diff --git a/memhub/docs/TODO-raw-recall-without-model.md b/memhub/docs/TODO-raw-recall-without-model.md new file mode 100644 index 0000000..136af50 --- /dev/null +++ b/memhub/docs/TODO-raw-recall-without-model.md @@ -0,0 +1,66 @@ +# TODO: recall imported conversations before a model is configured + +Status: open. Found 2026-10-28 by the Linux lifecycle run on build 35. + +## Problem + +A new user who has imported conversations but not configured a generation +model gets nothing from `MemHub_recall` or `memhub recall`. + +`recall_with_fallback` (`apps/core/src/one_memory/recall_fallback.py`) is +meant to cover this case: without a model it searches the SDK raw layer +(`l1_raw`), the imported conversations themselves. But under SDK authority +nothing writes that layer until a model is active: + +- File import and live capture only stage conversations into + `memory_write_jobs` (assembling, then pending). +- The durable write drain claims a job only when + `_sedimentation_claim_enabled` holds, which requires an active model + profile (`api.py`). +- The SDK writes `l1_raw` only inside `client.add`, which the drain calls. + +So until a model is configured, the raw layer is empty and the fallback has +nothing to search. Before the SDK cutover, imported Evidence lived in +MemHub's own database and was searchable without a model. + +The Linux lifecycle verification used to assert "import, then recall finds +it" on a machine with no model. It now asserts that the import reached the +write queue instead (`scripts/platform/linux/verify-linux-lifecycle.mjs`, +`waitForQueued`). Restore the recall assertion once this is fixed. + +## Direction + +Write imported conversations to the SDK raw layer without a generation +model, and leave extraction for when a model is configured. + +- The raw layer needs embeddings only. The bundled local ONNX model + (`builtin_retrieval_snapshot`) can produce them, so no network model is + needed. +- SDK: a public raw-only write, for example `add(..., infer=False)` or + `add_raw(messages, ...)`, that stores `l1_raw` with source, session, + workspace and `memory_at`, and returns its raw ids. It must be idempotent + by `request_id`, like `add`. +- MemHub: split the claim gate. Without a model, the drain may claim a + pending job for a raw-only write. Once a model is active, the same job + still needs extraction, and that write must not store `l1_raw` again. + Either the job records that its raw part is done, or extraction runs + through `add_extracted` / a mode that skips the raw store. +- The source sync switch and grant still apply, exactly as for extraction. + +## Open questions + +- Should raw-only ingestion run at all when the user has turned + sedimentation off, or does that switch mean "store nothing"? +- Storage cost: raw ingestion of a full history import without a model + embeds every turn up front. Check the time and disk cost on a large + history. +- When the user configures a model later, extract from the stored raw rows, + or re-read the jobs' frozen inputs? The second keeps one write path. + +## Done when + +- On a fresh install with no model, `memhub recall ""` returns that conversation with `mode: "raw"`. +- Configuring a model afterwards extracts memories from the same + conversations without duplicating their raw rows. +- `verify-linux-lifecycle.mjs` asserts recall again. diff --git a/memhub/scripts/platform/linux/linux-runtime-scripts.test.mjs b/memhub/scripts/platform/linux/linux-runtime-scripts.test.mjs index 9e3f542..f323b89 100644 --- a/memhub/scripts/platform/linux/linux-runtime-scripts.test.mjs +++ b/memhub/scripts/platform/linux/linux-runtime-scripts.test.mjs @@ -66,7 +66,6 @@ describe("Linux runtime scripts", () => { '"stop"', '"init"', '"scan"', - '"recall"', '"uninstall"', ]) { assert.match(script, new RegExp(command)); @@ -88,9 +87,11 @@ describe("Linux runtime scripts", () => { script, /Exit 1 is a valid transient receipt[\s\S]{0,300}\[0, 1, 3\]/, ); - assert.match(script, /append_recalled/); + assert.match(script, /initial_queued/); + assert.match(script, /append_queued/); + assert.match(script, /FROM memory_write_jobs/); assert.match(script, /idempotent_upgrade_instance_id_changed/); - assert.match(script, /post_upgrade_recalled/); + assert.match(script, /post_upgrade_queued/); assert.match(script, /uninstall_preserved_data/); assert.match(script, /is-enabled/); assert.match(script, /is-active/); diff --git a/memhub/scripts/platform/linux/verify-linux-lifecycle.mjs b/memhub/scripts/platform/linux/verify-linux-lifecycle.mjs index 45cfd73..2f603d9 100644 --- a/memhub/scripts/platform/linux/verify-linux-lifecycle.mjs +++ b/memhub/scripts/platform/linux/verify-linux-lifecycle.mjs @@ -10,6 +10,7 @@ import { } from "node:fs"; import os from "node:os"; import path from "node:path"; +import { DatabaseSync } from "node:sqlite"; function fail(message, receipt) { if (receipt) process.stdout.write(`${JSON.stringify(receipt, null, 2)}\n`); @@ -146,18 +147,35 @@ function doctorCheck(document, id) { return check; } -async function waitForRecall(prefix, phrase) { +/** + * Whether an imported phrase reached Core's durable write queue. + * + * Recall cannot prove an import here: this runner has no generation model, + * and with SDK authority nothing reaches the SDK (not even its raw layer) + * until a model is active. What the lifecycle can prove is that the import + * was read and queued for the SDK, durably, in the Runtime's own database. + */ +async function waitForQueued(phrase) { const deadline = Date.now() + 90_000; - let attempt = 0; + const database = path.join(dataDir, "one-memory.sqlite3"); while (Date.now() < deadline) { - attempt += 1; - const result = run( - `${prefix}-${attempt}`, - ["recall", phrase, "--json"], - [0, 1, 3], - 15_000, - ); - if (result.stdout.includes(phrase)) return true; + if (existsSync(database)) { + let connection; + try { + connection = new DatabaseSync(database, { readOnly: true }); + const row = connection + .prepare( + "SELECT COUNT(*) AS count FROM memory_write_jobs " + + "WHERE instr(payload_json, ?) > 0", + ) + .get(phrase); + if (Number(row?.count) > 0) return true; + } catch { + // The queue table appears once Core has migrated its schema. + } finally { + connection?.close(); + } + } await new Promise((resolve) => setTimeout(resolve, 2_000)); } return false; @@ -320,12 +338,11 @@ try { "--import", "--json", ]); - observations.initial_recalled = await waitForRecall( - "recall-initial", + observations.initial_queued = await waitForQueued( "Linux lifecycle initial phrase", ); - if (!observations.initial_recalled) - throw new Error("initial import was not recalled"); + if (!observations.initial_queued) + throw new Error("initial import was not queued for the SDK"); status.import = "passed"; writeFileSync( @@ -338,12 +355,11 @@ try { [0], 30_000, ); - observations.append_recalled = await waitForRecall( - "recall-appended", + observations.append_queued = await waitForQueued( "Linux lifecycle appended phrase", ); - if (!observations.append_recalled) - throw new Error("appended turn was not recalled"); + if (!observations.append_queued) + throw new Error("appended turn was not queued for the SDK"); status.append_sync = "passed"; const beforeUpgrade = await waitForRunningStatus("status-before-upgrade"); @@ -378,12 +394,11 @@ try { if (!observations.idempotent_upgrade_instance_id_changed) { throw new Error("idempotent upgrade did not restart the Runtime"); } - observations.post_upgrade_recalled = await waitForRecall( - "recall-after-upgrade", + observations.post_upgrade_queued = await waitForQueued( "Linux lifecycle appended phrase", ); - if (!observations.post_upgrade_recalled) { - throw new Error("idempotent upgrade lost previously recalled data"); + if (!observations.post_upgrade_queued) { + throw new Error("idempotent upgrade lost previously queued data"); } status.upgrade_idempotent = "passed";