Files
1jehuang--jcode/crates/jcode-base/src/memory_log.rs
T
wehub-resource-sync a789495a98
FreeBSD Smoke / FreeBSD Smoke (x86_64) (push) Has been cancelled
CI / Quality Guardrails (push) Has been cancelled
CI / Build & Test (macos-latest) (push) Has been cancelled
CI / Build & Test (ubuntu-latest) (push) Has been cancelled
CI / Build & Test (windows-latest) (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / PowerShell Syntax (push) Has been cancelled
CI / Windows Cross-Target Check (Linux) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:10:34 +08:00

485 lines
14 KiB
Rust

//! Persistent memory event log for post-session analysis.
//!
//! Writes structured JSONL (one JSON object per line) to:
//! `~/.jcode/logs/memory-events-YYYY-MM-DD.jsonl`
//!
//! Every memory pipeline event - embedding search, sidecar verification,
//! injection, extraction, maintenance, tool actions - is captured with
//! wall-clock timestamps, session ID, and full details.
//!
//! Logs are kept for 14 days (separate from general log rotation).
use crate::memory_types::MemoryEventKind;
use chrono::Local;
use serde::Serialize;
use std::fs::{self, File, OpenOptions};
use std::io::Write;
use std::path::PathBuf;
use std::sync::Mutex;
static MEMORY_LOGGER: Mutex<Option<MemoryLogger>> = Mutex::new(None);
struct MemoryLogger {
file: File,
current_date: String,
}
impl MemoryLogger {
fn open(date: &str) -> Option<Self> {
let dir = log_dir()?;
fs::create_dir_all(&dir).ok()?;
let path = dir.join(format!("memory-events-{}.jsonl", date));
let file = OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.ok()?;
Some(Self {
file,
current_date: date.to_string(),
})
}
fn write_entry(&mut self, entry: &LogEntry) {
if let Ok(json) = serde_json::to_string(entry) {
let _ = writeln!(self.file, "{}", json);
let _ = self.file.flush();
}
}
}
fn log_dir() -> Option<PathBuf> {
dirs::home_dir().map(|h| h.join(".jcode").join("logs"))
}
fn ensure_logger(date: &str) -> bool {
if let Ok(mut guard) = MEMORY_LOGGER.lock() {
if let Some(ref logger) = *guard
&& logger.current_date == date
{
return true;
}
*guard = MemoryLogger::open(date);
guard.is_some()
} else {
false
}
}
#[derive(Serialize)]
struct LogEntry {
timestamp: String,
session_id: Option<String>,
event: String,
#[serde(skip_serializing_if = "Option::is_none")]
detail: Option<serde_json::Value>,
}
fn current_session_id() -> Option<String> {
crate::logging::current_session()
}
fn write_log(event: &str, detail: Option<serde_json::Value>) {
let now = Local::now();
let date = now.format("%Y-%m-%d").to_string();
if !ensure_logger(&date) {
return;
}
let entry = LogEntry {
timestamp: now.format("%Y-%m-%dT%H:%M:%S%.3f%z").to_string(),
session_id: current_session_id(),
event: event.to_string(),
detail,
};
if let Ok(mut guard) = MEMORY_LOGGER.lock()
&& let Some(logger) = guard.as_mut()
{
logger.write_entry(&entry);
}
}
/// Log a memory event from the in-memory event system.
pub fn log_event(kind: &MemoryEventKind) {
let (event, detail) = match kind {
MemoryEventKind::EmbeddingStarted => ("embedding_started", None),
MemoryEventKind::EmbeddingComplete { latency_ms, hits } => (
"embedding_complete",
Some(serde_json::json!({
"latency_ms": latency_ms,
"hits": hits,
})),
),
MemoryEventKind::SidecarStarted => ("sidecar_started", None),
MemoryEventKind::SidecarRelevant { memory_preview } => (
"sidecar_relevant",
Some(serde_json::json!({
"memory_preview": memory_preview,
})),
),
MemoryEventKind::SidecarNotRelevant => ("sidecar_not_relevant", None),
MemoryEventKind::SidecarComplete { latency_ms } => (
"sidecar_complete",
Some(serde_json::json!({
"latency_ms": latency_ms,
})),
),
MemoryEventKind::MemorySurfaced { memory_preview } => (
"memory_surfaced",
Some(serde_json::json!({
"memory_preview": memory_preview,
})),
),
MemoryEventKind::MemoryInjected {
count,
prompt_chars,
age_ms,
preview,
items,
} => (
"memory_injected",
Some(serde_json::json!({
"count": count,
"prompt_chars": prompt_chars,
"age_ms": age_ms,
"preview": preview,
"items": items.iter().map(|i| serde_json::json!({
"section": i.section,
"content": i.content,
})).collect::<Vec<_>>(),
})),
),
MemoryEventKind::MaintenanceStarted { verified, rejected } => (
"maintenance_started",
Some(serde_json::json!({
"verified": verified,
"rejected": rejected,
})),
),
MemoryEventKind::MaintenanceLinked { links } => (
"maintenance_linked",
Some(serde_json::json!({ "links": links })),
),
MemoryEventKind::MaintenanceConfidence { boosted, decayed } => (
"maintenance_confidence",
Some(serde_json::json!({
"boosted": boosted,
"decayed": decayed,
})),
),
MemoryEventKind::MaintenanceCluster { clusters, members } => (
"maintenance_cluster",
Some(serde_json::json!({
"clusters": clusters,
"members": members,
})),
),
MemoryEventKind::MaintenanceTagInferred { tag, applied } => (
"maintenance_tag_inferred",
Some(serde_json::json!({
"tag": tag,
"applied": applied,
})),
),
MemoryEventKind::MaintenanceGap { candidates } => (
"maintenance_gap",
Some(serde_json::json!({ "candidates": candidates })),
),
MemoryEventKind::MaintenanceComplete { latency_ms } => (
"maintenance_complete",
Some(serde_json::json!({ "latency_ms": latency_ms })),
),
MemoryEventKind::ExtractionStarted { reason } => (
"extraction_started",
Some(serde_json::json!({ "reason": reason })),
),
MemoryEventKind::ExtractionComplete { count } => (
"extraction_complete",
Some(serde_json::json!({ "count": count })),
),
MemoryEventKind::Error { message } => {
("error", Some(serde_json::json!({ "message": message })))
}
MemoryEventKind::ToolRemembered {
content,
scope,
category,
} => (
"tool_remembered",
Some(serde_json::json!({
"content": content,
"scope": scope,
"category": category,
})),
),
MemoryEventKind::ToolRecalled { query, count } => (
"tool_recalled",
Some(serde_json::json!({
"query": query,
"count": count,
})),
),
MemoryEventKind::ToolForgot { id } => {
("tool_forgot", Some(serde_json::json!({ "id": id })))
}
MemoryEventKind::ToolTagged { id, tags } => (
"tool_tagged",
Some(serde_json::json!({
"id": id,
"tags": tags,
})),
),
MemoryEventKind::ToolLinked { from, to } => (
"tool_linked",
Some(serde_json::json!({
"from": from,
"to": to,
})),
),
MemoryEventKind::ToolListed { count } => {
("tool_listed", Some(serde_json::json!({ "count": count })))
}
};
write_log(event, detail);
}
/// Log when a pending memory is prepared (before it's actually injected).
pub fn log_pending_prepared(session_id: &str, prompt: &str, count: usize, memory_ids: &[String]) {
write_log(
"pending_prepared",
Some(serde_json::json!({
"target_session": session_id,
"count": count,
"prompt_chars": prompt.chars().count(),
"prompt_preview": jcode_core::util::truncate_str(prompt, 500),
"memory_ids": memory_ids,
})),
);
}
/// Log when memories are marked as injected (dedup tracking).
pub fn log_marked_injected(session_id: &str, ids: &[String]) {
if ids.is_empty() {
return;
}
write_log(
"marked_injected",
Some(serde_json::json!({
"target_session": session_id,
"memory_ids": ids,
})),
);
}
/// Log when memories are marked as already known to a session without an
/// injection (e.g. extracted from that session's own transcript).
pub fn log_marked_known(session_id: &str, ids: &[String], reason: &str) {
if ids.is_empty() {
return;
}
write_log(
"marked_known",
Some(serde_json::json!({
"target_session": session_id,
"memory_ids": ids,
"reason": reason,
})),
);
}
/// Log when a pending memory is consumed (actually injected into context).
pub fn log_pending_consumed(session_id: &str, count: usize, age_ms: u64, prompt_chars: usize) {
write_log(
"pending_consumed",
Some(serde_json::json!({
"target_session": session_id,
"count": count,
"age_ms": age_ms,
"prompt_chars": prompt_chars,
})),
);
}
/// Log when a pending memory is discarded (stale, duplicate, etc.)
pub fn log_pending_discarded(session_id: &str, reason: &str) {
write_log(
"pending_discarded",
Some(serde_json::json!({
"target_session": session_id,
"reason": reason,
})),
);
}
/// Log topic change detection (which triggers extraction).
pub fn log_topic_change(session_id: &str, old_topic: &str, new_topic: &str) {
write_log(
"topic_change",
Some(serde_json::json!({
"target_session": session_id,
"old_topic": old_topic,
"new_topic": new_topic,
})),
);
}
/// Log final extraction trigger (session end).
pub fn log_final_extraction(session_id: &str, transcript_chars: usize) {
write_log(
"final_extraction_started",
Some(serde_json::json!({
"target_session": session_id,
"transcript_chars": transcript_chars,
})),
);
}
/// Log embedding candidate filtering results.
pub fn log_candidate_filter(
session_id: &str,
total_candidates: usize,
after_dedup: usize,
context_preview: &str,
) {
write_log(
"candidate_filter",
Some(serde_json::json!({
"target_session": session_id,
"total_candidates": total_candidates,
"after_dedup": after_dedup,
"context_preview": jcode_core::util::truncate_str(context_preview, 200),
})),
);
}
/// Log the terminal judge decision for a memory surfacing turn. This is the
/// per-session attribution record backing the no-LLM conversion metric (see
/// `memory_judge_metrics`).
pub fn log_judge_decision(
session_id: &str,
decision: &str,
no_llm: bool,
degradation: bool,
candidate_count: usize,
) {
write_log(
"judge_decision",
Some(serde_json::json!({
"target_session": session_id,
"decision": decision,
"no_llm": no_llm,
"degradation": degradation,
"candidate_count": candidate_count,
})),
);
}
/// Remove `memory-events-*.jsonl` files older than the documented 14-day
/// retention window. The general log rotation deliberately leaves these files
/// alone (they are analysis data, not debug logs), so this is the only place
/// that prunes them.
pub fn cleanup_old_memory_logs() {
if let Some(dir) = log_dir() {
cleanup_old_memory_logs_in(&dir, Local::now());
}
}
/// Core of [`cleanup_old_memory_logs`], parameterized on the directory and
/// "now" for unit testing without touching the real log directory.
fn cleanup_old_memory_logs_in(dir: &std::path::Path, now: chrono::DateTime<Local>) {
let Ok(entries) = fs::read_dir(dir) else {
return;
};
let cutoff = now - chrono::Duration::days(14);
for entry in entries.flatten() {
let name = entry.file_name();
let Some(name) = name.to_str() else { continue };
if !(name.starts_with("memory-events-") && name.ends_with(".jsonl")) {
continue;
}
if let Ok(metadata) = entry.metadata()
&& metadata.is_file()
&& let Ok(modified) = metadata.modified()
{
let modified: chrono::DateTime<Local> = modified.into();
if modified < cutoff {
let _ = fs::remove_file(entry.path());
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::{Duration, SystemTime};
#[test]
fn memory_log_cleanup_respects_14_day_window() {
let dir = std::env::temp_dir().join(format!(
"jcode-mem-cleanup-test-{}-{}",
std::process::id(),
SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0)
));
fs::create_dir_all(&dir).expect("create temp dir");
let write = |name: &str, age_days: u64| {
let path = dir.join(name);
let mut f = File::create(&path).expect("create");
f.write_all(b"{}\n").ok();
if age_days > 0 {
let mtime = SystemTime::now() - Duration::from_secs(age_days * 24 * 60 * 60);
f.set_modified(mtime).expect("set mtime");
}
path
};
// 20 days old: past the 14-day window -> deleted.
let old = write("memory-events-2000-01-01.jsonl", 20);
// 10 days old: within the window -> kept.
let recent = write("memory-events-2000-02-01.jsonl", 10);
// Old, but not a memory-events file -> must be left alone here.
let other = write("jcode-2000-01-01.log", 20);
cleanup_old_memory_logs_in(&dir, Local::now());
assert!(!old.exists(), "20-day-old memory log should be deleted");
assert!(recent.exists(), "10-day-old memory log must survive");
assert!(
other.exists(),
"non-memory files are out of scope for this cleanup"
);
fs::remove_dir_all(&dir).ok();
}
}