diff --git a/CHANGELOG.md b/CHANGELOG.md index 2666701..9364395 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,9 @@ Every entry says what changed as a caller sees it, why, and what to watch for ### Added +- The installed Drive research check now verifies person links, stale-note revision refusal, + scoped source-backed memories and local source retirement through the released CLI. It + checks persisted memory staleness and owner-note preservation with synthetic data only. - Controlled Drive acceptance: an explicit-profile, isolated-workspace checker for cached text, hierarchy, source identity, exact search, Markdown, local annotations and optional syncs. A private fixture manifest and owner-started test-account login are required for live use; diff --git a/performance/documents/offline-catalog/README.md b/performance/documents/offline-catalog/README.md index 4d0a55a..73e33ac 100644 --- a/performance/documents/offline-catalog/README.md +++ b/performance/documents/offline-catalog/README.md @@ -45,6 +45,38 @@ Negative cases cover wrong scope, wrong-source search hits, pending sync, change lost notes, unknown input, existing output and error-output canary exclusion. No Google login, conversion fidelity, remote mutations, real embeddings or OAuth verification were measured. +### Installed knowledge lifecycle + +On 2026-10-15, the same exact npm package and synthetic fixture passed seven further behavior +groups. The question was whether the document knowledge features work together through the +released CLI, including source retirement, rather than only through individual service tests. +Reproduce with the `live-installed.mjs` command above; it now also runs +[`knowledge-installed.mjs`](knowledge-installed.mjs). + +| Behavior | Result | +| --- | --- | +| Explicitly link an imported document to a seeded person and filter by that person | Passed | +| Edit a local note, then try its old revision | Stale edit refused; updated text preserved | +| Create and confirm a memory | Proposed first; source subject/evidence and explicit work scope retained | +| List/search that memory within its scope and source | Work result found; personal scope empty | +| Remove the selected root and reconcile | Catalog, imported list, keyword search and source snapshot emptied | +| Inspect persisted knowledge after source retirement | Confirmed memory stale; owner-written note preserved | +| Reconcile again | No further removals | + +The existing eight initial and eight baseline checks still passed before the lifecycle cases. +Setup: Linux, Node 24.19.0, Drive 0.7.0, Core 0.278.0 and framework 0.28.2. The person is seeded +through the shared store's public API; links, notes, memories and reconciliation use the actual +compiled Drive CLI. Persisted memory/note outcomes are inspected through the public store API. +No SQL or Drive source imports are used. + +All processes use isolated state and the HTTP/browser/keyring denial wrapper. Reconciliation +calls `sync` without `--offline` only after `folders remove` leaves no selected roots: there is +no Google source to read, and the denial wrapper would fail an unexpected request. This is a +local source-retirement test, not a live Google move, trash or permission-change test. Automatic +editor identity association, project links, AI extraction and provider conversion fidelity remain +outside these results. Source removal intentionally exercises snapshot deletion; all other test +artifacts and reports remain available for inspection. + ## Setup and reproduction Synthetic data only: one root, one nested folder, one indexed PDF fixture and a second explicit diff --git a/performance/documents/offline-catalog/knowledge-installed.mjs b/performance/documents/offline-catalog/knowledge-installed.mjs new file mode 100644 index 0000000..394800a --- /dev/null +++ b/performance/documents/offline-catalog/knowledge-installed.mjs @@ -0,0 +1,80 @@ +import assert from "node:assert/strict" +import { existsSync } from "node:fs" +import { join } from "node:path" + +export const verifyKnowledge = async ({ invoke, openStore, env, workspace, reference }) => { + const checks = [] + const item = (words, options) => { + const answer = invoke(words, options) + assert.ok(answer.item) + return answer.item + } + const list = (words) => { + const answer = invoke(words) + assert.ok(Array.isArray(answer.items)) + return answer.items + } + const store = await openStore({ path: env.DRIVE_STORE, env }) + let person + try { + const account = { provider: "synthetic-messenger", account: "synthetic-account" } + await store.saveAccount(account, { name: "Synthetic test account" }) + await store.savePeople(account, [{ id: "synthetic-editor", name: "Rin Example" }]) + person = await store.personOf({ provider: account.provider, id: "synthetic-editor" }) + assert.ok(person) + } finally { + await store.close() + } + const personRef = `person:${person.uid}` + item(["relationships", "add", "doc", personRef, "--kind", "related-to"]) + assert.ok(item(["documents", "context", "doc"]).links.some((link) => link.to === personRef)) + assert.ok(list(["documents", "list", "--person", personRef]).some((doc) => doc.id === "doc")) + checks.push("explicit-person-link-and-filter") + + const note = item(["notes", "add", "doc", "Synthetic lifecycle note"]) + const edited = item(["notes", "edit", "doc", note.ref, "Updated lifecycle note", "--revision", String(note.revision)]) + assert.ok(edited.revision > note.revision) + const refused = invoke(["notes", "edit", "doc", note.ref, "Stale overwrite", "--revision", String(note.revision)], { + failure: true, + }) + assert.ok(refused.error?.code) + assert.equal(item(["notes", "show", "doc", note.ref]).text, "Updated lifecycle note") + checks.push("note-edit-refuses-stale-revision") + + const memory = item(["memories", "add", "doc", "Synthetic lifecyclemarker decision", "--scope", "work"]) + assert.equal(memory.subject, reference) + assert.deepEqual(memory.evidence, [reference]) + assert.equal(memory.status, "proposed") + assert.equal(memory.scope, "work") + assert.equal(item(["memories", "confirm", "doc", memory.ref]).status, "confirmed") + checks.push("memory-evidence-and-review") + assert.equal(list(["memories", "list", "doc", "--scope", "personal"]).length, 0) + assert.ok(list(["memories", "list", "doc", "--scope", "work"]).some((entry) => entry.ref === memory.ref)) + const found = item(["memories", "search", "doc", "lifecyclemarker", "--scope", "work"]) + assert.ok(found.items.some((entry) => entry.ref === memory.ref)) + checks.push("memory-scope-and-source-search") + + item(["folders", "remove", "root"]) + // With no selected roots, this sync reconciles local sources; the provider/keyring guard remains installed. + const reconciled = item(["sync"], { offline: false }) + assert.equal(reconciled.removed, 1) + assert.equal(reconciled.pending, 0) + assert.equal(list(["catalog", "list"]).length, 0) + assert.equal(list(["documents", "list"]).length, 0) + assert.equal(item(["search", "wirecatbodymarker", "--exact"]).hits.length, 0) + assert.equal(invoke(["documents", "show", "doc"], { failure: true }).error.code, "not_found") + assert.equal(existsSync(join(workspace, "state/profiles/synthetic/documents/doc.json")), false) + checks.push("scope-removal-reconciles-catalog-search-and-snapshot") + + const retained = await openStore({ path: env.DRIVE_STORE, env }) + try { + assert.equal((await retained.memories.get(memory.ref)).status, "stale") + assert.equal((await retained.notes.note(note.ref)).text, "Updated lifecycle note") + } finally { + await retained.close() + } + checks.push("removed-source-stales-memory-and-keeps-owner-note") + assert.equal(item(["sync"], { offline: false }).removed, 0) + checks.push("scope-removal-is-idempotent") + return checks +} diff --git a/performance/documents/offline-catalog/live-installed.mjs b/performance/documents/offline-catalog/live-installed.mjs index 8815492..821cf0e 100644 --- a/performance/documents/offline-catalog/live-installed.mjs +++ b/performance/documents/offline-catalog/live-installed.mjs @@ -5,6 +5,7 @@ import { createRequire } from "node:module" import { tmpdir } from "node:os" import { join, resolve } from "node:path" import { fileURLToPath, pathToFileURL } from "node:url" +import { verifyKnowledge } from "./knowledge-installed.mjs" import { initialize, workspaceEnvironment } from "./live-input.mjs" const [input, ...rest] = process.argv.slice(2) @@ -147,6 +148,20 @@ const run = (output, extra) => { const first = join(root, "first.json") const annotated = run(first, ["--annotate"]) const repeated = run(join(root, "repeat.json"), ["--baseline", first]) +const invoke = (words, { offline = true, failure = false } = {}) => { + assert.ok(offline || words.join(" ") === "sync") + const result = spawnSync( + process.execPath, + [join(wrapper, "dist/bin/drive.js"), "synthetic", ...(offline ? ["--offline"] : []), "--json", ...words], + { env, encoding: "utf8", timeout: 30000 }, + ) + assert.equal(result.error, undefined) + assert.equal(result.status === 0, !failure, "synthetic lifecycle command failed") + const answer = JSON.parse(result.stdout.trim() || result.stderr.trim()) + if (!failure) assert.equal(answer.meta.complete, true) + return answer +} +const knowledge = await verifyKnowledge({ invoke, openStore, env, workspace, reference: note.ref }) process.stdout.write( - `${JSON.stringify({ passed: true, data: "synthetic", live_google: false, drive: metadata.version, core: metadata.dependencies["@wirecat/cli-core"], first_checks: annotated.checks.length, repeat_checks: repeated.checks.length })}\n`, + `${JSON.stringify({ passed: true, data: "synthetic", live_google: false, drive: metadata.version, core: metadata.dependencies["@wirecat/cli-core"], first_checks: annotated.checks.length, repeat_checks: repeated.checks.length, knowledge_checks: knowledge })}\n`, )