fix(mnemosyne): corrected mnemosyne to await pending fact extractions

- Added pending extraction tracking to BeamMemory and a flush loop that waits for queued extraction promises.
- Added Mnemosyne.flushExtractions export and awaited it after forced session retention in coding-agent backend.
- Implemented remember/rememberBatch extraction scheduling with runFactExtraction, skipping empty content and swallowing extraction errors.
- Replaced extractAndStoreFacts insertion logic with storeFactStrings and updated entity counts from its return value.
- Added extraction wiring tests and changelog notes for extraction shutdown and todo animation visibility behavior.
This commit is contained in:
can1357
2026-05-30 19:00:26 +02:00
parent 0eee5a4019
commit c32460b2c5
10 changed files with 189 additions and 21 deletions
+3 -1
View File
@@ -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.
@@ -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) });
+5
View File
@@ -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.
+31 -20
View File
@@ -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,
@@ -117,6 +117,7 @@ export class BeamMemory implements BeamMemoryState {
readonly veracityConsolidator: unknown | null;
readonly caches: BeamCaches;
readonly config: BeamConfig;
readonly pendingExtractions: Set<Promise<void>> = new Set();
#closed = false;
constructor(options?: BeamMemoryOptions);
@@ -193,6 +194,12 @@ export class BeamMemory implements BeamMemoryState {
closeQuietly(this.db);
}
async flushExtractions(): Promise<void> {
while (this.pendingExtractions.size > 0) {
await Promise.allSettled([...this.pendingExtractions]);
}
}
remember(content: string, options: RememberOptions = {}): string {
return remember(this, content, options);
}
+47
View File
@@ -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<void> {
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;
}
@@ -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<Promise<void>>;
}
export interface AnnotationWriteOptions {
+8
View File
@@ -388,6 +388,10 @@ export class Mnemosyne {
if (this.#ownsDb) this.beam.close();
}
async flushExtractions(): Promise<void> {
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<void> {
return defaultFor(bank).flushExtractions();
}
export function scratchpadWrite(content: string, bank: string | null = null): string {
return defaultFor(bank).scratchpadWrite(content);
}
+1
View File
@@ -4,6 +4,7 @@ export * from "./core/llm-backends";
export * from "./core/memory";
export {
addMemory,
flushExtractions,
forget,
get,
getBank,
@@ -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);
});
});