Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
19 changes: 13 additions & 6 deletions src/curator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, number>();

export async function run(input: {
client: Client;
Expand All @@ -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");
Expand Down Expand Up @@ -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();
}
}

/**
Expand Down
33 changes: 22 additions & 11 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}

Expand Down Expand Up @@ -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);
Expand All @@ -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.
Expand Down Expand Up @@ -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);
Expand Down
4 changes: 2 additions & 2 deletions test/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});

Expand Down
Loading