From d85ab05959a07fdbbb06d39282e2cd606d1e66c1 Mon Sep 17 00:00:00 2001 From: Atlas-Key <296298943+Atlas-Key@users.noreply.github.com> Date: Mon, 19 Oct 2026 08:23:26 -0400 Subject: [PATCH 1/9] feat(loa): replace source pins through one helper --- src/lib/memory.ts | 60 ++++++++++++++++++++++++++++------------------- 1 file changed, 36 insertions(+), 24 deletions(-) diff --git a/src/lib/memory.ts b/src/lib/memory.ts index 7e15c9e..29e5708 100644 --- a/src/lib/memory.ts +++ b/src/lib/memory.ts @@ -868,33 +868,45 @@ export function createLoaEntryFromMessages( source_ids: null, } : entry); if (!pinSources) return loaId; + replaceLoaMessagePins(loaId, messages, db); + return loaId; + })(); +} - const insertSource = db.prepare(` - INSERT INTO loa_message_sources ( - loa_id, ordinal, message_id, session_id, timestamp, role, content, - project, importance, provenance - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `); - for (const [ordinal, message] of messages.entries()) { - insertSource.run( - loaId, - ordinal, - message.id!, - message.session_id, - message.timestamp, - message.role, - message.content, - message.project ?? null, - message.importance ?? 5, - message.provenance ?? null - ); +/** Replace one LoA's source pins. Caller owns the transaction. */ +export function replaceLoaMessagePins( + loaId: number, + messages: readonly Message[], + db = getDb(), +): void { + db.prepare('DELETE FROM loa_message_sources WHERE loa_id = ?').run(loaId); + const insertSource = db.prepare(` + INSERT INTO loa_message_sources ( + loa_id, ordinal, message_id, session_id, timestamp, role, content, + project, importance, provenance + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + for (const [ordinal, message] of messages.entries()) { + if (!Number.isSafeInteger(message.id) || message.id! < 1) { + throw new Error('LoA source messages must have persisted IDs'); } - db.prepare('UPDATE loa_entries SET source_ids = ? WHERE id = ?').run( - JSON.stringify({ table: 'loa_message_sources', loa_id: loaId }), - loaId + insertSource.run( + loaId, + ordinal, + message.id!, + message.session_id, + message.timestamp, + message.role, + message.content, + message.project ?? null, + message.importance ?? 5, + message.provenance ?? null ); - return loaId; - })(); + } + db.prepare('UPDATE loa_entries SET source_ids = ? WHERE id = ?').run( + JSON.stringify({ table: 'loa_message_sources', loa_id: loaId }), + loaId + ); } export function invalidateRecordEmbedding( From 2b1cc25e0bb2dc793005dc2e4a59f222bef972c5 Mon Sep 17 00:00:00 2001 From: Atlas-Key <296298943+Atlas-Key@users.noreply.github.com> Date: Mon, 19 Oct 2026 08:23:28 -0400 Subject: [PATCH 2/9] feat(loa): write one curated extract for an omp session --- src/commands/loa.ts | 103 ++++++++++++++++++- src/index.ts | 11 +- tests/commands/loa-omp-end.test.ts | 158 +++++++++++++++++++++++++++++ 3 files changed, 270 insertions(+), 2 deletions(-) create mode 100644 tests/commands/loa-omp-end.test.ts diff --git a/src/commands/loa.ts b/src/commands/loa.ts index 9b500b5..5719a7d 100644 --- a/src/commands/loa.ts +++ b/src/commands/loa.ts @@ -1,10 +1,14 @@ // recall loa command - Library of Alexandria capture -import { createLoaEntryFromMessages, getMessagesSinceLastLoa, getLastLoaEntry, getLoaEntry, getLoaMessages } from '../lib/memory.js'; +import { createLoaEntryFromMessages, getMessagesSinceLastLoa, getLastLoaEntry, getLoaEntry, getLoaMessages, replaceLoaMessagePins } from '../lib/memory.js'; import { detectProject } from '../lib/project.js'; import { embed, embeddingToBlob, checkEmbeddingService } from '../lib/embeddings.js'; import { getDb } from '../db/connection.js'; import { formatMessagesForExtraction, runFabricExtract } from '../lib/extraction.js'; +import { getRecallHome } from '../lib/runtime-paths.js'; +import { unlinkSync } from 'fs'; +import { join } from 'path'; +import type { Message } from '../types/index.js'; interface LoaOptions { continues?: number; @@ -196,3 +200,100 @@ export function runLoaList(limit: number = 10): void { console.log(''); } } + +const OMP_CURATED_DESCRIPTION = 'Curated fabric extraction from omp session end.'; + +function releaseOmpLoaLock(sessionId: string): void { + try { + unlinkSync(join(getRecallHome(), `omp-loa-${sessionId}.pid`)); + } catch { + // absent on a hand start + } +} + +function loadOmpLifecycleMessages(sessionId: string): Message[] { + return getDb().prepare(` + SELECT m.id, m.session_id, m.timestamp, m.role, m.content, m.project, m.importance, m.provenance + FROM published_messages m + WHERE EXISTS ( + SELECT 1 FROM active_host_ingest_messages h + WHERE h.message_id = m.id AND h.source = 'omp' AND h.session_id = ? + ) + ORDER BY m.id + `).all(sessionId) as Message[]; +} + +function ompLifecycleHighWater(sessionId: string): number | null { + const row = getDb().prepare(` + SELECT MAX(message_id) AS id FROM active_host_ingest_messages + WHERE source = 'omp' AND session_id = ? + `).get(sessionId) as { id: number | null }; + return row.id; +} + +function writeOmpCurated( + sessionId: string, + messages: Message[], + text: string, + highWater: number, +): void { + const db = getDb(); + const session = db.prepare('SELECT project FROM sessions WHERE session_id = ?') + .get(sessionId) as { project: string | null } | undefined; + const existing = db.prepare(` + SELECT id FROM loa_entries WHERE session_id = ? AND description = ? + ORDER BY id DESC LIMIT 1 + `).get(sessionId, OMP_CURATED_DESCRIPTION) as { id: number } | undefined; + if (!existing) { + createLoaEntryFromMessages({ + title: `Omp session ${sessionId}`, + description: OMP_CURATED_DESCRIPTION, + fabric_extract: text, + snapshot_max_message_id: highWater, + session_id: sessionId, + project: session?.project || undefined, + tags: 'curated,omp', + message_count: messages.length, + provenance: 'extracted', + }, messages); + return; + } + db.transaction(() => { + db.prepare(` + UPDATE loa_entries + SET fabric_extract = ?, message_count = ?, snapshot_max_message_id = ? + WHERE id = ? + `).run(text, messages.length, highWater, existing.id); + replaceLoaMessagePins(existing.id, messages, db); + })(); +} + +export async function runLoaOmpEnd( + sessionId: string, + deps: { extract?: typeof runFabricExtract } = {}, +): Promise { + const extract = deps.extract ?? runFabricExtract; + try { + let messages = loadOmpLifecycleMessages(sessionId); + if (messages.length === 0) return 0; + let highWater = ompLifecycleHighWater(sessionId); + let text: string; + try { + text = extract(formatMessagesForExtraction(messages)); + if (ompLifecycleHighWater(sessionId) !== highWater) { + messages = loadOmpLifecycleMessages(sessionId); + highWater = ompLifecycleHighWater(sessionId); + if (messages.length === 0 || highWater == null) return 0; + text = extract(formatMessagesForExtraction(messages)); + if (ompLifecycleHighWater(sessionId) !== highWater) return 0; + } + } catch { + return 0; + } + if (highWater == null) return 0; + writeOmpCurated(sessionId, messages, text, highWater); + return 0; + } finally { + releaseOmpLoaLock(sessionId); + } +} diff --git a/src/index.ts b/src/index.ts index 7d98303..ed9485e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,7 +13,7 @@ import { runShow } from './commands/show.js'; import { runStats } from './commands/stats.js'; import { runImport } from './commands/import.js'; import { runImportConversations } from './commands/import-conversations.js'; -import { runLoa, runLoaQuote, runLoaShow, runLoaList } from './commands/loa.js'; +import { runLoa, runLoaOmpEnd, runLoaQuote, runLoaShow, runLoaList } from './commands/loa.js'; import { runDump } from './commands/dump.js'; import { runHostHook } from './commands/host-hook.js'; import { runCapture } from './commands/capture.js'; @@ -376,6 +376,15 @@ loaCmd closeDb(); }); +loaCmd + .command('omp-end ') + .description('Write one Curated LoA for an omp lifecycle session') + .action(async (sessionId: string) => { + const code = await runLoaOmpEnd(sessionId); + closeDb(); + process.exit(code); + }); + // recall import-legacy - Import DISTILLED.md extracts program .command('import-legacy') diff --git a/tests/commands/loa-omp-end.test.ts b/tests/commands/loa-omp-end.test.ts new file mode 100644 index 0000000..5f4673c --- /dev/null +++ b/tests/commands/loa-omp-end.test.ts @@ -0,0 +1,158 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { existsSync, mkdirSync, writeFileSync } from 'fs'; +import { join } from 'path'; +import { getDb } from '../../src/db/connection'; +import { runLoaOmpEnd } from '../../src/commands/loa'; +import { ExtractorConfigError } from '../../src/lib/extraction'; +import { ingestHostTranscript } from '../../src/lib/host-ingest'; +import { addMessage } from '../../src/lib/memory'; +import { getRecallHome } from '../../src/lib/runtime-paths'; +import { isolateTestHome, setupTestDb, teardownTestDb } from '../helpers/setup'; + +const BASIC = 'Automatic terminal extraction from omp lifecycle capture.'; +const CURATED = 'Curated fabric extraction from omp session end.'; + +let restoreHome: (() => void) | undefined; + +beforeEach(() => { + restoreHome = isolateTestHome(); + setupTestDb(); + mkdirSync(getRecallHome(), { recursive: true }); +}); + +afterEach(() => { + teardownTestDb(); + restoreHome?.(); + restoreHome = undefined; +}); + +function capture(sessionId: string, content: string, position: number): void { + ingestHostTranscript({ + source: 'omp', + sessionId, + project: 'recall-test', + finalize: true, + messages: [{ role: 'user', content, sourcePosition: position, nativeId: `n-${position}` }], + }); +} + +function curatedRows(sessionId: string) { + return getDb().prepare(` + SELECT id, fabric_extract, snapshot_max_message_id, tags, description, title, project + FROM loa_entries WHERE session_id = ? AND description = ? + `).all(sessionId, CURATED) as Array<{ + id: number; + fabric_extract: string; + snapshot_max_message_id: number | null; + tags: string; + title: string; + project: string | null; + }>; +} + +function basicExtract(sessionId: string): string { + return (getDb().prepare(` + SELECT fabric_extract FROM loa_entries WHERE session_id = ? AND description = ? AND tags = ? + `).get(sessionId, BASIC, 'automatic-capture,omp') as { fabric_extract: string }).fabric_extract; +} + +describe('recall loa omp-end', () => { + test('writes one curated row and leaves the basic summary', async () => { + capture('ses-1', 'lifecycle only', 0); + const before = basicExtract('ses-1'); + const seen: string[] = []; + expect(await runLoaOmpEnd('ses-1', { extract: (content) => { + seen.push(content); + return 'curated body'; + } })).toBe(0); + const rows = curatedRows('ses-1'); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + fabric_extract: 'curated body', + tags: 'curated,omp', + title: 'Omp session ses-1', + project: 'recall-test', + }); + expect(rows[0].snapshot_max_message_id).toBeNumber(); + expect(basicExtract('ses-1')).toBe(before); + expect(seen[0]).toContain('lifecycle only'); + expect(existsSync(join(getRecallHome(), 'omp-loa-ses-1.pid'))).toBe(false); + }); + + test('fabric failure exits 0 and does not add a curated row', async () => { + capture('ses-fail', 'keep basic', 0); + const before = basicExtract('ses-fail'); + const lock = join(getRecallHome(), 'omp-loa-ses-fail.pid'); + writeFileSync(lock, '1'); + expect(await runLoaOmpEnd('ses-fail', { + extract: () => { throw new ExtractorConfigError('no fabric', 'curated'); }, + })).toBe(0); + expect(curatedRows('ses-fail')).toEqual([]); + expect(basicExtract('ses-fail')).toBe(before); + expect(existsSync(lock)).toBe(false); + }); + + test('scores lifecycle rows and not an explicit dump on the same session', async () => { + capture('ses-mix', 'lifecycle line', 0); + addMessage({ + session_id: 'ses-mix', + timestamp: '2026-10-18T00:00:00.000Z', + role: 'user', + content: 'dump only line', + }); + let seen = ''; + await runLoaOmpEnd('ses-mix', { extract: (content) => { seen = content; return 'from lifecycle'; } }); + expect(seen).toContain('lifecycle line'); + expect(seen).not.toContain('dump only line'); + expect(curatedRows('ses-mix')).toHaveLength(1); + }); + + test('a second run updates the same curated id', async () => { + capture('ses-rerun', 'first turn', 0); + await runLoaOmpEnd('ses-rerun', { extract: () => 'first extract' }); + const first = curatedRows('ses-rerun')[0]; + capture('ses-rerun', 'second turn', 1); + expect(await runLoaOmpEnd('ses-rerun', { extract: () => 'second extract' })).toBe(0); + const rows = curatedRows('ses-rerun'); + expect(rows).toHaveLength(1); + expect(rows[0].id).toBe(first.id); + expect(rows[0].fabric_extract).toBe('second extract'); + expect(rows[0].snapshot_max_message_id).toBeGreaterThan(first.snapshot_max_message_id!); + }); + + test('a later capture during extract is not overwritten by the stale extract', async () => { + capture('ses-stale', 'first capture', 0); + let calls = 0; + expect(await runLoaOmpEnd('ses-stale', { extract: () => { + calls += 1; + if (calls === 1) { + capture('ses-stale', 'second capture', 1); + return 'stale extract'; + } + return 'fresh extract'; + } })).toBe(0); + const rows = curatedRows('ses-stale'); + expect(rows).toHaveLength(1); + expect(rows[0].fabric_extract).toBe('fresh extract'); + expect(calls).toBe(2); + }); + + test('a high-water move during the retry writes nothing', async () => { + capture('ses-move', 'first capture', 0); + let calls = 0; + expect(await runLoaOmpEnd('ses-move', { extract: () => { + calls += 1; + capture('ses-move', `capture ${calls}`, calls); + return `extract ${calls}`; + } })).toBe(0); + expect(curatedRows('ses-move')).toEqual([]); + expect(calls).toBe(2); + }); + + test('no lifecycle rows exits 0', async () => { + let called = false; + expect(await runLoaOmpEnd('missing', { extract: () => { called = true; return 'nope'; } })).toBe(0); + expect(called).toBe(false); + expect(curatedRows('missing')).toEqual([]); + }); +}); From ef84f7d7f2b6774418a15b8ddad8bacfabdeda87 Mon Sep 17 00:00:00 2001 From: Atlas-Key <296298943+Atlas-Key@users.noreply.github.com> Date: Mon, 19 Oct 2026 08:23:30 -0400 Subject: [PATCH 3/9] feat(omp): spawn curated extract only when the snapshot is behind --- AGENTS.md | 2 +- docs/OMP_INTEGRATION.md | 2 + hosts/omp/recall.ts | 92 ++++++++++++++++++++++++++++++++++- tests/omp-integration.test.ts | 85 +++++++++++++++++++++++++++++--- 4 files changed, 170 insertions(+), 11 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 5debddf..80c5d12 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -131,7 +131,7 @@ Before adding code or content, search for an existing definition and extend it. - **Uninstall preservation**: `--purge` snapshots canonical `identity.md` / `DISTILLED.md` with the databases and materializes them into Claude's MEMORY directory when that does not overwrite a foreign file. Installed root hooks and recursive `hooks/lib/` helpers must also appear in the matching `uninstall.sh` inventories; the uninstall test audits both. - **MCP registration**: User scope in `~/.claude/settings.json` (or `~/.claude.json` if managed by `claude mcp add`) under `mcpServers`. The `env.RECALL_DB_PATH` block is populated by the installer. - **Hook registration**: Claude events live in `~/.claude/settings.json`; Codex events live in `hosts/plugins/recall/hooks/hooks.json`; Grok capture uses the managed `~/.grok/hooks/RecallLifecycle.json` file. -- **omp capture ownership**: the root `package.json#omp.extensions` loads `hosts/omp/recall.ts` through omp's native plugin manager. Only the awaited main-session `session_stop` event captures; native task/subagent sessions are excluded by omp. The installer still owns skills only. The extension sends ordered branch text to package-local `dist/index.js capture` and does not write SQLite. No duplicate database writer or Pi fallback. +- **omp capture ownership**: the root `package.json#omp.extensions` loads `hosts/omp/recall.ts` through omp's native plugin manager. Only the awaited main-session `session_stop` event captures; native task/subagent sessions are excluded by omp. The installer still owns skills only. The extension sends ordered branch text to package-local `dist/index.js capture` and does not write SQLite. No duplicate database writer or Pi fallback. After a successful capture it detaches `recall loa omp-end` only when that session's Curated snapshot is missing or behind and no child is already running — not on every turn; fabric failure keeps the basic summary. See [omp integration](docs/OMP_INTEGRATION.md). - **Hooks are self-contained**: `RecallExtract.ts`, `RecallStart.ts`, etc. are standalone scripts symlinked into `~/.claude/hooks/` from `~/.agents/Recall/shared/hooks/`. They don't import from `src/`. DB-path ownership follows the [source contracts](src/AGENTS.md#local-contracts). - **Input-scaled SQL bind lists**: any `IN (...)` or multi-row `VALUES` whose placeholder count grows with input MUST chunk through `src/lib/chunk.ts`'s `chunked()` (conservative 500, under SQLite's bind-variable limit) — never bind the whole list at once. Because hooks can't import `src/` (see above), a hook with an input-scaled list inlines a local equivalent. Partly enforced by `tests/lib/chunk-audit.test.ts`, which fails when a new input-scaled placeholder list appears in a file that doesn't already route some query through `chunked()` (and isn't allowlisted fixed-size). The guard is **file-granular**, not per-statement: a file that already calls `chunked()` anywhere is exempt wholesale, so a new un-chunked sibling `IN (...)` added to such a file (e.g. `memory.ts`, `dump.ts`, `aging.ts`, `dedup.ts` — the bulk-SQL files most likely to grow one) is NOT caught. Chunk those by hand and rely on review, not the guard, there. - **Shell-to-JS safety**: `install.sh` passes variables to `bun -e` via environment variables, never shell interpolation in JS strings. diff --git a/docs/OMP_INTEGRATION.md b/docs/OMP_INTEGRATION.md index 13f8bb3..0d7975c 100644 --- a/docs/OMP_INTEGRATION.md +++ b/docs/OMP_INTEGRATION.md @@ -34,6 +34,8 @@ Do not install the npm package named `recall-memory`; that name is an unrelated This is turn-completion capture, not crash recovery. An interrupted turn before `session_stop` is not guaranteed captured. Shutdown and pre-compaction hooks are not installed. The active branch is read again on the next stop after a failure. +`session_stop` is still turn completion; after a successful capture the extension detaches `recall loa omp-end` only when that session's Curated snapshot is missing or behind the lifecycle high-water and no child is already running. Fabric failure keeps the basic summary. + Capture bounds its CLI child to 30 seconds and its serialized JSON payload to 25 MiB, including metadata, escaping, and the truncation marker. Excluded content does not count toward this limit. When the scrubbed text would exceed the limit, capture allocates 60% of the remaining JSON-escaped byte budget to the beginning and 40% to the end of the text, separated by `[Recall omp capture truncated]`; a warning reports the UTF-8 bytes dropped from the scrubbed text. Malformed branches are rejected before starting the child; malformed branches, cancellations, and child failures warn without stopping omp. ## Explicit dump: `recall_dump` tool diff --git a/hosts/omp/recall.ts b/hosts/omp/recall.ts index 4d1326f..4a4839f 100644 --- a/hosts/omp/recall.ts +++ b/hosts/omp/recall.ts @@ -2,8 +2,11 @@ // Self-contained — no src/ imports, no @oh-my-pi runtime import. import { spawn } from 'node:child_process'; +import { closeSync, openSync, unlinkSync } from 'node:fs'; +import { homedir } from 'node:os'; import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { Database } from 'bun:sqlite'; import type { z } from 'zod'; import { scrub } from '../../hooks/lib/write-safety'; @@ -334,11 +337,83 @@ export function ompBranchMessages(entries: unknown): OmpMessage[] { return messages; } +const OMP_CURATED_DESCRIPTION = 'Curated fabric extraction from omp session end.'; + +function ompLoaLockPath(sessionId: string): string { + const dir = process.env.RECALL_HOME || join(homedir(), '.agents', 'Recall'); + return join(dir, `omp-loa-${sessionId}.pid`); +} + +function recallDbPath(): string { + const home = homedir(); + const raw = process.env.RECALL_DB_PATH; + if (!raw) return join(home, '.agents', 'Recall', 'recall.db'); + if (raw === '~') return home; + if (raw.startsWith('~/')) return home + raw.slice(1); + return raw; +} + +/** True when the Curated snapshot is missing or behind the lifecycle high-water. Read-only. */ +export function curatedSnapshotBehind(sessionId: string): boolean { + let db: Database; + try { + db = new Database(recallDbPath(), { readonly: true }); + } catch { + return false; + } + try { + const hw = db.query<{ id: number | null }, [string]>(` + SELECT MAX(message_id) AS id FROM active_host_ingest_messages + WHERE source = 'omp' AND session_id = ? + `).get(sessionId); + if (!hw?.id) return false; + const row = db.query<{ snapshot: number | null }, [string, string]>(` + SELECT snapshot_max_message_id AS snapshot FROM loa_entries + WHERE session_id = ? AND description = ? + ORDER BY id DESC LIMIT 1 + `).get(sessionId, OMP_CURATED_DESCRIPTION); + if (!row) return true; + return (row.snapshot ?? -1) < hw.id; + } catch { + return false; + } finally { + db.close(); + } +} + +export function exclusiveLoaLock(sessionId: string): boolean { + try { + closeSync(openSync(ompLoaLockPath(sessionId), 'wx')); + return true; + } catch { + return false; + } +} + +function releaseLoaLock(sessionId: string): void { + try { + unlinkSync(ompLoaLockPath(sessionId)); + } catch { + // lock already gone + } +} + +export function spawnLoaOmpEnd(sessionId: string, launch: typeof spawn = spawn): void { + const child = launch('bun', [CLI_PATH, 'loa', 'omp-end', sessionId], { + detached: true, + stdio: 'ignore', + }); + child.unref(); +} + export async function captureOmpSessionStop( event: OmpSessionStopEvent, ctx: OmpExtensionContext, pi?: OmpExtensionAPI, runChild: RunBoundedChild = runBoundedChild, + curatedAhead: (sessionId: string) => boolean = curatedSnapshotBehind, + acquireLock: (sessionId: string) => boolean = exclusiveLoaLock, + spawnDetached: (sessionId: string) => void = spawnLoaOmpEnd, ): Promise { try { if (event.signal?.aborted) { @@ -368,8 +443,21 @@ export async function captureOmpSessionStop( return; } const { code, aborted } = await runCli(runChild, ['capture'], payload, event.signal, CHILD_TIMEOUT_MS); - if (code === 0) return; - warn(ctx, pi, aborted ? 'Recall omp capture cancelled' : 'Recall omp capture failed'); + if (code !== 0) { + warn(ctx, pi, aborted ? 'Recall omp capture cancelled' : 'Recall omp capture failed'); + return; + } + if (aborted) return; + let locked = false; + try { + if (!curatedAhead(sessionId)) return; + if (!acquireLock(sessionId)) return; + locked = true; + spawnDetached(sessionId); + } catch { + if (locked) releaseLoaLock(sessionId); + warn(ctx, pi, 'Recall omp curated extract failed'); + } } catch { warn(ctx, pi, 'Recall omp capture failed'); } diff --git a/tests/omp-integration.test.ts b/tests/omp-integration.test.ts index 913ca5e..ee7bdcc 100644 --- a/tests/omp-integration.test.ts +++ b/tests/omp-integration.test.ts @@ -1,9 +1,15 @@ import { describe, expect, test } from 'bun:test'; +import type { spawn } from 'node:child_process'; +import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; import { captureOmpSessionStop, dumpOmpSession, + exclusiveLoaLock, MAX_OMP_STDIN_BYTES, runBoundedChild, + spawnLoaOmpEnd, type OmpExtensionContext, } from '../hosts/omp/recall'; @@ -23,6 +29,9 @@ function uiCtx( }; } +/** Fifth capture arg: Curated snapshot already matches, so do not spawn. */ +const curatedCurrent = () => false; + describe('omp native extension', () => { test('nonzero child warns via ui.notify without transcript', async () => { const messages: string[] = []; @@ -48,7 +57,7 @@ describe('omp native extension', () => { }), undefined, async (_file, _args, childStdin) => { stdin = childStdin; return 0; - }); + }, curatedCurrent); expect(JSON.parse(stdin).text).toBe('keep this question'); }); @@ -77,8 +86,7 @@ describe('omp native extension', () => { spawned = true; stdin = childStdin; return 0; - }, - ); + }, curatedCurrent); expect(spawned).toBe(true); expect(Buffer.byteLength(stdin, 'utf8')).toBeLessThanOrEqual(MAX_OMP_STDIN_BYTES); const payload = JSON.parse(stdin); @@ -110,8 +118,7 @@ describe('omp native extension', () => { async (_file, _args, childStdin) => { stdin = childStdin; return 0; - }, - ); + }, curatedCurrent); expect(Buffer.byteLength(stdin, 'utf8')).toBeLessThanOrEqual(MAX_OMP_STDIN_BYTES); const payload = JSON.parse(stdin); expect(payload.text.startsWith(head)).toBe(true); @@ -141,8 +148,7 @@ describe('omp native extension', () => { async (_file, _args, childStdin) => { stdin = childStdin; return 0; - }, - ); + }, curatedCurrent); expect(Buffer.byteLength(stdin, 'utf8')).toBeLessThanOrEqual(MAX_OMP_STDIN_BYTES); const text = JSON.parse(stdin).text as string; expect(text).toContain('[Recall omp capture truncated]'); @@ -172,7 +178,7 @@ describe('omp native extension', () => { args = childArgs; stdin = childStdin; return 0; - }); + }, curatedCurrent); expect(args[0]?.endsWith('/dist/index.js')).toBe(true); expect(args[0]).not.toContain('/hosts/dist/'); expect(args[1]).toBe('capture'); @@ -389,4 +395,67 @@ describe('omp native extension', () => { AbortSignal.abort(), )).resolves.toBeNull(); }); + + test('spawns loa omp-end only when the curated snapshot is behind and unlocked', async () => { + const home = mkdtempSync(join(tmpdir(), 'recall-omp-loa-')); + const previous = process.env.RECALL_HOME; + process.env.RECALL_HOME = home; + try { + const session = () => ({ + sessionManager: { + getSessionId: () => 'sess-loa', + getBranch: () => [{ type: 'message', id: 'u1', message: { role: 'user', content: 'turn' } }], + }, + }); + const ok = async () => 0; + const spawned: string[] = []; + const spawn = (sessionId: string) => { spawned.push(sessionId); }; + + await captureOmpSessionStop({}, uiCtx(() => {}, session()), undefined, ok, curatedCurrent, () => true, spawn); + expect(spawned).toEqual([]); + + writeFileSync(join(home, 'omp-loa-sess-loa.pid'), 'held'); + await captureOmpSessionStop({}, uiCtx(() => {}, session()), undefined, ok, () => true, exclusiveLoaLock, spawn); + expect(spawned).toEqual([]); + rmSync(join(home, 'omp-loa-sess-loa.pid')); + + let lockedBeforeSpawn = false; + await captureOmpSessionStop({}, uiCtx(() => {}, session()), undefined, ok, () => true, exclusiveLoaLock, (id) => { + lockedBeforeSpawn = existsSync(join(home, `omp-loa-${id}.pid`)); + spawned.push(id); + }); + expect(lockedBeforeSpawn).toBe(true); + expect(spawned).toEqual(['sess-loa']); + + await captureOmpSessionStop({}, uiCtx(() => {}, session()), undefined, async () => 1, () => true, () => { + throw new Error('lock should not run'); + }, spawn); + expect(spawned).toEqual(['sess-loa']); + + const messages: string[] = []; + rmSync(join(home, 'omp-loa-sess-loa.pid')); + await captureOmpSessionStop({}, uiCtx(message => messages.push(message), session()), undefined, ok, () => true, exclusiveLoaLock, () => { + throw new Error('spawn failed'); + }); + expect(messages).toEqual(['Recall omp curated extract failed']); + expect(existsSync(join(home, 'omp-loa-sess-loa.pid'))).toBe(false); + } finally { + if (previous === undefined) delete process.env.RECALL_HOME; + else process.env.RECALL_HOME = previous; + rmSync(home, { recursive: true, force: true }); + } + }); + + test('detached loa spawn ignores stdio and unrefs', () => { + let args: unknown[] = []; + let unrefed = false; + spawnLoaOmpEnd('sess-x', ((file, argv, opts) => { + args = [file, argv, opts]; + return { unref() { unrefed = true; } }; + }) as typeof spawn); + expect(args[0]).toBe('bun'); + expect(args[1]).toEqual([expect.stringContaining('dist/index.js'), 'loa', 'omp-end', 'sess-x']); + expect(args[2]).toEqual({ detached: true, stdio: 'ignore' }); + expect(unrefed).toBe(true); + }); }); From 629a6a4dfcf57bf1b1f39a059050c3c24f0ed2df Mon Sep 17 00:00:00 2001 From: Atlas-Key <296298943+Atlas-Key@users.noreply.github.com> Date: Mon, 19 Oct 2026 08:23:35 -0400 Subject: [PATCH 4/9] chore: mark merged template and scrub tasks done --- todo.txt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/todo.txt b/todo.txt index 6a0c54c..1e157ae 100644 --- a/todo.txt +++ b/todo.txt @@ -5,7 +5,7 @@ 2026-10-09 Slash-export windowed learnings to a user-chosen Obsidian folder. Parked; learnings-only export. +recall @outstanding issue:280 2026-10-09 Learn prefs into global AGENTS.md (and CLAUDE.md) below a Recall marker. Not started. +recall @outstanding issue:279 x 2026-10-17 2026-10-09 CREATE_TABLES lags migrations: decisions/learnings missing confidence. Confirmed on main: decisions/learnings CREATE TABLE lack confidence. +recall @outstanding issue:278 pr:https://github.com/edheltzel/Recall/pull/332 -2026-10-09 templates/mcp.json.template still registers mem-mcp. Confirmed on main: templates/mcp.json.template still uses mem-mcp. +recall @outstanding issue:277 @waiting gate parked +x 2026-10-19 2026-10-09 templates/mcp.json.template still registers mem-mcp. Confirmed on main: templates/mcp.json.template still uses mem-mcp. +recall @outstanding issue:277 @waiting gate parked pr:https://github.com/edheltzel/Recall/pull/338 2026-10-09 Collapse host identity lists toward SESSION_SOURCES. #282 added cursor to SESSION_SOURCES only; lists not collapsed. +recall @outstanding issue:269 2026-10-09 P2: Bun-free runtime spike (do not crowd Cursor/Grok). P2 spike; parked. +recall @outstanding issue:266 2026-10-09 Eval déjà vu ssh-sync vs Turso/libSQL epic #102. Eval only; ties to #102. +recall @outstanding issue:265 @@ -26,7 +26,7 @@ x 2026-10-17 2026-10-09 harden: applyConnectionPragmas sets journal_mode=WAL bef 2026-10-09 feat: frecency-in-ranking evidence/benchmark behind a default-off flag (informs #155). Default-off frecency flag; informs #155. +recall @outstanding issue:193 x 2026-10-17 2026-10-09 harden: bound WAL growth — centralize journal_size_limit across all Recall DB writers. No journal_size_limit on main. +recall @outstanding issue:192 pr:https://github.com/edheltzel/Recall/pull/335 x 2026-10-17 2026-10-09 harden: scrub() learnings.tags on the explicit add paths (un-redacted secret persist, LOW). Scrub learnings.tags on add paths. +recall @outstanding issue:182 pr:https://github.com/edheltzel/Recall/pull/333 -2026-10-09 chore: scrub-archive hostile-fs tests no-op-pass under root (add uid skip-guard). Root uid skip-guard for tests. +recall @outstanding issue:178 @waiting gate parked +x 2026-10-19 2026-10-09 chore: scrub-archive hostile-fs tests no-op-pass under root (add uid skip-guard). Root uid skip-guard for tests. +recall @outstanding issue:178 @waiting gate parked pr:https://github.com/edheltzel/Recall/pull/339 2026-10-09 docs: stale backup path ~/.claude/backups/recall in installation.md + upgrading.md. Confirmed on main: docs still say ~/.claude/backups/recall (also architecture.md). +recall @outstanding issue:174 x 2026-10-17 2026-10-09 harden: recall hybrid/semantic CLI crash on embedding dim mismatch. CLI cosineSimilarity calls still unguarded in embed.ts. +recall @outstanding issue:163 pr:https://github.com/edheltzel/Recall/pull/334 2026-10-09 B3: Blend frecency into ranking (importance #101 + L1 recall #100 + search order). needs - human; ranking change waits on Ed. +recall @outstanding issue:155 From e1fa89062d0e95fd5db21dbf96c28105a8284895 Mon Sep 17 00:00:00 2001 From: Atlas-Key <296298943+Atlas-Key@users.noreply.github.com> Date: Mon, 19 Oct 2026 08:23:35 -0400 Subject: [PATCH 5/9] chore: record omp curated follow-ups --- todo.txt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/todo.txt b/todo.txt index 1e157ae..203e0f2 100644 --- a/todo.txt +++ b/todo.txt @@ -55,7 +55,7 @@ x 2026-10-17 2026-10-09 10 lifecycle tests fail in any git worktree — the envi x 2026-10-17 2026-10-09 chore: fresh /ce-worktree worktrees miss node_modules → ~10 MCP-config tests fail spuriously. Worktree flow gone and jsonc-parser dep removed (eebe92d). +recall @close issue:165 report:github-closed x 2026-10-17 2026-10-09 Non-goals: no HostDescriptor, no adapter→plugin rename, no Cursor marketplace plugin, no Cursor host-hook glue. Reference-only non-goals list; Cursor wires shipped in #282 without them. +recall @close issue:271 report:github-closed x 2026-10-17 2026-10-09 create npm package for eady install. npm publish and `recall install` shipped in #170. +recall @close issue:1 report:github-closed -2026-10-14 omp host-ingest LoA runs the Automatic-capture Extractor instead of the basic 'Session with N messages' summary +recall issue:286 +2026-10-14 omp session end fabric Curated LoA, detached; basic summary stays on fabric failure +recall issue:286 note:/Users/ed/Developer/Atlas/Recall/.agents/atlas/plans/2026-10-18-omp-curated-loa-plan.md x 2026-10-17 2026-10-16 Decide fate of LoA #1996 (wrong Claude session dump) +recall x 2026-10-17 2026-10-16 Trim oversized omp captures with a 60/40 head/tail split instead of skipping, log bytes dropped +recall branch:feat/omp-capture-trim pr:https://github.com/edheltzel/Recall/pull/337 2026-10-16 Optional: capture a touched-files list (read/modified, max 20) with omp captures +recall @@ -66,3 +66,5 @@ x 2026-10-17 2026-10-17 Merge PR #336 .mailmap Cursor/Claude -> Atlas-Key +recal x 2026-10-17 2026-10-17 Decide no-mistakes axi driving for GitButler branches +recall @outstanding x 2026-10-17 2026-10-17 Finish Recall history rewrite: force-push rewritten main/tags, migrate GitButler stacks (staged in ../.recall-rewrite-2026-10-17) +Recall @blocked 2026-10-17 Trash ../.recall-rewrite-2026-10-17 backups once rewritten Recall history is confirmed fine +Recall +x 2026-10-18 2026-10-18 Leave PR #339 authored by edheltzel; self-review unavailable +recall pr:https://github.com/edheltzel/Recall/pull/339 +2026-10-18 329 stays separate: dump abort must kill its own fabric child +recall issue:329 note:/Users/ed/Developer/Atlas/Recall/.agents/atlas/plans/2026-10-18-omp-curated-loa-plan.md From ca95842a7dde4297853ceda6bf459dc5640d6ca4 Mon Sep 17 00:00:00 2001 From: Atlas-Key <296298943+Atlas-Key@users.noreply.github.com> Date: Mon, 19 Oct 2026 08:45:56 -0400 Subject: [PATCH 6/9] feat(omp): write a detached Curated LoA after lifecycle capture omp session_stop is turn completion. After a successful capture, detach recall loa omp-end only when the Curated snapshot is missing or behind the lifecycle high-water. Fabric failure keeps the basic summary. Explicit dump rows are not scored again. --- hooks/lib/db-path.ts | 3 +- hosts/omp/recall.ts | 49 ++++++++------- src/commands/loa.ts | 87 ++++++++++++++++---------- tests/commands/loa-omp-end.test.ts | 34 ++++++++++ tests/hooks/sqlite-writers.test.ts | 24 ++++++++ tests/omp-integration.test.ts | 99 +++++++++++++++++++++++++++++- 6 files changed, 240 insertions(+), 56 deletions(-) diff --git a/hooks/lib/db-path.ts b/hooks/lib/db-path.ts index 3b3d341..f9fa8dd 100644 --- a/hooks/lib/db-path.ts +++ b/hooks/lib/db-path.ts @@ -8,6 +8,7 @@ // 2. MEM_DB_PATH (legacy fallback) // 3. ~/.agents/Recall/recall.db (default) +import { homedir } from 'node:os'; import { join } from 'path'; /** Preserve the suffix: normalizing symlink/.. paths can select a different database. */ @@ -18,7 +19,7 @@ export function expandDbPathTilde(raw: string, home: string): string { } export function resolveDbPath(): string { - const home = process.env.HOME || process.env.USERPROFILE || ''; + const home = process.env.HOME || process.env.USERPROFILE || homedir(); const raw = process.env.RECALL_DB_PATH || process.env.MEM_DB_PATH; return raw ? expandDbPathTilde(raw, home) : join(home, '.agents', 'Recall', 'recall.db'); } diff --git a/hosts/omp/recall.ts b/hosts/omp/recall.ts index 4a4839f..27fbe99 100644 --- a/hosts/omp/recall.ts +++ b/hosts/omp/recall.ts @@ -2,12 +2,13 @@ // Self-contained — no src/ imports, no @oh-my-pi runtime import. import { spawn } from 'node:child_process'; -import { closeSync, openSync, unlinkSync } from 'node:fs'; +import { closeSync, mkdirSync, openSync, unlinkSync } from 'node:fs'; import { homedir } from 'node:os'; -import { join } from 'node:path'; +import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { Database } from 'bun:sqlite'; import type { z } from 'zod'; +import { resolveDbPath as getDbPath } from '../../hooks/lib/db-path'; import { scrub } from '../../hooks/lib/write-safety'; const PACKAGE_ROOT = fileURLToPath(new URL('../..', import.meta.url)); @@ -344,20 +345,23 @@ function ompLoaLockPath(sessionId: string): string { return join(dir, `omp-loa-${sessionId}.pid`); } -function recallDbPath(): string { - const home = homedir(); - const raw = process.env.RECALL_DB_PATH; - if (!raw) return join(home, '.agents', 'Recall', 'recall.db'); - if (raw === '~') return home; - if (raw.startsWith('~/')) return home + raw.slice(1); - return raw; +export function exclusiveLoaLock(sessionId: string): boolean { + const path = ompLoaLockPath(sessionId); + mkdirSync(dirname(path), { recursive: true }); + try { + closeSync(openSync(path, 'wx')); + return true; + } catch (error) { + if (error && typeof error === 'object' && 'code' in error && error.code === 'EEXIST') return false; + throw error; + } } /** True when the Curated snapshot is missing or behind the lifecycle high-water. Read-only. */ export function curatedSnapshotBehind(sessionId: string): boolean { let db: Database; try { - db = new Database(recallDbPath(), { readonly: true }); + db = new Database(getDbPath(), { readonly: true }); } catch { return false; } @@ -381,15 +385,6 @@ export function curatedSnapshotBehind(sessionId: string): boolean { } } -export function exclusiveLoaLock(sessionId: string): boolean { - try { - closeSync(openSync(ompLoaLockPath(sessionId), 'wx')); - return true; - } catch { - return false; - } -} - function releaseLoaLock(sessionId: string): void { try { unlinkSync(ompLoaLockPath(sessionId)); @@ -398,11 +393,19 @@ function releaseLoaLock(sessionId: string): void { } } -export function spawnLoaOmpEnd(sessionId: string, launch: typeof spawn = spawn): void { +export function spawnLoaOmpEnd( + sessionId: string, + launch: typeof spawn = spawn, + onError?: () => void, +): void { const child = launch('bun', [CLI_PATH, 'loa', 'omp-end', sessionId], { detached: true, stdio: 'ignore', }); + child.once('error', () => { + releaseLoaLock(sessionId); + onError?.(); + }); child.unref(); } @@ -414,6 +417,7 @@ export async function captureOmpSessionStop( curatedAhead: (sessionId: string) => boolean = curatedSnapshotBehind, acquireLock: (sessionId: string) => boolean = exclusiveLoaLock, spawnDetached: (sessionId: string) => void = spawnLoaOmpEnd, + launch: typeof spawn = spawn, ): Promise { try { if (event.signal?.aborted) { @@ -448,12 +452,15 @@ export async function captureOmpSessionStop( return; } if (aborted) return; + const detach = spawnDetached === spawnLoaOmpEnd + ? (id: string) => spawnLoaOmpEnd(id, launch, () => warn(ctx, pi, 'Recall omp curated extract failed')) + : spawnDetached; let locked = false; try { if (!curatedAhead(sessionId)) return; if (!acquireLock(sessionId)) return; locked = true; - spawnDetached(sessionId); + detach(sessionId); } catch { if (locked) releaseLoaLock(sessionId); warn(ctx, pi, 'Recall omp curated extract failed'); diff --git a/src/commands/loa.ts b/src/commands/loa.ts index 5719a7d..d3008f4 100644 --- a/src/commands/loa.ts +++ b/src/commands/loa.ts @@ -236,53 +236,74 @@ function writeOmpCurated( messages: Message[], text: string, highWater: number, -): void { +): boolean { const db = getDb(); - const session = db.prepare('SELECT project FROM sessions WHERE session_id = ?') - .get(sessionId) as { project: string | null } | undefined; - const existing = db.prepare(` - SELECT id FROM loa_entries WHERE session_id = ? AND description = ? - ORDER BY id DESC LIMIT 1 - `).get(sessionId, OMP_CURATED_DESCRIPTION) as { id: number } | undefined; - if (!existing) { - createLoaEntryFromMessages({ - title: `Omp session ${sessionId}`, - description: OMP_CURATED_DESCRIPTION, - fabric_extract: text, - snapshot_max_message_id: highWater, - session_id: sessionId, - project: session?.project || undefined, - tags: 'curated,omp', - message_count: messages.length, - provenance: 'extracted', - }, messages); - return; + db.exec('BEGIN IMMEDIATE'); + try { + if (ompLifecycleHighWater(sessionId) !== highWater) { + db.exec('ROLLBACK'); + return false; + } + const session = db.prepare('SELECT project FROM sessions WHERE session_id = ?') + .get(sessionId) as { project: string | null } | undefined; + const existing = db.prepare(` + SELECT id FROM loa_entries WHERE session_id = ? AND description = ? + ORDER BY id DESC LIMIT 1 + `).get(sessionId, OMP_CURATED_DESCRIPTION) as { id: number } | undefined; + if (!existing) { + createLoaEntryFromMessages({ + title: `Omp session ${sessionId}`, + description: OMP_CURATED_DESCRIPTION, + fabric_extract: text, + snapshot_max_message_id: highWater, + session_id: sessionId, + project: session?.project || undefined, + tags: 'curated,omp', + message_count: messages.length, + provenance: 'extracted', + }, messages); + } else { + db.prepare(` + UPDATE loa_entries + SET fabric_extract = ?, message_count = ?, snapshot_max_message_id = ? + WHERE id = ? + `).run(text, messages.length, highWater, existing.id); + replaceLoaMessagePins(existing.id, messages, db); + } + db.exec('COMMIT'); + return true; + } catch (error) { + try { db.exec('ROLLBACK'); } catch { /* already closed */ } + throw error; } - db.transaction(() => { - db.prepare(` - UPDATE loa_entries - SET fabric_extract = ?, message_count = ?, snapshot_max_message_id = ? - WHERE id = ? - `).run(text, messages.length, highWater, existing.id); - replaceLoaMessagePins(existing.id, messages, db); - })(); +} + +function loadedHighWater(messages: readonly Message[]): number | null { + const id = messages[messages.length - 1]?.id; + return Number.isSafeInteger(id) ? id! : null; } export async function runLoaOmpEnd( sessionId: string, - deps: { extract?: typeof runFabricExtract } = {}, + deps: { + extract?: typeof runFabricExtract; + afterLoad?: () => void; + beforeWrite?: () => void; + } = {}, ): Promise { const extract = deps.extract ?? runFabricExtract; try { let messages = loadOmpLifecycleMessages(sessionId); if (messages.length === 0) return 0; - let highWater = ompLifecycleHighWater(sessionId); + deps.afterLoad?.(); + let highWater = loadedHighWater(messages); + if (highWater == null) return 0; let text: string; try { text = extract(formatMessagesForExtraction(messages)); if (ompLifecycleHighWater(sessionId) !== highWater) { messages = loadOmpLifecycleMessages(sessionId); - highWater = ompLifecycleHighWater(sessionId); + highWater = loadedHighWater(messages); if (messages.length === 0 || highWater == null) return 0; text = extract(formatMessagesForExtraction(messages)); if (ompLifecycleHighWater(sessionId) !== highWater) return 0; @@ -290,8 +311,8 @@ export async function runLoaOmpEnd( } catch { return 0; } - if (highWater == null) return 0; - writeOmpCurated(sessionId, messages, text, highWater); + deps.beforeWrite?.(); + if (!writeOmpCurated(sessionId, messages, text, highWater)) return 0; return 0; } finally { releaseOmpLoaLock(sessionId); diff --git a/tests/commands/loa-omp-end.test.ts b/tests/commands/loa-omp-end.test.ts index 5f4673c..a9800db 100644 --- a/tests/commands/loa-omp-end.test.ts +++ b/tests/commands/loa-omp-end.test.ts @@ -155,4 +155,38 @@ describe('recall loa omp-end', () => { expect(called).toBe(false); expect(curatedRows('missing')).toEqual([]); }); + + test('a row committed after load is extracted, not labeled covered unseen', async () => { + capture('ses-gap', 'first capture', 0); + const seen: string[] = []; + expect(await runLoaOmpEnd('ses-gap', { + afterLoad: () => capture('ses-gap', 'late capture', 1), + extract: (content) => { + seen.push(content); + return `extract ${seen.length}`; + }, + })).toBe(0); + expect(seen.at(-1)).toContain('late capture'); + const rows = curatedRows('ses-gap'); + expect(rows).toHaveLength(1); + expect(rows[0].fabric_extract).toBe('extract 2'); + const max = getDb().prepare(` + SELECT MAX(message_id) AS id FROM active_host_ingest_messages + WHERE source = 'omp' AND session_id = ? + `).get('ses-gap') as { id: number }; + expect(rows[0].snapshot_max_message_id).toBe(max.id); + }); + + test('the write transaction refuses when high-water moved after the extract check', async () => { + capture('ses-commit', 'seen turn', 0); + expect(await runLoaOmpEnd('ses-commit', { + extract: () => 'would cover the old rows', + beforeWrite: () => capture('ses-commit', 'unseen turn', 1), + })).toBe(0); + expect(curatedRows('ses-commit')).toEqual([]); + const published = getDb().prepare(` + SELECT content FROM published_messages WHERE session_id = ? + `).all('ses-commit') as Array<{ content: string }>; + expect(published.map(row => row.content)).toContain('unseen turn'); + }); }); diff --git a/tests/hooks/sqlite-writers.test.ts b/tests/hooks/sqlite-writers.test.ts index 4c744f4..cd52c21 100644 --- a/tests/hooks/sqlite-writers.test.ts +++ b/tests/hooks/sqlite-writers.test.ts @@ -1,4 +1,6 @@ import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; +import { homedir } from 'node:os'; +import { join } from 'path'; import { setupTestDb, teardownTestDb } from '../helpers/setup'; import { Database } from 'bun:sqlite'; import { @@ -11,6 +13,7 @@ import { writeLoaEntryFromExtraction, writeExtractionErrors, } from '../../hooks/lib/sqlite-writers'; +import { resolveDbPath } from '../../hooks/lib/db-path'; let dbPath: string; @@ -46,6 +49,27 @@ describe('getDbPath', () => { else delete process.env.MEM_DB_PATH; } }); + + test('uses homedir when HOME and USERPROFILE are unset', () => { + const previous = { + HOME: process.env.HOME, + USERPROFILE: process.env.USERPROFILE, + RECALL_DB_PATH: process.env.RECALL_DB_PATH, + MEM_DB_PATH: process.env.MEM_DB_PATH, + }; + delete process.env.HOME; + delete process.env.USERPROFILE; + delete process.env.RECALL_DB_PATH; + delete process.env.MEM_DB_PATH; + try { + expect(resolveDbPath()).toBe(join(homedir(), '.agents', 'Recall', 'recall.db')); + } finally { + for (const [key, value] of Object.entries(previous)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + } + }); }); describe('ensureDbWritable', () => { diff --git a/tests/omp-integration.test.ts b/tests/omp-integration.test.ts index ee7bdcc..15f60c6 100644 --- a/tests/omp-integration.test.ts +++ b/tests/omp-integration.test.ts @@ -1,10 +1,14 @@ import { describe, expect, test } from 'bun:test'; import type { spawn } from 'node:child_process'; +import { EventEmitter } from 'node:events'; +import { Database } from 'bun:sqlite'; import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'fs'; import { tmpdir } from 'os'; import { join } from 'path'; +import { resolveDbPath } from '../hooks/lib/db-path'; import { captureOmpSessionStop, + curatedSnapshotBehind, dumpOmpSession, exclusiveLoaLock, MAX_OMP_STDIN_BYTES, @@ -451,11 +455,104 @@ describe('omp native extension', () => { let unrefed = false; spawnLoaOmpEnd('sess-x', ((file, argv, opts) => { args = [file, argv, opts]; - return { unref() { unrefed = true; } }; + return { unref() { unrefed = true; }, once() { return this; } }; }) as typeof spawn); expect(args[0]).toBe('bun'); expect(args[1]).toEqual([expect.stringContaining('dist/index.js'), 'loa', 'omp-end', 'sess-x']); expect(args[2]).toEqual({ detached: true, stdio: 'ignore' }); expect(unrefed).toBe(true); }); + + test('async spawn error releases the lock and warns', async () => { + const home = mkdtempSync(join(tmpdir(), 'recall-omp-spawn-err-')); + const previous = process.env.RECALL_HOME; + process.env.RECALL_HOME = home; + try { + const messages: string[] = []; + const { promise, resolve } = Promise.withResolvers(); + await captureOmpSessionStop({}, uiCtx(message => { + messages.push(message); + resolve(); + }, { + sessionManager: { + getSessionId: () => 'sess-enoent', + getBranch: () => [{ type: 'message', id: 'u1', message: { role: 'user', content: 'turn' } }], + }, + }), undefined, async () => 0, () => true, exclusiveLoaLock, undefined, (() => { + const child = new EventEmitter(); + queueMicrotask(() => child.emit('error', new Error('spawn bun ENOENT'))); + return Object.assign(child, { unref() {} }); + }) as typeof spawn); + await promise; + expect(messages).toEqual(['Recall omp curated extract failed']); + expect(existsSync(join(home, 'omp-loa-sess-enoent.pid'))).toBe(false); + } finally { + if (previous === undefined) delete process.env.RECALL_HOME; + else process.env.RECALL_HOME = previous; + rmSync(home, { recursive: true, force: true }); + } + }); + + test('MEM_DB_PATH-only curated check reads the database the child would write', () => { + const dir = mkdtempSync(join(tmpdir(), 'recall-omp-memdb-')); + const dbPath = join(dir, 'recall.db'); + const db = new Database(dbPath); + db.exec(` + CREATE TABLE active_host_ingest_messages (source TEXT, session_id TEXT, message_id INTEGER); + CREATE TABLE loa_entries ( + id INTEGER PRIMARY KEY, session_id TEXT, description TEXT, snapshot_max_message_id INTEGER + ); + INSERT INTO active_host_ingest_messages (source, session_id, message_id) VALUES ('omp', 'ses-mem', 7); + `); + db.close(); + const previousRecall = process.env.RECALL_DB_PATH; + const previousMem = process.env.MEM_DB_PATH; + delete process.env.RECALL_DB_PATH; + process.env.MEM_DB_PATH = dbPath; + try { + expect(resolveDbPath()).toBe(dbPath); + expect(curatedSnapshotBehind('ses-mem')).toBe(true); + const marked = new Database(dbPath); + marked.prepare(` + INSERT INTO loa_entries (session_id, description, snapshot_max_message_id) + VALUES (?, 'Curated fabric extraction from omp session end.', 7) + `).run('ses-mem'); + marked.close(); + expect(curatedSnapshotBehind('ses-mem')).toBe(false); + } finally { + if (previousRecall === undefined) delete process.env.RECALL_DB_PATH; + else process.env.RECALL_DB_PATH = previousRecall; + if (previousMem === undefined) delete process.env.MEM_DB_PATH; + else process.env.MEM_DB_PATH = previousMem; + rmSync(dir, { recursive: true, force: true }); + } + }); + + test('a missing lock directory is created; a non-directory parent warns and does not skip quietly', async () => { + const root = mkdtempSync(join(tmpdir(), 'recall-omp-lockdir-')); + const previous = process.env.RECALL_HOME; + process.env.RECALL_HOME = join(root, 'Recall'); + try { + expect(exclusiveLoaLock('ses-new')).toBe(true); + expect(existsSync(join(root, 'Recall', 'omp-loa-ses-new.pid'))).toBe(true); + + const blocker = join(root, 'not-a-dir'); + writeFileSync(blocker, 'x'); + process.env.RECALL_HOME = join(blocker, 'Recall'); + const messages: string[] = []; + let spawned = false; + await captureOmpSessionStop({}, uiCtx(message => messages.push(message), { + sessionManager: { + getSessionId: () => 'ses-new', + getBranch: () => [{ type: 'message', id: 'u1', message: { role: 'user', content: 'turn' } }], + }, + }), undefined, async () => 0, () => true, exclusiveLoaLock, () => { spawned = true; }); + expect(spawned).toBe(false); + expect(messages).toEqual(['Recall omp curated extract failed']); + } finally { + if (previous === undefined) delete process.env.RECALL_HOME; + else process.env.RECALL_HOME = previous; + rmSync(root, { recursive: true, force: true }); + } + }); }); From 48e4ac5b9518bbbff5e35c568ce456531bc317e4 Mon Sep 17 00:00:00 2001 From: Atlas-Key <296298943+Atlas-Key@users.noreply.github.com> Date: Mon, 19 Oct 2026 12:24:43 -0400 Subject: [PATCH 7/9] fix(loa): converge omp curated snapshot to lifecycle max - Retry runLoaOmpEnd up to 8 times until the Curated snapshot matches - Requeue a detached loa omp-end and hand the pid lock to the successor - Cover captures that land during the child worker --- src/commands/loa.ts | 53 +++++++++----- tests/commands/loa-omp-end.test.ts | 112 +++++++++++++++++++++++------ 2 files changed, 126 insertions(+), 39 deletions(-) diff --git a/src/commands/loa.ts b/src/commands/loa.ts index d3008f4..154bfeb 100644 --- a/src/commands/loa.ts +++ b/src/commands/loa.ts @@ -8,6 +8,8 @@ import { formatMessagesForExtraction, runFabricExtract } from '../lib/extraction import { getRecallHome } from '../lib/runtime-paths.js'; import { unlinkSync } from 'fs'; import { join } from 'path'; +import { spawn } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; import type { Message } from '../types/index.js'; interface LoaOptions { @@ -202,6 +204,8 @@ export function runLoaList(limit: number = 10): void { } const OMP_CURATED_DESCRIPTION = 'Curated fabric extraction from omp session end.'; +const OMP_LOA_ATTEMPTS = 8; +const OMP_LOA_CLI = join(fileURLToPath(new URL('../..', import.meta.url)), 'dist', 'index.js'); function releaseOmpLoaLock(sessionId: string): void { try { @@ -211,6 +215,15 @@ function releaseOmpLoaLock(sessionId: string): void { } } +function requeueOmpLoa(sessionId: string): void { + const child = spawn('bun', [OMP_LOA_CLI, 'loa', 'omp-end', sessionId], { + detached: true, + stdio: 'ignore', + }); + child.once('error', () => releaseOmpLoaLock(sessionId)); + child.unref(); +} + function loadOmpLifecycleMessages(sessionId: string): Message[] { return getDb().prepare(` SELECT m.id, m.session_id, m.timestamp, m.role, m.content, m.project, m.importance, m.provenance @@ -292,29 +305,33 @@ export async function runLoaOmpEnd( } = {}, ): Promise { const extract = deps.extract ?? runFabricExtract; + let handoff = false; + let hooked = false; try { - let messages = loadOmpLifecycleMessages(sessionId); - if (messages.length === 0) return 0; - deps.afterLoad?.(); - let highWater = loadedHighWater(messages); - if (highWater == null) return 0; - let text: string; - try { - text = extract(formatMessagesForExtraction(messages)); - if (ompLifecycleHighWater(sessionId) !== highWater) { - messages = loadOmpLifecycleMessages(sessionId); - highWater = loadedHighWater(messages); - if (messages.length === 0 || highWater == null) return 0; + for (let attempt = 0; attempt < OMP_LOA_ATTEMPTS; attempt++) { + const messages = loadOmpLifecycleMessages(sessionId); + if (messages.length === 0) return 0; + if (attempt === 0) deps.afterLoad?.(); + const highWater = loadedHighWater(messages); + if (highWater == null) return 0; + let text: string; + try { text = extract(formatMessagesForExtraction(messages)); - if (ompLifecycleHighWater(sessionId) !== highWater) return 0; + } catch { + return 0; + } + if (ompLifecycleHighWater(sessionId) !== highWater) continue; + if (!hooked) { + hooked = true; + deps.beforeWrite?.(); + if (ompLifecycleHighWater(sessionId) !== highWater) continue; } - } catch { - return 0; + if (writeOmpCurated(sessionId, messages, text, highWater)) return 0; } - deps.beforeWrite?.(); - if (!writeOmpCurated(sessionId, messages, text, highWater)) return 0; + requeueOmpLoa(sessionId); + handoff = true; return 0; } finally { - releaseOmpLoaLock(sessionId); + if (!handoff) releaseOmpLoaLock(sessionId); } } diff --git a/tests/commands/loa-omp-end.test.ts b/tests/commands/loa-omp-end.test.ts index a9800db..9b257da 100644 --- a/tests/commands/loa-omp-end.test.ts +++ b/tests/commands/loa-omp-end.test.ts @@ -50,6 +50,13 @@ function curatedRows(sessionId: string) { }>; } +function lifecycleMax(sessionId: string): number { + return (getDb().prepare(` + SELECT MAX(message_id) AS id FROM active_host_ingest_messages + WHERE source = 'omp' AND session_id = ? + `).get(sessionId) as { id: number }).id; +} + function basicExtract(sessionId: string): string { return (getDb().prepare(` SELECT fabric_extract FROM loa_entries WHERE session_id = ? AND description = ? AND tags = ? @@ -137,16 +144,24 @@ describe('recall loa omp-end', () => { expect(calls).toBe(2); }); - test('a high-water move during the retry writes nothing', async () => { + test('a capture during extract is included before the worker exits', async () => { capture('ses-move', 'first capture', 0); let calls = 0; - expect(await runLoaOmpEnd('ses-move', { extract: () => { - calls += 1; - capture('ses-move', `capture ${calls}`, calls); - return `extract ${calls}`; - } })).toBe(0); - expect(curatedRows('ses-move')).toEqual([]); - expect(calls).toBe(2); + expect(await runLoaOmpEnd('ses-move', { + extract: (content) => { + calls += 1; + if (calls <= 2) { + capture('ses-move', `final capture ${calls}`, calls); + return `stale ${calls}`; + } + return content; + }, + })).toBe(0); + expect(calls).toBeGreaterThan(2); + const rows = curatedRows('ses-move'); + expect(rows).toHaveLength(1); + expect(rows[0].fabric_extract).toContain('final capture 2'); + expect(rows[0].snapshot_max_message_id).toBe(lifecycleMax('ses-move')); }); test('no lifecycle rows exits 0', async () => { @@ -170,23 +185,78 @@ describe('recall loa omp-end', () => { const rows = curatedRows('ses-gap'); expect(rows).toHaveLength(1); expect(rows[0].fabric_extract).toBe('extract 2'); - const max = getDb().prepare(` - SELECT MAX(message_id) AS id FROM active_host_ingest_messages - WHERE source = 'omp' AND session_id = ? - `).get('ses-gap') as { id: number }; - expect(rows[0].snapshot_max_message_id).toBe(max.id); + expect(rows[0].snapshot_max_message_id).toBe(lifecycleMax('ses-gap')); }); - test('the write transaction refuses when high-water moved after the extract check', async () => { + test('a row committed before the write is extracted in the same worker', async () => { capture('ses-commit', 'seen turn', 0); + let planted = false; expect(await runLoaOmpEnd('ses-commit', { - extract: () => 'would cover the old rows', - beforeWrite: () => capture('ses-commit', 'unseen turn', 1), + extract: (content) => content, + beforeWrite: () => { + if (planted) return; + planted = true; + capture('ses-commit', 'unseen turn', 1); + }, })).toBe(0); - expect(curatedRows('ses-commit')).toEqual([]); - const published = getDb().prepare(` - SELECT content FROM published_messages WHERE session_id = ? - `).all('ses-commit') as Array<{ content: string }>; - expect(published.map(row => row.content)).toContain('unseen turn'); + const rows = curatedRows('ses-commit'); + expect(rows).toHaveLength(1); + expect(rows[0].fabric_extract).toContain('unseen turn'); + expect(rows[0].snapshot_max_message_id).toBe(lifecycleMax('ses-commit')); + }); + + test('a capture during the child worker is covered without another parent call', async () => { + const sessionId = 'ses-xproc'; + capture(sessionId, 'first capture', 0); + const home = getRecallHome(); + const pid = join(home, `omp-loa-${sessionId}.pid`); + const ready = join(home, 'ready'); + const go = join(home, 'go'); + writeFileSync(pid, '1'); + const modulePath = join(import.meta.dir, '../../src/commands/loa.ts'); + const child = Bun.spawn(['bun', '-e', ` + import { existsSync, writeFileSync } from 'fs'; + import { runLoaOmpEnd } from ${JSON.stringify(modulePath)}; + let waited = false; + const code = await runLoaOmpEnd(process.env.SESSION_ID, { + afterLoad() { writeFileSync(process.env.READY, '1'); }, + extract(content) { + if (!waited) { + waited = true; + const start = Date.now(); + while (!existsSync(process.env.GO)) { + if (Date.now() - start > 5000) { + console.error('child timed out waiting to extract'); + return content; + } + } + } + return content; + }, + }); + process.exit(code); + `], { + env: { ...process.env, RECALL_HOME: home, SESSION_ID: sessionId, READY: ready, GO: go }, + stdout: 'pipe', + stderr: 'pipe', + }); + try { + const start = Date.now(); + while (!existsSync(ready)) { + if (child.exitCode !== null || Date.now() - start > 5000) break; + } + capture(sessionId, 'final capture', 1); + writeFileSync(go, '1'); + const exit = await child.exited; + const stderr = await new Response(child.stderr).text(); + expect(exit, stderr).toBe(0); + const rows = curatedRows(sessionId); + expect(rows, stderr).toHaveLength(1); + expect(rows[0].fabric_extract).toContain('final capture'); + expect(rows[0].snapshot_max_message_id).toBe(lifecycleMax(sessionId)); + expect(existsSync(pid)).toBe(false); + } finally { + child.kill(); + } }); }); From c236d37f37d46638aa115961d1472a2e1de9d79e Mon Sep 17 00:00:00 2001 From: Atlas-Key <296298943+Atlas-Key@users.noreply.github.com> Date: Mon, 19 Oct 2026 12:59:37 -0400 Subject: [PATCH 8/9] no-mistakes(review): Fix omp curation freshness, handoff, embeddings, and test isolation --- docs/OMP_INTEGRATION.md | 2 +- hosts/omp/loa-protocol.ts | 59 ++++++++++ hosts/omp/recall.ts | 48 +------- src/commands/loa.ts | 96 +++++++--------- tests/commands/loa-omp-end.test.ts | 173 ++++++++++++++++++++++++----- tests/omp-integration.test.ts | 17 ++- 6 files changed, 262 insertions(+), 133 deletions(-) create mode 100644 hosts/omp/loa-protocol.ts diff --git a/docs/OMP_INTEGRATION.md b/docs/OMP_INTEGRATION.md index 0d7975c..352e378 100644 --- a/docs/OMP_INTEGRATION.md +++ b/docs/OMP_INTEGRATION.md @@ -34,7 +34,7 @@ Do not install the npm package named `recall-memory`; that name is an unrelated This is turn-completion capture, not crash recovery. An interrupted turn before `session_stop` is not guaranteed captured. Shutdown and pre-compaction hooks are not installed. The active branch is read again on the next stop after a failure. -`session_stop` is still turn completion; after a successful capture the extension detaches `recall loa omp-end` only when that session's Curated snapshot is missing or behind the lifecycle high-water and no child is already running. Fabric failure keeps the basic summary. +`session_stop` is still turn completion; after a successful capture the extension detaches `recall loa omp-end` only when that session's Curated source pins differ from the ordered published lifecycle messages and no child is already running. Revisiting an earlier snapshot also triggers curation. The worker releases its lock and checks again before finishing so a capture that deferred to it is still covered. Fabric failure keeps the basic summary. Capture bounds its CLI child to 30 seconds and its serialized JSON payload to 25 MiB, including metadata, escaping, and the truncation marker. Excluded content does not count toward this limit. When the scrubbed text would exceed the limit, capture allocates 60% of the remaining JSON-escaped byte budget to the beginning and 40% to the end of the text, separated by `[Recall omp capture truncated]`; a warning reports the UTF-8 bytes dropped from the scrubbed text. Malformed branches are rejected before starting the child; malformed branches, cancellations, and child failures warn without stopping omp. diff --git a/hosts/omp/loa-protocol.ts b/hosts/omp/loa-protocol.ts new file mode 100644 index 0000000..b511d93 --- /dev/null +++ b/hosts/omp/loa-protocol.ts @@ -0,0 +1,59 @@ +import type { Database } from 'bun:sqlite'; +import { closeSync, mkdirSync, openSync, unlinkSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { dirname, join } from 'node:path'; + +export const OMP_CURATED_DESCRIPTION = 'Curated fabric extraction from omp session end.'; + +function ompLoaLockPath(sessionId: string): string { + return join(process.env.RECALL_HOME || join(homedir(), '.agents', 'Recall'), `omp-loa-${sessionId}.pid`); +} + +export function exclusiveLoaLock(sessionId: string): boolean { + const path = ompLoaLockPath(sessionId); + mkdirSync(dirname(path), { recursive: true }); + try { + closeSync(openSync(path, 'wx')); + return true; + } catch (error) { + if (error && typeof error === 'object' && 'code' in error && error.code === 'EEXIST') return false; + throw error; + } +} + +export function releaseLoaLock(sessionId: string): void { + try { + unlinkSync(ompLoaLockPath(sessionId)); + } catch {} +} + +export function loadOmpLifecycleMessages(db: Database, sessionId: string): unknown[] { + return db.prepare(` + SELECT m.id, m.session_id, m.timestamp, m.role, m.content, m.project, m.importance, m.provenance + FROM active_host_ingest_messages h + JOIN published_messages m ON m.id = h.message_id + WHERE h.source = 'omp' AND h.session_id = ? AND h.source_position IS NOT NULL + ORDER BY h.source_position, h.message_id + `).all(sessionId); +} + +export function ompSnapshotsMatch(left: readonly unknown[], right: readonly unknown[]): boolean { + return JSON.stringify(left) === JSON.stringify(right); +} + +export function ompCuratedSnapshotBehind(db: Database, sessionId: string): boolean { + return db.transaction(() => { + const messages = loadOmpLifecycleMessages(db, sessionId); + if (messages.length === 0) return false; + const row = db.prepare(` + SELECT id FROM loa_entries WHERE session_id = ? AND description = ? + ORDER BY id DESC LIMIT 1 + `).get(sessionId, OMP_CURATED_DESCRIPTION) as { id: number } | undefined; + if (!row) return true; + const pins = db.prepare(` + SELECT message_id AS id, session_id, timestamp, role, content, project, importance, provenance + FROM loa_message_sources WHERE loa_id = ? ORDER BY ordinal + `).all(row.id); + return !ompSnapshotsMatch(messages, pins); + })(); +} diff --git a/hosts/omp/recall.ts b/hosts/omp/recall.ts index 27fbe99..e0b7385 100644 --- a/hosts/omp/recall.ts +++ b/hosts/omp/recall.ts @@ -2,14 +2,15 @@ // Self-contained — no src/ imports, no @oh-my-pi runtime import. import { spawn } from 'node:child_process'; -import { closeSync, mkdirSync, openSync, unlinkSync } from 'node:fs'; -import { homedir } from 'node:os'; -import { dirname, join } from 'node:path'; +import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { Database } from 'bun:sqlite'; import type { z } from 'zod'; import { resolveDbPath as getDbPath } from '../../hooks/lib/db-path'; import { scrub } from '../../hooks/lib/write-safety'; +import { exclusiveLoaLock, ompCuratedSnapshotBehind, releaseLoaLock } from './loa-protocol'; + +export { exclusiveLoaLock } from './loa-protocol'; const PACKAGE_ROOT = fileURLToPath(new URL('../..', import.meta.url)); const CLI_PATH = join(PACKAGE_ROOT, 'dist', 'index.js'); @@ -338,26 +339,6 @@ export function ompBranchMessages(entries: unknown): OmpMessage[] { return messages; } -const OMP_CURATED_DESCRIPTION = 'Curated fabric extraction from omp session end.'; - -function ompLoaLockPath(sessionId: string): string { - const dir = process.env.RECALL_HOME || join(homedir(), '.agents', 'Recall'); - return join(dir, `omp-loa-${sessionId}.pid`); -} - -export function exclusiveLoaLock(sessionId: string): boolean { - const path = ompLoaLockPath(sessionId); - mkdirSync(dirname(path), { recursive: true }); - try { - closeSync(openSync(path, 'wx')); - return true; - } catch (error) { - if (error && typeof error === 'object' && 'code' in error && error.code === 'EEXIST') return false; - throw error; - } -} - -/** True when the Curated snapshot is missing or behind the lifecycle high-water. Read-only. */ export function curatedSnapshotBehind(sessionId: string): boolean { let db: Database; try { @@ -366,18 +347,7 @@ export function curatedSnapshotBehind(sessionId: string): boolean { return false; } try { - const hw = db.query<{ id: number | null }, [string]>(` - SELECT MAX(message_id) AS id FROM active_host_ingest_messages - WHERE source = 'omp' AND session_id = ? - `).get(sessionId); - if (!hw?.id) return false; - const row = db.query<{ snapshot: number | null }, [string, string]>(` - SELECT snapshot_max_message_id AS snapshot FROM loa_entries - WHERE session_id = ? AND description = ? - ORDER BY id DESC LIMIT 1 - `).get(sessionId, OMP_CURATED_DESCRIPTION); - if (!row) return true; - return (row.snapshot ?? -1) < hw.id; + return ompCuratedSnapshotBehind(db, sessionId); } catch { return false; } finally { @@ -385,14 +355,6 @@ export function curatedSnapshotBehind(sessionId: string): boolean { } } -function releaseLoaLock(sessionId: string): void { - try { - unlinkSync(ompLoaLockPath(sessionId)); - } catch { - // lock already gone - } -} - export function spawnLoaOmpEnd( sessionId: string, launch: typeof spawn = spawn, diff --git a/src/commands/loa.ts b/src/commands/loa.ts index 154bfeb..5212c4f 100644 --- a/src/commands/loa.ts +++ b/src/commands/loa.ts @@ -1,16 +1,13 @@ // recall loa command - Library of Alexandria capture -import { createLoaEntryFromMessages, getMessagesSinceLastLoa, getLastLoaEntry, getLoaEntry, getLoaMessages, replaceLoaMessagePins } from '../lib/memory.js'; +import { createLoaEntry, createLoaEntryFromMessages, getMessagesSinceLastLoa, getLastLoaEntry, getLoaEntry, getLoaMessages, invalidateRecordEmbedding, replaceLoaMessagePins } from '../lib/memory.js'; import { detectProject } from '../lib/project.js'; import { embed, embeddingToBlob, checkEmbeddingService } from '../lib/embeddings.js'; import { getDb } from '../db/connection.js'; import { formatMessagesForExtraction, runFabricExtract } from '../lib/extraction.js'; -import { getRecallHome } from '../lib/runtime-paths.js'; -import { unlinkSync } from 'fs'; -import { join } from 'path'; import { spawn } from 'node:child_process'; -import { fileURLToPath } from 'node:url'; import type { Message } from '../types/index.js'; +import { exclusiveLoaLock, loadOmpLifecycleMessages, OMP_CURATED_DESCRIPTION, ompCuratedSnapshotBehind, ompSnapshotsMatch, releaseLoaLock } from '../../hosts/omp/loa-protocol.js'; interface LoaOptions { continues?: number; @@ -203,68 +200,39 @@ export function runLoaList(limit: number = 10): void { } } -const OMP_CURATED_DESCRIPTION = 'Curated fabric extraction from omp session end.'; const OMP_LOA_ATTEMPTS = 8; -const OMP_LOA_CLI = join(fileURLToPath(new URL('../..', import.meta.url)), 'dist', 'index.js'); - -function releaseOmpLoaLock(sessionId: string): void { - try { - unlinkSync(join(getRecallHome(), `omp-loa-${sessionId}.pid`)); - } catch { - // absent on a hand start - } -} function requeueOmpLoa(sessionId: string): void { - const child = spawn('bun', [OMP_LOA_CLI, 'loa', 'omp-end', sessionId], { + const child = spawn('bun', [process.argv[1], 'loa', 'omp-end', sessionId], { detached: true, stdio: 'ignore', }); - child.once('error', () => releaseOmpLoaLock(sessionId)); + child.once('error', () => releaseLoaLock(sessionId)); child.unref(); } -function loadOmpLifecycleMessages(sessionId: string): Message[] { - return getDb().prepare(` - SELECT m.id, m.session_id, m.timestamp, m.role, m.content, m.project, m.importance, m.provenance - FROM published_messages m - WHERE EXISTS ( - SELECT 1 FROM active_host_ingest_messages h - WHERE h.message_id = m.id AND h.source = 'omp' AND h.session_id = ? - ) - ORDER BY m.id - `).all(sessionId) as Message[]; -} - -function ompLifecycleHighWater(sessionId: string): number | null { - const row = getDb().prepare(` - SELECT MAX(message_id) AS id FROM active_host_ingest_messages - WHERE source = 'omp' AND session_id = ? - `).get(sessionId) as { id: number | null }; - return row.id; -} - function writeOmpCurated( sessionId: string, messages: Message[], text: string, - highWater: number, ): boolean { const db = getDb(); db.exec('BEGIN IMMEDIATE'); try { - if (ompLifecycleHighWater(sessionId) !== highWater) { + if (!ompSnapshotsMatch(loadOmpLifecycleMessages(db, sessionId), messages)) { db.exec('ROLLBACK'); return false; } const session = db.prepare('SELECT project FROM sessions WHERE session_id = ?') .get(sessionId) as { project: string | null } | undefined; const existing = db.prepare(` - SELECT id FROM loa_entries WHERE session_id = ? AND description = ? + SELECT id, fabric_extract FROM loa_entries WHERE session_id = ? AND description = ? ORDER BY id DESC LIMIT 1 - `).get(sessionId, OMP_CURATED_DESCRIPTION) as { id: number } | undefined; + `).get(sessionId, OMP_CURATED_DESCRIPTION) as { id: number; fabric_extract: string } | undefined; + const highWater = messages.reduce((max, message) => Math.max(max, message.id!), 0); + let loaId: number; if (!existing) { - createLoaEntryFromMessages({ + loaId = createLoaEntry({ title: `Omp session ${sessionId}`, description: OMP_CURATED_DESCRIPTION, fabric_extract: text, @@ -274,15 +242,17 @@ function writeOmpCurated( tags: 'curated,omp', message_count: messages.length, provenance: 'extracted', - }, messages); + }); } else { + loaId = existing.id; db.prepare(` UPDATE loa_entries SET fabric_extract = ?, message_count = ?, snapshot_max_message_id = ? WHERE id = ? `).run(text, messages.length, highWater, existing.id); - replaceLoaMessagePins(existing.id, messages, db); + if (existing.fabric_extract !== text) invalidateRecordEmbedding(db, 'loa_entries', existing.id); } + replaceLoaMessagePins(loaId, messages, db); db.exec('COMMIT'); return true; } catch (error) { @@ -291,47 +261,59 @@ function writeOmpCurated( } } -function loadedHighWater(messages: readonly Message[]): number | null { - const id = messages[messages.length - 1]?.id; - return Number.isSafeInteger(id) ? id! : null; -} - export async function runLoaOmpEnd( sessionId: string, deps: { extract?: typeof runFabricExtract; afterLoad?: () => void; beforeWrite?: () => void; + afterWrite?: () => void; } = {}, ): Promise { const extract = deps.extract ?? runFabricExtract; let handoff = false; let hooked = false; + let completed = false; try { for (let attempt = 0; attempt < OMP_LOA_ATTEMPTS; attempt++) { - const messages = loadOmpLifecycleMessages(sessionId); - if (messages.length === 0) return 0; + const messages = loadOmpLifecycleMessages(getDb(), sessionId) as Message[]; + if (messages.length === 0) { + completed = true; + return 0; + } if (attempt === 0) deps.afterLoad?.(); - const highWater = loadedHighWater(messages); - if (highWater == null) return 0; let text: string; try { text = extract(formatMessagesForExtraction(messages)); } catch { return 0; } - if (ompLifecycleHighWater(sessionId) !== highWater) continue; + if (!ompSnapshotsMatch(loadOmpLifecycleMessages(getDb(), sessionId), messages)) continue; if (!hooked) { hooked = true; deps.beforeWrite?.(); - if (ompLifecycleHighWater(sessionId) !== highWater) continue; + if (!ompSnapshotsMatch(loadOmpLifecycleMessages(getDb(), sessionId), messages)) continue; + } + if (writeOmpCurated(sessionId, messages, text)) { + completed = true; + deps.afterWrite?.(); + return 0; } - if (writeOmpCurated(sessionId, messages, text, highWater)) return 0; } requeueOmpLoa(sessionId); handoff = true; return 0; } finally { - if (!handoff) releaseOmpLoaLock(sessionId); + if (!handoff) { + releaseLoaLock(sessionId); + if (completed && ompCuratedSnapshotBehind(getDb(), sessionId) && exclusiveLoaLock(sessionId)) { + try { + requeueOmpLoa(sessionId); + } catch (error) { + releaseLoaLock(sessionId); + throw error; + } + } + } } } diff --git a/tests/commands/loa-omp-end.test.ts b/tests/commands/loa-omp-end.test.ts index 9b257da..e5e6643 100644 --- a/tests/commands/loa-omp-end.test.ts +++ b/tests/commands/loa-omp-end.test.ts @@ -1,27 +1,76 @@ -import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; -import { existsSync, mkdirSync, writeFileSync } from 'fs'; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from 'bun:test'; +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs'; import { join } from 'path'; import { getDb } from '../../src/db/connection'; import { runLoaOmpEnd } from '../../src/commands/loa'; import { ExtractorConfigError } from '../../src/lib/extraction'; import { ingestHostTranscript } from '../../src/lib/host-ingest'; -import { addMessage } from '../../src/lib/memory'; +import { addMessage, getLoaMessages } from '../../src/lib/memory'; import { getRecallHome } from '../../src/lib/runtime-paths'; +import { curatedSnapshotBehind, exclusiveLoaLock } from '../../hosts/omp/recall'; import { isolateTestHome, setupTestDb, teardownTestDb } from '../helpers/setup'; const BASIC = 'Automatic terminal extraction from omp lifecycle capture.'; const CURATED = 'Curated fabric extraction from omp session end.'; let restoreHome: (() => void) | undefined; +let previousRecallHome: string | undefined; +let workerDir: string; +let workerCli: string; + +beforeAll(async () => { + workerDir = mkdtempSync(join(import.meta.dir, '../../.agents/omp-loa-test-')); + const entry = join(workerDir, 'index.ts'); + writeFileSync(entry, ` + import { existsSync, readFileSync, writeFileSync } from 'node:fs'; + import { runLoaOmpEnd } from ${JSON.stringify(join(import.meta.dir, '../../src/commands/loa.ts'))}; + import { ingestHostTranscript } from ${JSON.stringify(join(import.meta.dir, '../../src/lib/host-ingest.ts'))}; + const sessionId = process.argv.at(-1); + function pause() { + if (existsSync(process.env.READY)) return; + writeFileSync(process.env.READY, '1'); + const start = Date.now(); + while (!existsSync(process.env.GO)) { + if (Date.now() - start > 5000) throw new Error('worker timed out'); + } + } + const code = await runLoaOmpEnd(sessionId, { + [process.env.HOOK || 'afterLoad']: process.env.READY ? pause : undefined, + extract(content) { + if (process.env.COUNTER) { + const calls = existsSync(process.env.COUNTER) ? Number(readFileSync(process.env.COUNTER, 'utf8')) + 1 : 1; + writeFileSync(process.env.COUNTER, String(calls)); + if (calls <= 8) ingestHostTranscript({ + source: 'omp', sessionId, project: 'recall-test', finalize: true, + messages: [{ role: 'user', content: 'retry round ' + calls, sourcePosition: calls, nativeId: 'n-' + calls }], + }); + } + return content; + }, + }); + process.exit(code); + `); + const result = await Bun.build({ entrypoints: [entry], outdir: join(workerDir, 'dist'), target: 'bun', external: ['bun:sqlite', 'sqlite-vec'] }); + if (!result.success) throw new AggregateError(result.logs, 'Could not bundle omp worker'); + workerCli = join(workerDir, 'dist/index.js'); +}); + +afterAll(() => { + if (workerDir) rmSync(workerDir, { recursive: true, force: true }); +}); beforeEach(() => { + previousRecallHome = process.env.RECALL_HOME; restoreHome = isolateTestHome(); + process.env.RECALL_HOME = join(process.env.HOME!, '.agents', 'Recall'); setupTestDb(); mkdirSync(getRecallHome(), { recursive: true }); }); afterEach(() => { teardownTestDb(); + if (previousRecallHome === undefined) delete process.env.RECALL_HOME; + else process.env.RECALL_HOME = previousRecallHome; restoreHome?.(); restoreHome = undefined; }); @@ -53,7 +102,7 @@ function curatedRows(sessionId: string) { function lifecycleMax(sessionId: string): number { return (getDb().prepare(` SELECT MAX(message_id) AS id FROM active_host_ingest_messages - WHERE source = 'omp' AND session_id = ? + WHERE source = 'omp' AND session_id = ? AND source_position IS NOT NULL `).get(sessionId) as { id: number }).id; } @@ -127,6 +176,57 @@ describe('recall loa omp-end', () => { expect(rows[0].snapshot_max_message_id).toBeGreaterThan(first.snapshot_max_message_id!); }); + test.each([false, true])('a revisited snapshot is curated with an earlier row id (existing curated: %s)', async (existing) => { + capture('ses-revisit', 'snapshot A', 0); + const firstId = lifecycleMax('ses-revisit'); + capture('ses-revisit', 'snapshot B', 1); + if (existing) await runLoaOmpEnd('ses-revisit', { extract: content => content }); + capture('ses-revisit', 'snapshot A', 0); + expect(lifecycleMax('ses-revisit')).toBe(firstId); + expect(curatedSnapshotBehind('ses-revisit')).toBe(true); + await runLoaOmpEnd('ses-revisit', { extract: content => content }); + const rows = curatedRows('ses-revisit'); + expect(rows).toHaveLength(1); + expect(rows[0].fabric_extract).toContain('snapshot A'); + expect(rows[0].fabric_extract).not.toContain('snapshot B'); + expect(rows[0].snapshot_max_message_id).toBe(firstId); + expect(curatedSnapshotBehind('ses-revisit')).toBe(false); + }); + + test('removing an earlier frame invalidates a snapshot with the same maximum id', async () => { + const sessionId = 'ses-same-max'; + const earlier = { role: 'user' as const, content: 'earlier frame', sourcePosition: 0, nativeId: 'a' }; + const later = { role: 'user' as const, content: 'later frame', sourcePosition: 1, nativeId: 'b' }; + ingestHostTranscript({ source: 'omp', sessionId, finalize: true, messages: [earlier, later] }); + await runLoaOmpEnd(sessionId, { extract: content => content }); + const first = curatedRows(sessionId)[0]; + ingestHostTranscript({ source: 'omp', sessionId, finalize: true, messages: [later] }); + expect(lifecycleMax(sessionId)).toBe(first.snapshot_max_message_id!); + expect(curatedSnapshotBehind(sessionId)).toBe(true); + await runLoaOmpEnd(sessionId, { extract: content => content }); + const row = curatedRows(sessionId)[0]; + expect(row.id).toBe(first.id); + expect(getLoaMessages(row.id).map(message => message.content)).toEqual(['later frame']); + expect(curatedSnapshotBehind(sessionId)).toBe(false); + }); + + test.each(['old extract', 'new extract'])('an update returning %s keeps embeddings aligned with the extract', async (text) => { + capture('ses-embedding', 'first frame', 0); + await runLoaOmpEnd('ses-embedding', { extract: () => 'old extract' }); + const id = curatedRows('ses-embedding')[0].id; + capture('ses-unrelated', 'unrelated frame', 0); + const db = getDb(); + const insert = db.prepare(`INSERT INTO embeddings (source_table, source_id, model, dimensions, embedding) VALUES ('loa_entries', ?, 'fixture', 1, ?)`); + const unrelated = (db.prepare('SELECT id FROM loa_entries WHERE session_id = ? AND description = ?').get('ses-unrelated', BASIC) as { id: number }).id; + insert.run(id, new Uint8Array(4)); + insert.run(unrelated, new Uint8Array(4)); + capture('ses-embedding', 'second frame', 1); + await runLoaOmpEnd('ses-embedding', { extract: () => text }); + expect(curatedRows('ses-embedding')[0].fabric_extract).toBe(text); + expect(db.prepare('SELECT source_id FROM embeddings WHERE source_table = ? AND source_id = ?').all('loa_entries', id)).toEqual(text === 'old extract' ? [{ source_id: id }] : []); + expect(db.prepare('SELECT source_id FROM embeddings WHERE source_table = ? AND source_id = ?').all('loa_entries', unrelated)).toEqual([{ source_id: unrelated }]); + }); + test('a later capture during extract is not overwritten by the stale extract', async () => { capture('ses-stale', 'first capture', 0); let calls = 0; @@ -205,7 +305,7 @@ describe('recall loa omp-end', () => { expect(rows[0].snapshot_max_message_id).toBe(lifecycleMax('ses-commit')); }); - test('a capture during the child worker is covered without another parent call', async () => { + test.each(['afterLoad', 'afterWrite'])('a capture at %s is covered without another parent call', async (hook) => { const sessionId = 'ses-xproc'; capture(sessionId, 'first capture', 0); const home = getRecallHome(); @@ -213,30 +313,8 @@ describe('recall loa omp-end', () => { const ready = join(home, 'ready'); const go = join(home, 'go'); writeFileSync(pid, '1'); - const modulePath = join(import.meta.dir, '../../src/commands/loa.ts'); - const child = Bun.spawn(['bun', '-e', ` - import { existsSync, writeFileSync } from 'fs'; - import { runLoaOmpEnd } from ${JSON.stringify(modulePath)}; - let waited = false; - const code = await runLoaOmpEnd(process.env.SESSION_ID, { - afterLoad() { writeFileSync(process.env.READY, '1'); }, - extract(content) { - if (!waited) { - waited = true; - const start = Date.now(); - while (!existsSync(process.env.GO)) { - if (Date.now() - start > 5000) { - console.error('child timed out waiting to extract'); - return content; - } - } - } - return content; - }, - }); - process.exit(code); - `], { - env: { ...process.env, RECALL_HOME: home, SESSION_ID: sessionId, READY: ready, GO: go }, + const child = Bun.spawn([process.execPath, workerCli, 'loa', 'omp-end', sessionId], { + env: { ...process.env, READY: ready, GO: go, HOOK: hook }, stdout: 'pipe', stderr: 'pipe', }); @@ -244,19 +322,54 @@ describe('recall loa omp-end', () => { const start = Date.now(); while (!existsSync(ready)) { if (child.exitCode !== null || Date.now() - start > 5000) break; + await Bun.sleep(10); } + expect(existsSync(ready)).toBe(true); capture(sessionId, 'final capture', 1); + expect(exclusiveLoaLock(sessionId)).toBe(false); writeFileSync(go, '1'); const exit = await child.exited; const stderr = await new Response(child.stderr).text(); expect(exit, stderr).toBe(0); + await waitForCuration(sessionId, pid); const rows = curatedRows(sessionId); expect(rows, stderr).toHaveLength(1); expect(rows[0].fabric_extract).toContain('final capture'); expect(rows[0].snapshot_max_message_id).toBe(lifecycleMax(sessionId)); expect(existsSync(pid)).toBe(false); } finally { + writeFileSync(go, '1'); child.kill(); + await child.exited; } - }); + }, 15_000); + + test('exhausted attempts hand off to a working bundled successor', async () => { + const sessionId = 'ses-retry'; + capture(sessionId, 'initial frame', 0); + const pid = join(getRecallHome(), `omp-loa-${sessionId}.pid`); + const counter = join(getRecallHome(), 'counter'); + expect(exclusiveLoaLock(sessionId)).toBe(true); + const child = Bun.spawn([process.execPath, workerCli, 'loa', 'omp-end', sessionId], { + env: { ...process.env, COUNTER: counter }, stdout: 'pipe', stderr: 'pipe', + }); + try { + expect(await child.exited, await new Response(child.stderr).text()).toBe(0); + await waitForCuration(sessionId, pid); + const rows = curatedRows(sessionId); + expect(rows).toHaveLength(1); + expect(rows[0].fabric_extract).toContain('retry round 8'); + expect(curatedSnapshotBehind(sessionId)).toBe(false); + } finally { + child.kill(); + await child.exited; + } + }, 15_000); }); + +async function waitForCuration(sessionId: string, path: string): Promise { + const start = Date.now(); + while ((existsSync(path) || curatedSnapshotBehind(sessionId)) && Date.now() - start < 5000) await Bun.sleep(10); + expect(existsSync(path)).toBe(false); + expect(curatedSnapshotBehind(sessionId)).toBe(false); +} diff --git a/tests/omp-integration.test.ts b/tests/omp-integration.test.ts index 15f60c6..f41b0e3 100644 --- a/tests/omp-integration.test.ts +++ b/tests/omp-integration.test.ts @@ -498,11 +498,19 @@ describe('omp native extension', () => { const dbPath = join(dir, 'recall.db'); const db = new Database(dbPath); db.exec(` - CREATE TABLE active_host_ingest_messages (source TEXT, session_id TEXT, message_id INTEGER); + CREATE TABLE active_host_ingest_messages (source TEXT, session_id TEXT, message_id INTEGER, source_position INTEGER); + CREATE TABLE published_messages ( + id INTEGER, session_id TEXT, timestamp TEXT, role TEXT, content TEXT, project TEXT, importance INTEGER, provenance TEXT + ); CREATE TABLE loa_entries ( id INTEGER PRIMARY KEY, session_id TEXT, description TEXT, snapshot_max_message_id INTEGER ); - INSERT INTO active_host_ingest_messages (source, session_id, message_id) VALUES ('omp', 'ses-mem', 7); + CREATE TABLE loa_message_sources ( + loa_id INTEGER, ordinal INTEGER, message_id INTEGER, session_id TEXT, timestamp TEXT, role TEXT, + content TEXT, project TEXT, importance INTEGER, provenance TEXT + ); + INSERT INTO active_host_ingest_messages VALUES ('omp', 'ses-mem', 7, 0); + INSERT INTO published_messages VALUES (7, 'ses-mem', '2026-10-18T00:00:00Z', 'user', 'captured frame', NULL, 5, 'verbatim'); `); db.close(); const previousRecall = process.env.RECALL_DB_PATH; @@ -517,6 +525,11 @@ describe('omp native extension', () => { INSERT INTO loa_entries (session_id, description, snapshot_max_message_id) VALUES (?, 'Curated fabric extraction from omp session end.', 7) `).run('ses-mem'); + marked.exec(` + INSERT INTO loa_message_sources + SELECT 1, 0, id, session_id, timestamp, role, content, project, importance, provenance + FROM published_messages; + `); marked.close(); expect(curatedSnapshotBehind('ses-mem')).toBe(false); } finally { From dd5f5fea813aa2efaf3ebb652d70e3289da7e927 Mon Sep 17 00:00:00 2001 From: Atlas-Key <296298943+Atlas-Key@users.noreply.github.com> Date: Mon, 19 Oct 2026 13:17:00 -0400 Subject: [PATCH 9/9] no-mistakes(document): Update omp curation documentation and verify lint --- CONTEXT.md | 4 ++-- docs/OMP_INTEGRATION.md | 4 +++- docs/architecture.md | 4 ++-- docs/cli-reference.md | 2 ++ docs/mcp-tools.md | 2 +- 5 files changed, 10 insertions(+), 6 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index 0902ba1..d390fd3 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -24,11 +24,11 @@ Recall's core product posture: it captures and curates memory in the background ## LoA **Automatic-capture LoA**: -An extracted Library of Alexandria entry created by terminal session capture without an explicit user LoA command. +A Library of Alexandria entry produced by terminal session capture in the automatic-capture tier, separate from Curated LoA. _Avoid_: hook LoA, Stop-hook LoA, Fabric extract (the column name is not the writer) **Curated LoA**: -A Library of Alexandria entry created by an explicit user or agent LoA command. +A Library of Alexandria entry created by a user or agent LoA command, a session dump, or host background curation. _Avoid_: manual LoA, Fabric LoA ## Extractor diff --git a/docs/OMP_INTEGRATION.md b/docs/OMP_INTEGRATION.md index 352e378..41dc019 100644 --- a/docs/OMP_INTEGRATION.md +++ b/docs/OMP_INTEGRATION.md @@ -34,7 +34,9 @@ Do not install the npm package named `recall-memory`; that name is an unrelated This is turn-completion capture, not crash recovery. An interrupted turn before `session_stop` is not guaranteed captured. Shutdown and pre-compaction hooks are not installed. The active branch is read again on the next stop after a failure. -`session_stop` is still turn completion; after a successful capture the extension detaches `recall loa omp-end` only when that session's Curated source pins differ from the ordered published lifecycle messages and no child is already running. Revisiting an earlier snapshot also triggers curation. The worker releases its lock and checks again before finishing so a capture that deferred to it is still covered. Fabric failure keeps the basic summary. +After a successful capture, the extension opens the database read-only to check curation freshness. It detaches the package-local `recall loa omp-end ` worker when the session has published lifecycle messages and its Curated entry is missing or its source pins differ from those messages, provided no worker holds the session lock. Revisiting an earlier snapshot also triggers curation. The worker releases its lock and checks again before finishing so a capture that deferred to it is still covered. + +The worker uses the configured [Curated Extractor](architecture.md#extractor-config), Fabric by default, and creates one separate entry tagged `curated,omp`. Later successful extracts replace that entry's body, source pins, message count, and snapshot cursor while retaining its ID. Only the session's ordered published lifecycle messages are extracted; explicit dumps on the same session stay separate. A changed extract invalidates the entry's old embedding; this worker does not generate a replacement embedding. Extraction failure leaves the basic summary and any previous Curated entry unchanged; a later successful capture can retry curation. Basic capture continues to work without Fabric. Capture bounds its CLI child to 30 seconds and its serialized JSON payload to 25 MiB, including metadata, escaping, and the truncation marker. Excluded content does not count toward this limit. When the scrubbed text would exceed the limit, capture allocates 60% of the remaining JSON-escaped byte budget to the beginning and 40% to the end of the text, separated by `[Recall omp capture truncated]`; a warning reports the UTF-8 bytes dropped from the scrubbed text. Malformed branches are rejected before starting the child; malformed branches, cancellations, and child failures warn without stopping omp. diff --git a/docs/architecture.md b/docs/architecture.md index fb4c068..878e20b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -111,9 +111,9 @@ MCP (`recall-mcp`) and `agent-skills/` remain the cross-host agent surfaces. | host_ingest_generations | Lifecycle generation identity and publication status | No | | host_ingest_generation_messages | Scrubbed generation records activated by one checkpoint pointer | Yes | | host_ingest_embedding_invalidations | Pending semantic-index cleanup for an activated lifecycle generation | No | -| loa_message_sources | Retention-aware exact message lineage for automatic terminal summaries | No | +| loa_message_sources | Retention-aware exact message lineage for terminal summaries and pinned LoA entries | No | | messages | Conversation turns (user + assistant content); includes `importance` (1-10) and a nullable internal lifecycle-publication token | Yes | -| loa_entries | Library of Alexandria entries (Automatic-capture LoA and Curated LoA); extract body in `fabric_extract` (column name, not the writer); includes `importance` (1-10, floor 5) and an immutable snapshot cursor independent of retention-nullable display ranges | Yes | +| loa_entries | Library of Alexandria entries (Automatic-capture LoA and Curated LoA); extract body in `fabric_extract` (column name, not the writer); includes `importance` (1-10, floor 5) and a snapshot cursor independent of retention-nullable display ranges ([omp update semantics](OMP_INTEGRATION.md#capture-contract)) | Yes | | decisions | Architectural decisions with reasoning; includes `status` (active/superseded/reverted), `confidence` (high/medium/low), and `importance` (1-10) columns | Yes | | learnings | Problems solved and patterns discovered; includes `confidence` (high/medium/low) and `importance` (1-10) columns | Yes | | breadcrumbs | Contextual notes, references, and TODOs (with importance 1-10) | Yes | diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 36eedae..ebfbaec 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -100,6 +100,8 @@ recall loa quote 1 # View raw source messages for ent LoA is the primary knowledge capture mechanism. Raw transcripts are high-noise; Curated LoA entries contain `fabric` Extractor output, message lineage, continuation chains, and project context. Chaining (`-c `) links a new entry to a prior one, forming a thread of related sessions. Automatic-capture LoA is written by session extract, not this command. Extractor config: [architecture](architecture.md#extractor-config). +The omp extension invokes `recall loa omp-end ` for [background curation](OMP_INTEGRATION.md#capture-contract). + ### Decisions ```bash diff --git a/docs/mcp-tools.md b/docs/mcp-tools.md index b63f0f4..9f97485 100644 --- a/docs/mcp-tools.md +++ b/docs/mcp-tools.md @@ -141,7 +141,7 @@ memory_stats() ## loa_show -Show a full Library of Alexandria entry. The body is the `fabric_extract` column (name, not the writer). Entries may be Automatic-capture LoA or Curated LoA; only Curated LoA from `recall loa` or `memory_dump` with `skip_fabric: false` is Fabric `extract_wisdom` output. +Show a full Library of Alexandria entry. The body is the `fabric_extract` column (name, not the writer). For entry types, see [LoA terminology](../CONTEXT.md#loa). **Parameters**