@cairnkeep/cli 2.22.4 → 2.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/claude/commands/recall.md +8 -1
  3. package/docs/README.md +3 -0
  4. package/docs/agents.md +9 -0
  5. package/docs/benchmarks/memory-retrieval-v1.json +115 -0
  6. package/docs/learning/CURRICULUM-MAP.md +1 -1
  7. package/docs/learning/FEATURE-GUIDE.md +7 -1
  8. package/docs/learning/lessons/L00-why-cairnkeep.md +1 -1
  9. package/docs/learning/lessons/L01-safe-trial.md +3 -3
  10. package/docs/learning/lessons/L02-installation.md +2 -2
  11. package/docs/learning/lessons/L03-first-project.md +1 -1
  12. package/docs/learning/lessons/L04-memory-fundamentals.md +20 -0
  13. package/docs/learning/lessons/L19-validated-skill-improvement.md +1 -1
  14. package/docs/learning/lessons/L20-mcp-tool-profiles.md +1 -1
  15. package/docs/learning/lessons/L21-context-packs.md +1 -1
  16. package/docs/learning/lessons/L22-native-windows.md +2 -2
  17. package/docs/learning/lessons/L23-guided-setup.md +1 -1
  18. package/docs/learning/lessons/L24-okf-exchange.md +1 -1
  19. package/docs/learning/lessons/L25-playbooks.md +1 -1
  20. package/docs/learning/lessons/L26-context-intelligence.md +1 -1
  21. package/docs/learning/lessons/L27-ecosystem-evidence.md +1 -1
  22. package/docs/learning/lessons/L28-security-assurance.md +22 -2
  23. package/docs/learning/tracks/operator.md +6 -1
  24. package/docs/learning/video-scripts/V04-memory-retrieval-supplement.md +34 -0
  25. package/docs/learning/video-scripts/V28-security-assurance.md +20 -0
  26. package/docs/memory-retrieval.md +174 -0
  27. package/docs/operating.md +5 -0
  28. package/docs/releasing.md +65 -0
  29. package/docs/security-assurance.md +15 -0
  30. package/docs/storage.md +5 -0
  31. package/examples/eval/bundled-fake.json +1 -1
  32. package/mcp-memory-server/dist/index.js +43 -2
  33. package/mcp-memory-server/dist/memory-retrieval.js +128 -0
  34. package/mcp-memory-server/dist/note-store.js +24 -4
  35. package/mcp-memory-server/package.json +2 -0
  36. package/opencode/command/recall.md +8 -1
  37. package/package.json +1 -1
  38. package/scripts/lib/release-verification.mjs +131 -0
  39. package/scripts/test-memory-retrieval-docs.sh +19 -0
  40. package/scripts/test-release-sbom.sh +32 -0
  41. package/scripts/test-release-verification.mjs +109 -0
  42. package/scripts/test-release-verification.sh +4 -0
  43. package/scripts/test-setup-reconcile.mjs +4 -2
  44. package/scripts/test-windows-native.mjs +1 -0
  45. package/scripts/verify-phase19-runtime-evidence.mjs +43 -3
  46. package/scripts/verify-published-release.mjs +138 -0
  47. package/templates/playbook-agent-instructions.md.template +5 -0
@@ -0,0 +1,174 @@
1
+ # Memory retrieval and the Markdown baseline
2
+
3
+ Status: available since Cairnkeep 2.23.0 as an opt-in strategy. Existing search
4
+ defaults are unchanged; the developer study commands require a source checkout.
5
+
6
+ Cairnkeep keeps authoritative memory text readable. Ordinary memory lives in
7
+ AgentFS-backed SQLite; derived note address spaces and wiki pages use files.
8
+ Optional embeddings are a rebuildable search aid, not fine-tuning, encrypted
9
+ storage, or a replacement for the original text. Semantic search computes
10
+ missing vectors lazily and caches them outside the authoritative records.
11
+ Configured providers receive memory text and queries; an endpoint can run
12
+ locally or remotely. See [storage](storage.md).
13
+
14
+ ## Opt-in hybrid search
15
+
16
+ Existing `memory_search` calls retain legacy behavior and response shapes.
17
+ Clients can explicitly select the new strategy through MCP:
18
+
19
+ ```json
20
+ {
21
+ "scope": "project",
22
+ "query": "office tunnel access",
23
+ "strategy": "hybrid",
24
+ "top_k": 5,
25
+ "explain": true
26
+ }
27
+ ```
28
+
29
+ The result has `mode: "hybrid"`, ordinary original-text `results`, and a
30
+ `retrieval` diagnostic. Empty results are valid: `empty_reason` distinguishes
31
+ an empty scope from `no_relevant_memory`. `embedding_status` distinguishes
32
+ unconfigured, available, failed and not-used providers. Ranking scores are
33
+ labelled `ranking-not-confidence`; neither a cosine nor a lexical score is a
34
+ probability that a statement is true.
35
+
36
+ The new strategy currently supports the `memory` address space. File-backed
37
+ note searches retain their existing exact behavior; requesting hybrid there
38
+ fails explicitly. Existing `memory_read` key/query semantics are unchanged.
39
+
40
+ ### Selection and ranking
41
+
42
+ 1. Resolve explicit scopes and apply enabled typed-node hard filters before
43
+ ranking or embedding. Neither aliases nor semantic similarity widens scope.
44
+ 2. Tokenize Unicode words, ignore a small source-defined set of common
45
+ English/Italian stop words, and rank with BM25 (key terms appear twice).
46
+ Query terms are capped at 64; tokenization is capped at 4,096 terms per field.
47
+ 3. Admit lexical matches covering at least 60% of significant query terms,
48
+ with at least two matched terms for multi-term queries or one for a single
49
+ term. An exact key is admitted directly. Compound identifiers and numeric
50
+ identifiers must match exactly within at least one explicit query variant.
51
+ Match complete identifiers, not prefixes: `ember-42` does not match
52
+ `ember-42-backup`, and `4.2.1` does not match `4.2.1-rc1`. Sentence-ending
53
+ punctuation is not part of an identifier.
54
+ 4. With embeddings available, semantic scores contribute to ranking. A match
55
+ based only on semantics additionally requires an **explicit positive**
56
+ `min_score`. Omitted or zero `min_score` never admits semantic-only matches.
57
+ Choose that threshold using examples from the configured model and corpus;
58
+ no universal cosine cutoff is supplied or claimed as calibrated confidence.
59
+ 5. Put exact keys first, then sort by 80% normalized BM25 contribution plus
60
+ 20% nonnegative cosine contribution. Break ties by scope/key deterministically.
61
+ Collapse identical key/value pairs; keep different values separate.
62
+
63
+ `top_k` is a maximum, not a quota to fill. Provider failure, incomplete vectors,
64
+ zero vectors or invalid dimensions fall back to lexical admission. Failure
65
+ diagnostics include no provider response bodies. This does not change the
66
+ legacy strategy's existing fallback semantics.
67
+
68
+ With `explain: true`, each result includes `match.reasons`, matched terms,
69
+ lexical coverage, the optional semantic score, and source scope/key locators.
70
+ Identical records retain all source locators. `possible_conflicts` identifies
71
+ admitted records sharing a key but having different live values across scopes.
72
+ That is a review aid, not a finding that one value is false or an instruction
73
+ to merge them. Explanations exist only in the response; no new recall log is kept.
74
+
75
+ ### Explicit aliases
76
+
77
+ Aliases can be curated into ordinary memory text, or supplied as bounded query
78
+ alternatives:
79
+
80
+ ```json
81
+ {
82
+ "scope": "project",
83
+ "query": "home connection",
84
+ "strategy": "hybrid",
85
+ "query_aliases": ["office tunnel access", "relaybox"],
86
+ "explain": true
87
+ }
88
+ ```
89
+
90
+ At most eight aliases of 256 UTF-8 bytes each are accepted. The original hybrid
91
+ query is bounded to 4 KiB. Invalid bounds fail before opening scopes or making
92
+ provider requests. Aliases affect lexical selection; the original query is
93
+ embedded once. They are caller-supplied hints and may explicitly substitute an
94
+ identifier, not automatically inferred identities or additional authority.
95
+ `query_aliases` and `explain` require `strategy: "hybrid"`.
96
+
97
+ ## Current values, history and authority
98
+
99
+ Search reads live records and excludes hidden supersession history. Updating a
100
+ fact through the existing supersession workflow makes the new value current
101
+ and preserves the displaced value for explicit history inspection. A new MCP
102
+ process can retrieve the live value after the previous local process exits.
103
+ Simultaneous clients should use one shared server: independent direct local
104
+ AgentFS processes can contend for native database locks.
105
+
106
+ Current means current in the store, not independently verified or still true
107
+ in the real world. No automatic expiry, contradiction resolution, history
108
+ deletion, knowledge promotion or durable memory mutation is introduced. Verify
109
+ maintained sources and supersede stale facts only through an authorized workflow.
110
+ Personal scopes in a trusted deployment are not a multi-user ACL mechanism.
111
+ The separate team preview is unchanged.
112
+
113
+ ## A fair Markdown comparison
114
+
115
+ A curated Markdown folder provides readable durable knowledge, human editing,
116
+ navigation and Git history. Adding the same retrieval policy can reproduce the
117
+ same retrieval results. Cairnkeep provides a maintained shared MCP interface,
118
+ scope routing, lifecycle operations and harness integrations; SQLite itself is
119
+ not evidence of better recall.
120
+
121
+ The reproducible study builds real temporary Markdown files and equivalent
122
+ memory candidates with identical keys, values, scopes and explicit aliases.
123
+ It compares a searchable-folder BM25 baseline, the legacy substring policy,
124
+ the new admission policy and the same new policy applied to Markdown. Both
125
+ folder variants receive the same tokenizer/BM25 implementation; the baseline
126
+ is deliberately strong, and the Markdown parity control distinguishes search
127
+ policy gains from storage format. The substring comparator unions explicit
128
+ query variants, equivalent to separate legacy requests.
129
+
130
+ Thirteen declared scenarios include a changed tunnel configuration, aliases,
131
+ Italian queries, continuation conventions, release policy, unrelated queries,
132
+ nearby/obsolete identifiers and wrong-project content, with 0/100/1,000
133
+ distractors. The [frozen report](benchmarks/memory-retrieval-v1.json) shows:
134
+
135
+ | Adapter, 1,000 distractors | Correct first result, 9 positive cases | Negative queries returning results, 4 cases | Irrelevant value bytes |
136
+ |---|---:|---:|---:|
137
+ | Legacy substring | 3/9 | 0/4 | 0 |
138
+ | Markdown + BM25 | 8/9 | 3/4 | 3,359 |
139
+ | Hybrid lexical admission | 8/9 | 0/4 | 0 |
140
+ | Markdown + same admission | 8/9 | 0/4 | 0 |
141
+
142
+ These are synthetic regression cases, not general retrieval accuracy, an
143
+ independent holdout, autonomous task quality or proof of human consent. The
144
+ semantic-only paraphrase is deliberately missed by lexical modes; the report
145
+ does not claim perfect recall. A real MCP control separately verifies actual
146
+ supersession, new-process handoff, scope filtering before provider requests and
147
+ provider-failure behavior. No live user memory is read by these checks.
148
+
149
+ From a source checkout:
150
+
151
+ ```sh
152
+ npm --prefix mcp-memory-server run build
153
+ npm --prefix mcp-memory-server run check:memory-retrieval
154
+ npm --prefix mcp-memory-server run study:memory-retrieval
155
+ # Explicit provider evaluation, using synthetic fixtures only:
156
+ node mcp-memory-server/scripts/run-memory-retrieval-study.mjs --live --summary
157
+ ```
158
+
159
+ The live study reports a model-ID digest, not credentials or endpoint addresses.
160
+ Its sample threshold of 0.75 is an evaluation arm, not a recommended default.
161
+ An attempted live run during development accepted short probes but failed with
162
+ HTTP 502 on some fixture inputs. No successful live-model result is claimed.
163
+ The offline suite never requires a model, API key, paid service or network beyond
164
+ its own loopback fixture. An optional learned reranker remains deferred until
165
+ provider-specific evidence demonstrates a benefit beyond this simpler policy.
166
+
167
+ ## Research connection
168
+
169
+ [Alex Wang's associative-memory article](https://alexwang.ai/posts/associative-memory/)
170
+ provides a useful perspective on recall interference. Its neural-layer capacity
171
+ experiments do not establish limits for a text database. This implementation
172
+ applies selective retrieval at the application layer without changing model
173
+ weights. Merely applying softmax to an existing score list preserves ordering
174
+ and would not improve fixed-top-k selection.
package/docs/operating.md CHANGED
@@ -32,6 +32,11 @@ sync command but never runs it automatically. This guide covers all three.
32
32
  - Optional: an OpenAI-compatible LLM endpoint for memory extraction and
33
33
  embedding-ranked search. Without it, memory search degrades to substring
34
34
  matching — everything else still works.
35
+ Explicit `memory_search` calls with `strategy: "hybrid"` additionally support
36
+ lexical/identifier admission without a provider, bounded `query_aliases`,
37
+ optional explanations and empty results. Semantic-only admission requires an
38
+ explicit positive `min_score`; see [memory retrieval](memory-retrieval.md)
39
+ for model-specific threshold limits and the Markdown comparison.
35
40
  - Optional: rootless Podman for the containerized memory server and isolated
36
41
  workspace base described in [Containers](containers.md).
37
42
 
package/docs/releasing.md CHANGED
@@ -66,8 +66,73 @@ or moved. If the tree-addressed candidate has expired, rerun CI for the exact
66
66
  release commit before retrying publication; the workflow never substitutes a
67
67
  different version or tree.
68
68
 
69
+ CI job retries can also regenerate the same tree-addressed candidate. Its
70
+ upload explicitly replaces an existing artifact of that name within the same
71
+ workflow run, only after all platform/runtime dependencies succeed. This avoids
72
+ an artifact-name conflict after a completed job is retried; it does not move
73
+ versioned image tags, republish npm versions or replace GitHub Release assets.
74
+ Candidate checksums and tree/version binding are verified as usual.
75
+
69
76
  ## Repository configuration
70
77
 
78
+ ### Repeatable post-publication verification
79
+
80
+ From a source checkout, use the maintained artifact verifier after the normal
81
+ publication workflow succeeds:
82
+
83
+ ```sh
84
+ node scripts/verify-published-release.mjs \
85
+ --version X.Y.Z --commit RELEASE_COMMIT --tree RELEASE_TREE \
86
+ --ci-run SUCCESSFUL_CI_RUN --security-run SUCCESSFUL_SECURITY_RUN \
87
+ --publish-run SUCCESSFUL_PUBLISH_RUN \
88
+ --out /private/evidence/release-verification.json
89
+ ```
90
+
91
+ Provide the exact 40-character commit and tree and numeric run IDs. The output
92
+ parent must already exist. The tool refuses to replace an existing report and
93
+ writes one only after all checks succeed, with mode `0600` on POSIX. It supports
94
+ stable releases whose npm version and both `latest` image tags are the expected
95
+ release; it does not reinterpret an old release as the current channel.
96
+
97
+ It requires a GitHub CLI supporting `attestation verify` and its exact-source,
98
+ workflow, certificate-identity and hosted-runner policy flags, plus a current
99
+ npm supporting registry-signature and provenance verification. Use `--gh PATH`
100
+ or `--npm PATH` for explicit tool selection; stale trust data or unsupported
101
+ flags fail closed rather than skipping a check. No runtime dependency is added.
102
+
103
+ The tool checks successful maintained CI, security and publication workflows, immutable
104
+ tag/commit/tree binding, retained CI candidate checksums and exact release-asset
105
+ bytes, CycloneDX 1.6, registry bytes/integrity, npm signatures/attestations, both
106
+ anonymous OCI architectures, per-architecture bound SPDX documents and signed
107
+ exact-source publication provenance. It uses bounded network responses and
108
+ subprocess deadlines. Registry blob redirects are limited to the designated
109
+ GitHub container storage host, without forwarding registry authorization.
110
+
111
+ Verification downloads assets and installs the exact registry package into
112
+ private disposable staging with lifecycle scripts disabled; staging is removed
113
+ on success or failure. It does not publish, move tags, change configuration,
114
+ restart a server, enable capture or upgrade a machine. The report explicitly
115
+ states `deployment_verified: false`: separately run the maintained backup,
116
+ upgrade/rollback, routed/legacy-session and project-doctor procedures. Artifact
117
+ verification is not deployment evidence or a security certification.
118
+ Anonymous OCI checks read manifests, configuration and bound SBOM blobs, not
119
+ every image layer. The report records `full_image_layer_pull_verified: false`;
120
+ the separate anonymous full-pull release gate above still applies.
121
+
122
+ Run the offline positive and mutation controls without network access:
123
+
124
+ ```sh
125
+ node scripts/test-release-verification.mjs
126
+ ```
127
+
128
+ These controls inject failed workflows, different trees/tags, expired
129
+ candidates, tampered assets, unsafe checksum paths, registry mismatches,
130
+ signature/provenance failures, missing architectures and unbound SBOMs. They
131
+ test policy decisions using synthetic inputs, not real cryptographic signatures;
132
+ the live verifier delegates signature validation to npm and the GitHub CLI.
133
+
134
+ ### Publication permissions
135
+
71
136
  The repository must provide an `NPM_TOKEN` Actions secret authorized to publish
72
137
  `@cairnkeep/cli`. Keep that credential out of local files and rotate it according
73
138
  to the npm account's security policy. Workflow defaults grant only
@@ -164,6 +164,8 @@ node mcp-memory-server/scripts/smoke-outbound-security.mjs
164
164
  node scripts/test-runtime-file-security.mjs
165
165
  node scripts/test-omp-staged-security.mjs
166
166
  node scripts/test-container-health.mjs
167
+ node mcp-memory-server/scripts/smoke-note-mcp.mjs --fixture-security-only
168
+ node scripts/verify-phase19-runtime-evidence.mjs --self-test
167
169
  ```
168
170
 
169
171
  Build the server first when testing a source checkout. These regressions do not
@@ -171,6 +173,19 @@ assert that every historical CodeQL/Scorecard alert is fixed. Triage findings
171
173
  individually and use a fresh default-branch analysis to confirm alert status;
172
174
  never dismiss an uninvestigated finding merely to improve the score.
173
175
 
176
+ The developer-evidence follow-up also hardens the shared runtime note pre-image
177
+ hasher: it reads from an inspected descriptor under a 64 MiB cap and rejects
178
+ replacement or growth before accepting a hash. Missing targets remain valid
179
+ pre-images; linked or unsafe targets do not. Fixture creation uses exclusive
180
+ creation, retaining a file created by a competing writer. Regression controls
181
+ exercise both the actual transaction entry point and fixture creation.
182
+ Developer snapshots retain exact hashes and modes from the same bounded read;
183
+ the runtime-evidence verifier retains its 16 MiB cap and rejects both growth
184
+ and replacement. Corrupt SQLite fixtures must still remain failed and unchanged
185
+ after doctor. Remote-context test labels never interpolate provider values.
186
+ These are read-integrity controls, not atomic multi-file note publication or a
187
+ same-account sandbox. Re-run the full native-platform gates before shipping.
188
+
174
189
  The bounded-input control exercises real evaluation, OKF import/export, graph
175
190
  status, receipt and progressive-cache readers. Valid-file substitution and
176
191
  same-inode growth are injected at actual inspection/read boundaries; the test
package/docs/storage.md CHANGED
@@ -48,6 +48,11 @@ services used to process/search memory. They do not change where the SQLite
48
48
  databases are stored. Git-provider and routing configuration do not change
49
49
  storage either.
50
50
 
51
+ `memory_search` can optionally select [hybrid retrieval](memory-retrieval.md).
52
+ This changes read-time ranking only: aliases are request hints, explanations
53
+ are returned to the caller without a new log, and original memory text remains
54
+ authoritative. No new store, migration or automatic memory capture is introduced.
55
+
51
56
  Trajectory storage is deliberately separate from MCP memory. The harness hook
52
57
  writes `.agentfs/trajectory.db` on the client machine where Claude Code,
53
58
  OpenCode, or Pi is running; it is never redirected to the remote HTTP memory server.
@@ -1 +1 @@
1
- {"identifier":"cairn-offline-fake-v1","package_version":"2.22.4","schema_version":1,"task_set_digest":"13e95379f35839593047ec34521a65a8fa3ab92bd3993bca9dc8bf5c4a9ff36f"}
1
+ {"identifier":"cairn-offline-fake-v1","package_version":"2.23.0","schema_version":1,"task_set_digest":"13e95379f35839593047ec34521a65a8fa3ab92bd3993bca9dc8bf5c4a9ff36f"}
@@ -25,6 +25,7 @@ import { profileAllowsTool, resolveMcpToolProfile } from "./mcp-tool-profile.js"
25
25
  import { isWorkEvidenceEnabled } from "./work-evidence-schema.js";
26
26
  import { extractMemoryCandidates as extractMemoryCandidatesFromProvider } from "./memory-extraction.js";
27
27
  import { applyReviewedMemory, invalidateReviewedMemory } from "./reviewed-memory-store.js";
28
+ import { rankHybridMemory } from "./memory-retrieval.js";
28
29
  const MINIMUM_NODE_MAJOR = 22;
29
30
  const nodeMajor = Number.parseInt(process.versions.node, 10);
30
31
  if (!Number.isInteger(nodeMajor) || nodeMajor < MINIMUM_NODE_MAJOR) {
@@ -377,7 +378,14 @@ async function semanticSearch(scope, query, topK, minScore, config = getMemoryCo
377
378
  entries: filterEntries(await listEntries(candidate, "", { projectId, typed: options.typed }), options.filters ?? {}),
378
379
  })));
379
380
  const allEntries = perScopeEntries.flatMap((group) => group.entries);
381
+ const hybrid = (embeddingStatus, semanticScores) => ({
382
+ ...rankHybridMemory(allEntries, query, { topK, minScore, aliases: options.aliases, explain: options.explain, embeddingStatus, semanticScores }),
383
+ mode: "hybrid",
384
+ ...(embeddingConfig ? { model: embeddingConfig.model } : {}),
385
+ });
380
386
  if (allEntries.length === 0) {
387
+ if (options.strategy === "hybrid")
388
+ return hybrid(embeddingConfig ? "not_used" : "unconfigured");
381
389
  return { results: [], mode: embeddingConfig ? "semantic" : "substring" };
382
390
  }
383
391
  const substringFallback = () => {
@@ -390,6 +398,8 @@ async function semanticSearch(scope, query, topK, minScore, config = getMemoryCo
390
398
  };
391
399
  };
392
400
  if (!embeddingConfig) {
401
+ if (options.strategy === "hybrid")
402
+ return hybrid("unconfigured");
393
403
  return substringFallback();
394
404
  }
395
405
  try {
@@ -422,9 +432,23 @@ async function semanticSearch(scope, query, topK, minScore, config = getMemoryCo
422
432
  }
423
433
  }
424
434
  const [queryVector] = await embedTexts(embeddingConfig, [query]);
435
+ if (options.strategy === "hybrid") {
436
+ const validVector = (vector) => {
437
+ if (!vector?.length || vector.length > 65536 || vector.some(value => !Number.isFinite(value)))
438
+ return false;
439
+ const squaredNorm = vector.reduce((total, value) => total + value * value, 0);
440
+ return squaredNorm > 0 && Number.isFinite(squaredNorm);
441
+ };
442
+ if (!validVector(queryVector) || allEntries.some(entry => !validVector(vectors.get(entry)) || vectors.get(entry).length !== queryVector.length)) {
443
+ throw new Error("Embedding vectors are incomplete or invalid.");
444
+ }
445
+ }
425
446
  for (const cache of caches) {
426
447
  cache.save();
427
448
  }
449
+ if (options.strategy === "hybrid") {
450
+ return hybrid("available", new Map(allEntries.map(entry => [entry, Math.max(-1, Math.min(1, cosineSimilarity(queryVector, vectors.get(entry))))])));
451
+ }
428
452
  const exact = options.typed
429
453
  ? allEntries.filter((entry) => isExactTextHit(entry, query)).sort(compareMemoryEntries).map((entry) => ({ ...entry, score: 1 }))
430
454
  : [];
@@ -444,6 +468,8 @@ async function semanticSearch(scope, query, topK, minScore, config = getMemoryCo
444
468
  }
445
469
  catch {
446
470
  // Embedding endpoint failure — degrade gracefully to substring matching.
471
+ if (options.strategy === "hybrid")
472
+ return hybrid("failed");
447
473
  return substringFallback();
448
474
  }
449
475
  }
@@ -1112,6 +1138,9 @@ function buildMemoryServer(context, capabilitySnapshot, withCapability, toolProf
1112
1138
  query: z.string().min(1),
1113
1139
  top_k: z.number().int().min(1).max(50).optional(),
1114
1140
  min_score: z.number().min(0).max(1).optional(),
1141
+ strategy: z.enum(["legacy", "hybrid"]).optional().describe("Default legacy preserves current retrieval. Hybrid admits lexical/identifier matches and semantic-only matches above an explicit positive min_score."),
1142
+ query_aliases: z.array(z.string().trim().min(1).max(256)).max(8).optional().describe("Explicit alternative query phrases for hybrid lexical retrieval; never widens scopes."),
1143
+ explain: z.boolean().optional().describe("Include hybrid match reasons, provenance locators and possible conflicting live values."),
1115
1144
  ...(typedNodesEnabled ? {
1116
1145
  address_space: NODE_ADDRESS_SPACE_SCHEMA.optional(),
1117
1146
  node_types: z.array(nodeTypeSchema).min(1).optional(),
@@ -1124,8 +1153,17 @@ function buildMemoryServer(context, capabilitySnapshot, withCapability, toolProf
1124
1153
  },
1125
1154
  }, async (input) => {
1126
1155
  const { scope, query, top_k, min_score } = input;
1156
+ if (input.strategy !== "hybrid" && (input.query_aliases !== undefined || input.explain !== undefined)) {
1157
+ throw new Error("query_aliases and explain require strategy='hybrid'.");
1158
+ }
1159
+ if (input.strategy === "hybrid") {
1160
+ // Validate bounds before opening scopes or contacting an embedding service.
1161
+ rankHybridMemory([], query, { topK: top_k ?? 8, minScore: min_score ?? 0, aliases: input.query_aliases });
1162
+ }
1127
1163
  const addressSpace = "address_space" in input && input.address_space !== undefined ? NODE_ADDRESS_SPACE_SCHEMA.parse(input.address_space) : undefined;
1128
1164
  if (addressSpace && addressSpace !== "memory") {
1165
+ if (input.strategy === "hybrid")
1166
+ throw new Error("Hybrid retrieval currently supports the memory address space only.");
1129
1167
  const noteStore = await import("./note-store.js");
1130
1168
  const entries = noteStore.searchAddressedNotes({ ...noteTargetOptions(scope, addressSpace), query, ...memoryFiltersFromInput(input) }).slice(0, top_k ?? 8);
1131
1169
  const results = entries.map((entry) => ({ ...entry, score: 1 }));
@@ -1135,11 +1173,14 @@ function buildMemoryServer(context, capabilitySnapshot, withCapability, toolProf
1135
1173
  structuredContent: payload,
1136
1174
  };
1137
1175
  }
1138
- const { results, mode, model } = await semanticSearch(scope, query, top_k ?? 8, min_score ?? 0, memoryConfig(), context.projectId, {
1176
+ const { results, mode, model, retrieval } = await semanticSearch(scope, query, top_k ?? 8, min_score ?? 0, memoryConfig(), context.projectId, {
1139
1177
  typed: typedNodesEnabled,
1140
1178
  filters: typedNodesEnabled ? memoryFiltersFromInput(input) : {},
1179
+ strategy: input.strategy,
1180
+ aliases: input.query_aliases,
1181
+ explain: input.explain,
1141
1182
  });
1142
- const payload = { mode, model, count: results.length, results };
1183
+ const payload = { mode, model, count: results.length, results, ...(retrieval ? { retrieval } : {}) };
1143
1184
  return {
1144
1185
  content: [{ type: "text", text: asToolText(payload) }],
1145
1186
  structuredContent: payload,
@@ -0,0 +1,128 @@
1
+ const STOP = new Set('a an and are as at be by can do does for from how i in is it me my of on or our please that the their this to use we what when where which with would you your un una uno il lo la gli le di da del della e che con come per posso mio mia sul nel dei'.split(' '));
2
+ const normalize = (value) => value.normalize('NFKC').toLowerCase();
3
+ const compare = (left, right) => left.scope.localeCompare(right.scope, 'en') || left.key.localeCompare(right.key, 'en');
4
+ const rounded = (value) => Math.round(value * 1e6) / 1e6;
5
+ export function retrievalTerms(text) {
6
+ const result = [];
7
+ for (const match of normalize(text).matchAll(/[\p{L}\p{N}]+/gu)) {
8
+ if (match[0].length > 1 && !STOP.has(match[0]))
9
+ result.push(match[0]);
10
+ if (result.length === 4096)
11
+ break;
12
+ }
13
+ return result;
14
+ }
15
+ function identifiers(text) {
16
+ // Match ordinary words too, then select identifiers. Requiring a separator
17
+ // in an unanchored greedy expression repeatedly rescans long plain words.
18
+ const result = new Set();
19
+ for (const match of text.matchAll(/[\p{L}\p{N}]+(?:[._:/-][\p{L}\p{N}]+)*/gu)) {
20
+ if (/[._:/\d-]/u.test(match[0]))
21
+ result.add(match[0]);
22
+ }
23
+ return result;
24
+ }
25
+ function variant(text) {
26
+ const normalized = normalize(text).trim();
27
+ return { normalized, terms: [...new Set(retrievalTerms(text))].slice(0, 64), identifiers: identifiers(normalized) };
28
+ }
29
+ /** BM25 is also used by the transparent Markdown-folder baseline. */
30
+ export function lexicalMemoryScores(entries, query) {
31
+ const queryTerms = [...new Set(retrievalTerms(query))].slice(0, 64);
32
+ const docs = entries.map(entry => [
33
+ ...retrievalTerms(entry.key), ...retrievalTerms(entry.key),
34
+ ...retrievalTerms(entry.value), ...retrievalTerms([entry.node_type, ...(entry.tags ?? [])].filter(Boolean).join(' ')),
35
+ ]);
36
+ const frequencies = docs.map(terms => {
37
+ const counts = new Map();
38
+ for (const term of terms)
39
+ counts.set(term, (counts.get(term) ?? 0) + 1);
40
+ return counts;
41
+ });
42
+ const averageLength = docs.reduce((total, terms) => total + terms.length, 0) / Math.max(1, docs.length) || 1;
43
+ const documentFrequencies = new Map(queryTerms.map(term => [term, frequencies.filter(counts => counts.has(term)).length]));
44
+ return docs.map((terms, index) => queryTerms.reduce((score, term) => {
45
+ const frequency = frequencies[index].get(term) ?? 0;
46
+ const df = documentFrequencies.get(term) ?? 0;
47
+ const idf = Math.log(1 + (entries.length - df + 0.5) / (df + 0.5));
48
+ return score + idf * frequency * 2.2 / (frequency + 1.2 * (0.25 + 0.75 * terms.length / averageLength));
49
+ }, 0));
50
+ }
51
+ export function rankHybridMemory(entries, query, options) {
52
+ const aliases = options.aliases ?? [];
53
+ if (Buffer.byteLength(query, 'utf8') > 4096 || !query.trim() || aliases.length > 8
54
+ || aliases.some(alias => !alias.trim() || Buffer.byteLength(alias, 'utf8') > 256)
55
+ || !Number.isInteger(options.topK) || options.topK < 1 || options.topK > 50
56
+ || !Number.isFinite(options.minScore) || options.minScore < 0 || options.minScore > 1) {
57
+ throw new Error('Hybrid memory search requires bounded query, aliases, result count and similarity threshold.');
58
+ }
59
+ const variants = [...new Set([query, ...aliases].map(text => normalize(text).trim()))].map(variant);
60
+ const lexicalScores = variants.map(item => lexicalMemoryScores(entries, item.normalized));
61
+ const candidates = entries.map((entry, index) => {
62
+ const text = normalize([entry.key, entry.value, entry.node_type, ...(entry.tags ?? [])].filter(Boolean).join('\n'));
63
+ const words = new Set(retrievalTerms(text));
64
+ const entryIdentifiers = identifiers(text);
65
+ const matches = variants.map((item, v) => {
66
+ const matched = item.terms.filter(term => words.has(term));
67
+ const coverage = item.terms.length ? matched.length / item.terms.length : 0;
68
+ const identifierMatch = [...item.identifiers].every(id => entryIdentifiers.has(id));
69
+ const exactKey = normalize(entry.key) === item.normalized;
70
+ return {
71
+ matched, coverage, identifierMatch, exactKey,
72
+ eligible: identifierMatch && (exactKey || (coverage >= 0.6 && matched.length >= Math.min(2, item.terms.length) && matched.length > 0)),
73
+ lexical: lexicalScores[v][index],
74
+ };
75
+ });
76
+ const best = matches.filter(match => match.eligible).sort((a, b) => Number(b.exactKey) - Number(a.exactKey) || b.lexical - a.lexical)[0];
77
+ const rawSemantic = options.semanticScores?.get(entry);
78
+ const semantic = typeof rawSemantic === 'number' && Number.isFinite(rawSemantic) && rawSemantic >= -1 && rawSemantic <= 1 ? rawSemantic : null;
79
+ const semanticAdmission = options.minScore > 0 && semantic !== null && semantic >= options.minScore && matches.some(match => match.identifierMatch);
80
+ const lexical = best?.lexical ?? 0;
81
+ const score = 0.8 * lexical / (1 + lexical) + 0.2 * Math.max(0, semantic ?? 0);
82
+ return { entry, best, semantic, semanticAdmission, score };
83
+ }).filter(item => item.best || item.semanticAdmission)
84
+ .sort((a, b) => Number(Boolean(b.best?.exactKey)) - Number(Boolean(a.best?.exactKey)) || b.score - a.score || compare(a.entry, b.entry));
85
+ const grouped = new Map();
86
+ for (const candidate of candidates) {
87
+ const key = JSON.stringify([candidate.entry.key, candidate.entry.value]);
88
+ const group = grouped.get(key) ?? [];
89
+ group.push(candidate);
90
+ grouped.set(key, group);
91
+ }
92
+ const results = [...grouped.values()].slice(0, options.topK).map(group => {
93
+ const item = group[0];
94
+ const reasons = [
95
+ ...(item.best?.exactKey ? ['exact-key'] : []),
96
+ ...(item.best ? ['lexical-coverage'] : []),
97
+ ...(item.semanticAdmission ? ['semantic-threshold'] : []),
98
+ ...(group.length > 1 ? ['identical-records-collapsed'] : []),
99
+ ];
100
+ return {
101
+ ...item.entry, score: rounded(item.score),
102
+ ...(options.explain ? { match: {
103
+ reasons, matched_terms: item.best?.matched ?? [], lexical_coverage: rounded(item.best?.coverage ?? 0),
104
+ semantic_score: item.semantic === null ? null : rounded(item.semantic),
105
+ sources: group.map(({ entry }) => ({ scope: entry.scope, key: entry.key })).sort(compare),
106
+ } } : {}),
107
+ };
108
+ });
109
+ const conflicts = [...new Set(results.map(item => item.key))].flatMap(key => {
110
+ const relevant = candidates.filter(item => item.entry.key === key);
111
+ return new Set(relevant.map(item => item.entry.value)).size > 1
112
+ ? [{ key, scopes: [...new Set(relevant.map(item => item.entry.scope))].sort() }] : [];
113
+ });
114
+ return {
115
+ results,
116
+ retrieval: {
117
+ strategy: 'hybrid',
118
+ score_kind: 'ranking-not-confidence',
119
+ embedding_status: options.embeddingStatus ?? (options.semanticScores ? 'available' : 'unconfigured'),
120
+ semantic_min_score: options.minScore > 0 ? options.minScore : null,
121
+ alias_count: aliases.length,
122
+ candidates: entries.length,
123
+ admitted: grouped.size,
124
+ ...(results.length === 0 ? { empty_reason: entries.length ? 'no_relevant_memory' : 'empty_scope' } : {}),
125
+ ...(options.explain ? { possible_conflicts: conflicts } : {}),
126
+ },
127
+ };
128
+ }
@@ -6,6 +6,7 @@ import { buildFailureSignature } from "./failure-signature.js";
6
6
  import { NOTE_SCHEMA_VERSION, noteNodeSchema, } from "./note-schema.js";
7
7
  import { memoryImportEnvelopeSchema, nodePathSchema, } from "./node-schema.js";
8
8
  import { atomicReplace } from "./platform-security.js";
9
+ import { readStableFile } from "./stable-file.js";
9
10
  const MANAGED_START = "<!-- cairnkeep:managed:v1:start -->";
10
11
  const MANAGED_END = "<!-- cairnkeep:managed:v1:end -->";
11
12
  const MAX_PROCESSED_SESSIONS_PER_PROJECT = 4096;
@@ -569,7 +570,22 @@ export function doctorNoteStore(repair = false, options = {}) {
569
570
  }
570
571
  }
571
572
  function fileHash(path) {
572
- return existsSync(path) ? createHash("sha256").update(readFileSync(path)).digest("hex") : null;
573
+ try {
574
+ const { bytes } = readStableFile(path, { label: "Note transaction pre-image", maxBytes: 64 * 1024 * 1024 });
575
+ return createHash("sha256").update(bytes).digest("hex");
576
+ }
577
+ catch (error) {
578
+ // Missing targets are valid pre-images. Diagnose absence only after
579
+ // the descriptor-first read failed; never authorize a read by a check.
580
+ try {
581
+ lstatSync(path);
582
+ }
583
+ catch (missing) {
584
+ if (missing.code === "ENOENT")
585
+ return null;
586
+ }
587
+ throw error;
588
+ }
573
589
  }
574
590
  function bytesHash(bytes) {
575
591
  return bytes === null ? null : createHash("sha256").update(bytes).digest("hex");
@@ -1024,9 +1040,13 @@ export async function commitNoteImport(plan) {
1024
1040
  export function createNoteMutationFixture(options) {
1025
1041
  const notesRoot = join(options.storeRoot, ".cairnkeep-note-fixture");
1026
1042
  const target = join(notesRoot, "live.txt");
1027
- if (!existsSync(target)) {
1028
- mkdirSync(dirname(target), { recursive: true });
1029
- writeFileSync(target, "before\n");
1043
+ mkdirSync(dirname(target), { recursive: true });
1044
+ try {
1045
+ writeFileSync(target, "before\n", { flag: "wx", mode: 0o600 });
1046
+ }
1047
+ catch (error) {
1048
+ if (error.code !== "EEXIST")
1049
+ throw error;
1030
1050
  }
1031
1051
  const bytes = options.operation === "delete" ? null : `${options.operation}-after\n`;
1032
1052
  return { schema_version: 1, operation: options.operation, notes_root: notesRoot, changes: [{ path: target, bytes, before_hash: fileHash(target), final_hash: bytesHash(bytes) }], result: { ok: true, fixture: true } };
@@ -15,6 +15,8 @@
15
15
  "check:embeddings": "node scripts/smoke-embeddings.mjs",
16
16
  "check:extract": "node scripts/smoke-extract-cli.mjs",
17
17
  "check:search": "node scripts/smoke-search-e2e.mjs",
18
+ "check:memory-retrieval": "node scripts/smoke-memory-retrieval.mjs && node scripts/smoke-memory-retrieval-mcp.mjs && node scripts/run-memory-retrieval-study.mjs --check --summary",
19
+ "study:memory-retrieval": "npm run build && node scripts/run-memory-retrieval-study.mjs --summary",
18
20
  "check:scope-guard": "node scripts/smoke-scope-guard.mjs",
19
21
  "check:http-guard": "node scripts/smoke-http-guard.mjs",
20
22
  "check:remote-context": "node scripts/smoke-remote-context.mjs",
@@ -25,7 +25,14 @@ Read order (canonical sources still win over derived layers):
25
25
 
26
26
  <process>
27
27
  ## 1. AgentFS
28
- `cairn-memory_memory_search` scope `project` with $ARGUMENTS. If nothing relevant, retry scope `all`.
28
+ `cairn-memory_memory_search` scope `project` with $ARGUMENTS. When the discovered
29
+ schema supports it, use `strategy: "hybrid"` for identifier-sensitive or
30
+ mixed-wording queries and `explain: true` to inspect matches. Supply only aliases
31
+ already known from the task. Semantic-only hybrid matches require an explicitly
32
+ chosen positive `min_score`; retain the existing semantic strategy for paraphrases
33
+ when no model-specific threshold is available. Empty results are valid.
34
+ Use broader configured scopes only when the question genuinely spans them;
35
+ do not automatically widen a search merely to force a hit.
29
36
 
30
37
  ## 2. Wiki
31
38
  Read `.planning/wiki/index.md` if present and follow links relevant to $ARGUMENTS.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cairnkeep/cli",
3
- "version": "2.22.4",
3
+ "version": "2.23.0",
4
4
  "description": "A durable, harness-agnostic memory + context layer for coding agents and MCP clients.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",