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:
@@ -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) });
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user