From c3db84a5d23a026a2f83109bac5ca181f3e21c38 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Wed, 8 Apr 2026 19:13:17 +0000 Subject: [PATCH] fix: lower curation threshold, add per-session tracking, and improve observability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Curation was effectively never firing on typical agentic sessions because afterTurns defaulted to 10 user messages — most sessions have 3-5. The counter also resets on every OpenCode restart. Additionally, the module-level lastCuratedAt timestamp leaked across sessions, causing curation on session B to skip messages created before session A's curation. - Lower afterTurns default from 10 to 3 - Make lastCuratedAt a per-session Map instead of a shared number - Add diagnostic logging: curation skip reason, activeSessions gate, empty export detection, message.updated fetch failures --- src/config.ts | 4 ++-- src/curator.ts | 19 +++++++++++++------ src/index.ts | 33 ++++++++++++++++++++++----------- test/config.test.ts | 4 ++-- 4 files changed, 39 insertions(+), 21 deletions(-) diff --git a/src/config.ts b/src/config.ts index fae80df..2c0b873 100644 --- a/src/config.ts +++ b/src/config.ts @@ -37,11 +37,11 @@ export const LoreConfig = z.object({ .object({ enabled: z.boolean().default(true), onIdle: z.boolean().default(true), - afterTurns: z.number().min(1).default(10), + afterTurns: z.number().min(1).default(3), /** Max knowledge entries per project before consolidation triggers. Default: 25. */ maxEntries: z.number().min(10).default(25), }) - .default({ enabled: true, onIdle: true, afterTurns: 10, maxEntries: 25 }), + .default({ enabled: true, onIdle: true, afterTurns: 3, maxEntries: 25 }), pruning: z .object({ /** Days to keep distilled temporal messages before pruning. Default: 120. */ diff --git a/src/curator.ts b/src/curator.ts index c92d14e..9c1028a 100644 --- a/src/curator.ts +++ b/src/curator.ts @@ -64,8 +64,10 @@ function parseOps(text: string): CuratorOp[] { } } -// Track which messages we've already curated -let lastCuratedAt = 0; +// Track which messages we've already curated — per session to prevent +// cross-session leaking (curation on session A advancing the timestamp +// past session B's messages, causing B's curation to find < 3 recent). +const lastCuratedAt = new Map(); export async function run(input: { client: Client; @@ -78,7 +80,8 @@ export async function run(input: { // Get recent messages since last curation const all = temporal.bySession(input.projectPath, input.sessionID); - const recent = all.filter((m) => m.created_at > lastCuratedAt); + const sessionCuratedAt = lastCuratedAt.get(input.sessionID) ?? 0; + const recent = all.filter((m) => m.created_at > sessionCuratedAt); if (recent.length < 3) return { created: 0, updated: 0, deleted: 0 }; const text = recent.map((m) => `[${m.role}] ${m.content}`).join("\n\n"); @@ -170,12 +173,16 @@ export async function run(input: { } } - lastCuratedAt = Date.now(); + lastCuratedAt.set(input.sessionID, Date.now()); return { created, updated, deleted }; } -export function resetCurationTracker() { - lastCuratedAt = 0; +export function resetCurationTracker(sessionID?: string) { + if (sessionID) { + lastCuratedAt.delete(sessionID); + } else { + lastCuratedAt.clear(); + } } /** diff --git a/src/index.ts b/src/index.ts index ea275f6..9d9c6ce 100644 --- a/src/index.ts +++ b/src/index.ts @@ -299,8 +299,9 @@ export const LorePlugin: Plugin = async (ctx) => { calibrate(actualInput, msg.sessionID, getLastTransformedCount(msg.sessionID)); } } - } catch { + } catch (e) { // Message may not be fetchable yet during streaming + log.warn(`message.updated: failed to fetch message ${msg.id} for session ${msg.sessionID.substring(0, 16)}:`, e); } } @@ -377,7 +378,10 @@ export const LorePlugin: Plugin = async (ctx) => { if (event.type === "session.idle") { const sessionID = event.properties.sessionID; if (await shouldSkip(sessionID)) return; - if (!activeSessions.has(sessionID)) return; + if (!activeSessions.has(sessionID)) { + log.info(`session ${sessionID.substring(0, 16)} idle but not in activeSessions — skipping`); + return; + } // Run background distillation for any remaining undistilled messages await backgroundDistill(sessionID); @@ -388,13 +392,15 @@ export const LorePlugin: Plugin = async (ctx) => { // caused onIdle=true (default) to short-circuit, running the curator // on EVERY session.idle — an LLM worker call after every agent turn. const cfg = config(); - if ( - cfg.knowledge.enabled && - cfg.curator.onIdle && - turnsSinceCuration >= cfg.curator.afterTurns - ) { - await backgroundCurate(sessionID); - turnsSinceCuration = 0; + if (cfg.knowledge.enabled && cfg.curator.onIdle) { + if (turnsSinceCuration >= cfg.curator.afterTurns) { + await backgroundCurate(sessionID); + turnsSinceCuration = 0; + } else { + log.info( + `curation skipped: ${turnsSinceCuration}/${cfg.curator.afterTurns} user turns since last curation`, + ); + } } // Consolidate entries if count exceeds cfg.curator.maxEntries. @@ -444,8 +450,13 @@ export const LorePlugin: Plugin = async (ctx) => { try { const agentsCfg = cfg.agentsFile; if (isValidProjectPath(projectPath) && cfg.knowledge.enabled && agentsCfg.enabled) { - const filePath = join(projectPath, agentsCfg.path); - exportToFile({ projectPath, filePath }); + const entries = ltm.forProject(projectPath, false); + if (entries.length === 0) { + log.info("agents-file export: 0 knowledge entries for project, skipping write"); + } else { + const filePath = join(projectPath, agentsCfg.path); + exportToFile({ projectPath, filePath }); + } } } catch (e) { log.error("agents-file export error:", e); diff --git a/test/config.test.ts b/test/config.test.ts index 62d152b..4464c13 100644 --- a/test/config.test.ts +++ b/test/config.test.ts @@ -58,11 +58,11 @@ describe("LoreConfig — knowledge schema", () => { }); describe("LoreConfig — curator schema", () => { - test("curator defaults: enabled=true, onIdle=true, afterTurns=10, maxEntries=25", () => { + test("curator defaults: enabled=true, onIdle=true, afterTurns=3, maxEntries=25", () => { const cfg = LoreConfig.parse({}); expect(cfg.curator.enabled).toBe(true); expect(cfg.curator.onIdle).toBe(true); - expect(cfg.curator.afterTurns).toBe(10); + expect(cfg.curator.afterTurns).toBe(3); expect(cfg.curator.maxEntries).toBe(25); });