diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 62da9b451..d669bd393 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,7 +1,6 @@ # Changelog ## [Unreleased] - ### Added - Added prompt-mode autocomplete for supported internal URL schemes (`skill://`, `rule://`, `agent://`, `artifact://`, `local://`, `memory://`, and `omp://`) so typing those tokens now suggests existing resources as completion candidates @@ -25,14 +24,17 @@ - Changed the `task` tool's streaming call preview to list each dispatched agent's `id` and UI description as a tree instead of a bare `N agents` count, so the individual agents are visible while the tool-call arguments are still streaming. The collapsed view caps at 12 entries (`… N more agents`); the expanded view shows all. - Changed Mnemosyne `recall` tool output to include memory ids for explicit recall results so agents can target `memory_edit`; auto-injected memory context and `reflect` remain id-free. - Changed the system prompt to advertise `memory://root` only when the local memory backend is active. +- Changed `todo_write` result rendering to animate completed items in place: the checkbox flips checked first, then the strikethrough reveals across the task text. ### Removed - Removed the standalone `ask`, `task`, and `yield` tools along with their obsolete prompts, docs, and tests; delegation now routes through persistent `delegate` agents plus IRC coordination. - Removed the `/orchestrate` slash command; orchestration is now triggered by the `orchestrate` keyword (see Added) so the contract rides alongside the user's own prompt instead of replacing it. +- Removed the sticky Todos panel all-done drop/collapse animation; completed todo state now stays visible until the next explicit todo update changes it. ### Fixed +- Fixed Mnemosyne session shutdown to flush queued memory extractions before exit so the last turn’s facts are not lost - Fixed a native crash (`malloc: pointer being freed was not allocated` / `NAPI FATAL ERROR`) when quitting after the local transformers.js title model had run. The tiny-title worker no longer calls `pipeline.dispose()` on shutdown — disposing the onnxruntime session freed native memory that Bun's worker/NAPI teardown then freed again. The worker is torn down immediately after, so the OS reclaims the model memory regardless. - Fixed the tiny-title download progress bar flashing on every first message even when the local model was already downloaded. A cached model emits the same `download`/`progress` events as a real download, so the bar is now revealed only when in-flight progress events keep arriving past a short grace window — cache hits finish (or fall silent during onnxruntime init) before then and never show the bar. - Fixed the Mnemosyne memory backend lifecycle so auto-retain counts the full session transcript, delegated agents inherit the parent Mnemosyne state, `/memory clear` removes scoped project-bank databases, session disposal closes Mnemosyne SQLite handles, session switches rekey/reset Mnemosyne tracking, and project bank names include an absolute-root hash with safe bank-name sanitization. diff --git a/packages/coding-agent/src/mnemosyne/backend.ts b/packages/coding-agent/src/mnemosyne/backend.ts index 756220545..6a6e05d52 100644 --- a/packages/coding-agent/src/mnemosyne/backend.ts +++ b/packages/coding-agent/src/mnemosyne/backend.ts @@ -113,6 +113,9 @@ export const mnemosyneBackend: MemoryBackend = { setMnemosyneSessionState(session, state); } await state?.forceRetainCurrentSession(); + // Drain the background fact extraction scheduled by the final retain + // before the process can exit, otherwise the last turn's facts are lost. + await state?.memory.flushExtractions(); state?.memory.sleepAllSessions(false); } catch (error) { logger.warn("Mnemosyne: enqueue failed.", { error: String(error) }); diff --git a/packages/mnemosyne/CHANGELOG.md b/packages/mnemosyne/CHANGELOG.md index 5a1f477a4..acb70761d 100644 --- a/packages/mnemosyne/CHANGELOG.md +++ b/packages/mnemosyne/CHANGELOG.md @@ -1,12 +1,14 @@ # Changelog ## [Unreleased] + ### Added - Added `llm.extractionPrompt` runtime option to override the fact-extraction prompt template using `{text}` and `{lang}` placeholders - Added `llm.consolidationPrompt` runtime option to override the consolidation sleep prompt template using `{memories}`, `{source}`, and `{memory_count}` placeholders - Published `@oh-my-pi/pi-mnemosyne` to npm: the local SQLite memory engine is now built, checked, tested, and released through the monorepo CI pipeline alongside the other workspace packages. - Exported the diagnostic inspector as the `@oh-my-pi/pi-mnemosyne/diagnose` subpath for coding-agent memory maintenance commands. +- Added `flushExtractions()` (on `Mnemosyne`, `BeamMemory`, and as a module-level export) to drain in-flight background fact extraction; used by tests and graceful shutdown so facts are persisted before the database closes. ### Changed @@ -14,4 +16,7 @@ ### Fixed +- Fixed `rememberBatch(..., { extract: true })` to run background fact extraction for batch uploads (including per-item `extract` flags) so extracted facts are generated and recallable after extraction +- Fixed `extract: true` fact extraction to continue safely when no LLM is configured by turning extraction failures into no-op background tasks - Fixed configured LLM fact extraction by using temperature 0 so re-ingesting the same text is deterministic and avoids near-duplicate extractions +- Fixed `remember(..., { extract: true })` silently dropping the flag: it now schedules the LLM fact extractor (`extractFactsSafe`) over the stored content and persists the extracted facts so they become recallable. Previously the LLM extractor had no production callers and `extract` was dead. diff --git a/packages/mnemosyne/src/core/beam/consolidate.ts b/packages/mnemosyne/src/core/beam/consolidate.ts index 932a35400..9ddf910cc 100644 --- a/packages/mnemosyne/src/core/beam/consolidate.ts +++ b/packages/mnemosyne/src/core/beam/consolidate.ts @@ -362,6 +362,36 @@ export function detectLanguage(_beam: BeamMemoryState, text: string): string { } return spanish >= 3 ? "es" : "en"; } +export function storeFactStrings( + beam: BeamMemoryState, + facts: readonly string[], + messageIdx = 0, + sourceMemoryId: string | null = null, + importance = 0.7, +): number { + let stored = 0; + for (const fact of facts) { + insertFactRows(beam, messageIdx, "entity", "fact", fact, fact, importance, sourceMemoryId); + stored++; + const pref = /^The user (prefers|dislikes) (.+)$/i.exec(fact); + if (pref?.[2]) { + beam.db.run( + `INSERT INTO memoria_preferences (session_id, message_idx, preference, topic, evolution, context_snippet, source_memory_id) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + [sourceSession(beam), messageIdx, fact, pref[2], null, fact, sourceMemoryId], + ); + } + const instruction = /^Instruction: (.+)$/i.exec(fact); + if (instruction?.[1]) { + beam.db.run( + `INSERT INTO memoria_instructions (session_id, message_idx, instruction, active, topic, context_snippet, source_memory_id) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + [sourceSession(beam), messageIdx, instruction[1], 1, null, fact, sourceMemoryId], + ); + } + } + return stored; +} export function extractAndStoreFacts( beam: BeamMemoryState, content: string, @@ -437,26 +467,7 @@ export function extractAndStoreFacts( counts.version++; } - for (const fact of heuristicExtractFacts(text)) { - insertFactRows(beam, messageIdx, "entity", "fact", fact, fact, 0.7, sourceMemoryId); - counts.entity++; - const pref = /^The user (prefers|dislikes) (.+)$/i.exec(fact); - if (pref?.[2]) { - beam.db.run( - `INSERT INTO memoria_preferences (session_id, message_idx, preference, topic, evolution, context_snippet, source_memory_id) - VALUES (?, ?, ?, ?, ?, ?, ?)`, - [sourceSession(beam), messageIdx, fact, pref[2], null, fact, sourceMemoryId], - ); - } - const instruction = /^Instruction: (.+)$/i.exec(fact); - if (instruction?.[1]) { - beam.db.run( - `INSERT INTO memoria_instructions (session_id, message_idx, instruction, active, topic, context_snippet, source_memory_id) - VALUES (?, ?, ?, ?, ?, ?, ?)`, - [sourceSession(beam), messageIdx, instruction[1], 1, null, fact, sourceMemoryId], - ); - } - } + counts.entity += storeFactStrings(beam, heuristicExtractFacts(text), messageIdx, sourceMemoryId); for (const match of text.matchAll( /\b([A-Z][A-Za-z0-9_-]{2,})\s+(?:is|uses|runs|owns|depends on)\s+([^.!?;]{2,80})/g, diff --git a/packages/mnemosyne/src/core/beam/index.ts b/packages/mnemosyne/src/core/beam/index.ts index 0b9d964b5..52cf68192 100644 --- a/packages/mnemosyne/src/core/beam/index.ts +++ b/packages/mnemosyne/src/core/beam/index.ts @@ -117,6 +117,7 @@ export class BeamMemory implements BeamMemoryState { readonly veracityConsolidator: unknown | null; readonly caches: BeamCaches; readonly config: BeamConfig; + readonly pendingExtractions: Set> = new Set(); #closed = false; constructor(options?: BeamMemoryOptions); @@ -193,6 +194,12 @@ export class BeamMemory implements BeamMemoryState { closeQuietly(this.db); } + async flushExtractions(): Promise { + while (this.pendingExtractions.size > 0) { + await Promise.allSettled([...this.pendingExtractions]); + } + } + remember(content: string, options: RememberOptions = {}): string { return remember(this, content, options); } diff --git a/packages/mnemosyne/src/core/beam/store.ts b/packages/mnemosyne/src/core/beam/store.ts index fbf688994..6b972a118 100644 --- a/packages/mnemosyne/src/core/beam/store.ts +++ b/packages/mnemosyne/src/core/beam/store.ts @@ -3,6 +3,9 @@ import { transaction } from "../../db"; import { toUtcIso } from "../../util/datetime"; import { generateId } from "../../util/ids"; import { EpisodicGraph } from "../episodic-graph"; +import { extractFactsSafe } from "../extraction"; +import { getMnemosyneRuntimeOptions, withMnemosyneRuntimeOptions } from "../runtime-options"; +import { storeFactStrings } from "./consolidate"; import { vecAvailable, vecInsert } from "./helpers"; import type { BeamEvent, @@ -204,6 +207,43 @@ function proactiveLinkIfEnabled( } } +/** + * Run the LLM fact extractor over freshly stored content and persist the + * resulting facts. Best-effort: failures (no LLM, closed DB, malformed output) + * are swallowed so they can never disrupt the synchronous `remember` that + * scheduled them. + */ +async function runFactExtraction(beam: BeamMemoryState, memoryId: string, content: string): Promise { + try { + const facts = await extractFactsSafe(content); + if (facts.length === 0) return; + storeFactStrings(beam, facts, 0, memoryId); + invalidateCaches(beam); + } catch { + // Background fact extraction is best-effort and never surfaces to the caller. + } +} + +/** + * Schedule background fact extraction for a stored memory. `remember` is + * synchronous, so the async extractor is fired-and-forgotten; the promise is + * tracked on `beam.pendingExtractions` so callers can drain it via + * `flushExtractions()` (tests, graceful shutdown). The active runtime options + * (host LLM `complete`, model, prompt overrides) are captured here and + * re-entered inside the task because the AsyncLocalStorage scope set by + * `Mnemosyne.#withRuntimeOptions` has already exited by the time the task runs. + */ +function scheduleFactExtraction(beam: BeamMemoryState, memoryId: string, content: string): void { + if (content.trim() === "") return; + const runtimeOptions = getMnemosyneRuntimeOptions(); + const task = withMnemosyneRuntimeOptions(runtimeOptions, () => runFactExtraction(beam, memoryId, content)); + const pending = beam.pendingExtractions; + if (pending !== undefined) { + pending.add(task); + void task.finally(() => pending.delete(task)); + } +} + function rowToDict(row: Row): Row { return { ...row }; } @@ -301,6 +341,7 @@ export function remember(beam: BeamMemoryState, content: string, options: StoreR importance, metadata: metadata ?? undefined, }); + if (options.extract === true) scheduleFactExtraction(beam, memoryId, content); invalidateCaches(beam); return memoryId; } @@ -363,6 +404,12 @@ export function rememberBatch( trimWorkingMemory(beam); }); invalidateCaches(beam); + items.forEach((item, index) => { + const id = ids[index]; + if (id !== undefined && (item.extract === true || options.extract === true)) { + scheduleFactExtraction(beam, id, item.content); + } + }); return ids; } diff --git a/packages/mnemosyne/src/core/beam/types.ts b/packages/mnemosyne/src/core/beam/types.ts index d06085e03..9b4529e78 100644 --- a/packages/mnemosyne/src/core/beam/types.ts +++ b/packages/mnemosyne/src/core/beam/types.ts @@ -85,6 +85,8 @@ export interface BeamMemoryState { veracityConsolidator: unknown | null; caches: BeamCaches; config: BeamConfig; + /** Tracks in-flight background fact-extraction tasks scheduled by `remember(..., { extract: true })`. */ + pendingExtractions?: Set>; } export interface AnnotationWriteOptions { diff --git a/packages/mnemosyne/src/core/memory.ts b/packages/mnemosyne/src/core/memory.ts index c2fcc5cfe..1a9cff068 100644 --- a/packages/mnemosyne/src/core/memory.ts +++ b/packages/mnemosyne/src/core/memory.ts @@ -388,6 +388,10 @@ export class Mnemosyne { if (this.#ownsDb) this.beam.close(); } + async flushExtractions(): Promise { + await this.beam.flushExtractions(); + } + remember(memory: string | RememberInput, options: RememberFacadeOptions = {}): string { const content = typeof memory === "string" ? memory : memory.content; return this.#withRuntimeOptions(() => this.beam.remember(content, toRememberOptions(memory, options))); @@ -560,6 +564,10 @@ export function sleepAllSessions(dryRun = false, bank: string | null = null): Sl return defaultFor(bank).sleepAllSessions(dryRun); } +export function flushExtractions(bank: string | null = null): Promise { + return defaultFor(bank).flushExtractions(); +} + export function scratchpadWrite(content: string, bank: string | null = null): string { return defaultFor(bank).scratchpadWrite(content); } diff --git a/packages/mnemosyne/src/index.ts b/packages/mnemosyne/src/index.ts index a150c8f86..b0334f001 100644 --- a/packages/mnemosyne/src/index.ts +++ b/packages/mnemosyne/src/index.ts @@ -4,6 +4,7 @@ export * from "./core/llm-backends"; export * from "./core/memory"; export { addMemory, + flushExtractions, forget, get, getBank, diff --git a/packages/mnemosyne/test/extraction-wiring.test.ts b/packages/mnemosyne/test/extraction-wiring.test.ts new file mode 100644 index 000000000..91309253c --- /dev/null +++ b/packages/mnemosyne/test/extraction-wiring.test.ts @@ -0,0 +1,82 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import { Mnemosyne } from "../src/core/memory"; +import type { MnemosyneLlmCompletion } from "../src/core/runtime-options"; + +const instances: Mnemosyne[] = []; + +afterEach(async () => { + for (const memory of instances) { + await memory.flushExtractions(); + memory.close(); + } + instances.length = 0; +}); + +function makeMemory(llm: false | { complete: MnemosyneLlmCompletion }): Mnemosyne { + const memory = new Mnemosyne({ + sessionId: "extract-wiring", + dbPath: ":memory:", + llm: llm === false ? false : { enabled: true, complete: llm.complete }, + }); + instances.push(memory); + return memory; +} + +describe("remember(extract) wires the LLM fact extractor", () => { + it("runs the configured completion and makes extracted facts recallable", async () => { + let calls = 0; + const memory = makeMemory({ + complete: prompt => { + calls += 1; + expect(prompt).toContain("dark roast"); + return "The user loves coffee\nThe user prefers dark roast"; + }, + }); + + const id = memory.remember("I love coffee, especially dark roast.", { + source: "test", + extract: true, + }); + expect(id).toBeTruthy(); + + // Extraction is fired-and-forgotten by the synchronous `remember`; drain it. + await memory.flushExtractions(); + + expect(calls).toBe(1); + expect(memory.beam.factRecall("coffee", 5).some(fact => fact.content.includes("coffee"))).toBe(true); + expect(memory.beam.factRecall("dark roast", 5).some(fact => fact.content.includes("dark roast"))).toBe(true); + }); + + it("does not invoke the extractor when extract is not requested", async () => { + let calls = 0; + const memory = makeMemory({ + complete: () => { + calls += 1; + return "The user loves coffee"; + }, + }); + + memory.remember("I love coffee, especially dark roast.", { source: "test" }); + await memory.flushExtractions(); + + expect(calls).toBe(0); + expect(memory.beam.factRecall("coffee", 5)).toHaveLength(0); + }); + + it("stores the memory without throwing when extraction has no LLM", async () => { + const memory = makeMemory(false); + + const id = memory.remember("Some opaque payload with no extractable facts: zzz qqq.", { + source: "test", + extract: true, + }); + expect(id).toBeTruthy(); + + // Must resolve cleanly even though no LLM is configured. + await expect(memory.flushExtractions()).resolves.toBeUndefined(); + + // The memory itself is still durably stored and recallable. + const recalled = memory.recall("opaque payload", 5); + expect(recalled.some(row => row.id === id)).toBe(true); + }); +});