@cairnkeep/cli 2.22.5 → 2.23.1

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 (43) hide show
  1. package/CHANGELOG.md +43 -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/domain-knowledge.md +42 -4
  7. package/docs/learning/CURRICULUM-MAP.md +3 -3
  8. package/docs/learning/FEATURE-GUIDE.md +7 -1
  9. package/docs/learning/lessons/L00-why-cairnkeep.md +1 -1
  10. package/docs/learning/lessons/L01-safe-trial.md +3 -3
  11. package/docs/learning/lessons/L02-installation.md +2 -2
  12. package/docs/learning/lessons/L03-first-project.md +1 -1
  13. package/docs/learning/lessons/L04-memory-fundamentals.md +20 -0
  14. package/docs/learning/lessons/L09-domain-knowledge.md +11 -0
  15. package/docs/learning/lessons/L19-validated-skill-improvement.md +1 -1
  16. package/docs/learning/lessons/L20-mcp-tool-profiles.md +1 -1
  17. package/docs/learning/lessons/L21-context-packs.md +1 -1
  18. package/docs/learning/lessons/L22-native-windows.md +2 -2
  19. package/docs/learning/lessons/L23-guided-setup.md +1 -1
  20. package/docs/learning/lessons/L24-okf-exchange.md +1 -1
  21. package/docs/learning/lessons/L25-playbooks.md +1 -1
  22. package/docs/learning/lessons/L26-context-intelligence.md +1 -1
  23. package/docs/learning/lessons/L27-ecosystem-evidence.md +1 -1
  24. package/docs/learning/lessons/L28-security-assurance.md +2 -2
  25. package/docs/learning/video-scripts/V04-memory-retrieval-supplement.md +34 -0
  26. package/docs/learning/video-scripts/V09-workspace-routing-supplement.md +36 -0
  27. package/docs/memory-retrieval.md +174 -0
  28. package/docs/operating.md +5 -0
  29. package/docs/releasing.md +7 -0
  30. package/docs/storage.md +5 -0
  31. package/examples/eval/bundled-fake.json +1 -1
  32. package/mcp-memory-server/dist/anythingllm-workspace.js +109 -0
  33. package/mcp-memory-server/dist/index.js +54 -6
  34. package/mcp-memory-server/dist/memory-retrieval.js +128 -0
  35. package/mcp-memory-server/package.json +5 -2
  36. package/opencode/command/recall.md +8 -1
  37. package/package.json +2 -2
  38. package/scripts/test-dependency-security.mjs +40 -0
  39. package/scripts/test-dependency-security.sh +4 -0
  40. package/scripts/test-memory-retrieval-docs.sh +19 -0
  41. package/scripts/test-release-sbom.sh +32 -0
  42. package/scripts/verify-dependency-security.mjs +19 -0
  43. 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,6 +66,13 @@ 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
 
71
78
  ### Repeatable post-publication verification
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.5","schema_version":1,"task_set_digest":"13e95379f35839593047ec34521a65a8fa3ab92bd3993bca9dc8bf5c4a9ff36f"}
1
+ {"identifier":"cairn-offline-fake-v1","package_version":"2.23.1","schema_version":1,"task_set_digest":"13e95379f35839593047ec34521a65a8fa3ab92bd3993bca9dc8bf5c4a9ff36f"}
@@ -0,0 +1,109 @@
1
+ import { existsSync, realpathSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { isAbsolute, join, relative, resolve } from "node:path";
4
+ import { readStableJson } from "./stable-file.js";
5
+ /** Shared workspace that is never a project default; it holds cross-project patterns. */
6
+ const SHARED_WORKSPACE = "engineering-patterns";
7
+ const WORKSPACE_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
8
+ const MAX_MAPPING_BYTES = 1024 * 1024;
9
+ const MAX_PROJECTS = 4096;
10
+ function expandHome(value) {
11
+ if (value === "~")
12
+ return homedir();
13
+ if (value.startsWith("~/"))
14
+ return join(homedir(), value.slice(2));
15
+ return value;
16
+ }
17
+ /**
18
+ * Locate `anythingllm-projects.json`.
19
+ *
20
+ * Mirrors the precedence in `examples/anythingllm/sync_to_anythingllm.py` so the
21
+ * resolver and the sync script can never disagree about which mapping is live.
22
+ */
23
+ export function anythingllmProjectsFile(env = process.env, legacyDir) {
24
+ const override = env.CAIRN_ANYTHINGLLM_PROJECTS_FILE;
25
+ if (override) {
26
+ const explicit = resolve(expandHome(override));
27
+ return existsSync(explicit) ? explicit : undefined;
28
+ }
29
+ if (legacyDir) {
30
+ const legacy = join(legacyDir, "anythingllm-projects.json");
31
+ if (existsSync(legacy))
32
+ return legacy;
33
+ }
34
+ const configHome = env.XDG_CONFIG_HOME
35
+ ? resolve(expandHome(env.XDG_CONFIG_HOME))
36
+ : join(homedir(), ".config");
37
+ const standard = join(configHome, "cairnkeep", "anythingllm-projects.json");
38
+ if (existsSync(standard))
39
+ return standard;
40
+ return undefined;
41
+ }
42
+ /**
43
+ * Workspace slug declared for the project containing `cwd`, if any.
44
+ *
45
+ * The deepest matching `path` wins so a checkout nested inside another project
46
+ * resolves to its own workspace rather than its parent's.
47
+ */
48
+ export function workspaceForDirectory(cwd, env = process.env, legacyDir) {
49
+ const file = anythingllmProjectsFile(env, legacyDir);
50
+ if (!file)
51
+ return undefined;
52
+ let entries;
53
+ try {
54
+ const parsed = readStableJson(file, { label: "Workspace mapping", maxBytes: MAX_MAPPING_BYTES });
55
+ if (!Array.isArray(parsed?.projects) || parsed.projects.length > MAX_PROJECTS)
56
+ return undefined;
57
+ entries = parsed.projects;
58
+ }
59
+ catch {
60
+ // A malformed or unreadable mapping must not break workspace resolution;
61
+ // fall back to the configured list rather than failing the tool call.
62
+ return undefined;
63
+ }
64
+ let here;
65
+ try {
66
+ here = realpathSync.native(cwd);
67
+ }
68
+ catch {
69
+ return undefined;
70
+ }
71
+ let bestSlug;
72
+ let bestDepth = -1;
73
+ let ambiguous = false;
74
+ for (const entry of entries) {
75
+ const slug = typeof entry?.slug === "string" ? entry.slug : "";
76
+ const path = typeof entry?.path === "string" ? entry.path : "";
77
+ if (!WORKSPACE_PATTERN.test(slug) || slug === SHARED_WORKSPACE || !isAbsolute(path))
78
+ continue;
79
+ let root;
80
+ try {
81
+ root = realpathSync.native(path);
82
+ }
83
+ catch {
84
+ continue;
85
+ }
86
+ const rel = relative(root, here);
87
+ const contains = rel === "" || (rel !== ".." && !rel.startsWith("../") && !rel.startsWith("..\\") && !isAbsolute(rel));
88
+ if (contains && root.length > bestDepth) {
89
+ bestSlug = slug;
90
+ bestDepth = root.length;
91
+ ambiguous = false;
92
+ }
93
+ else if (contains && root.length === bestDepth && slug !== bestSlug) {
94
+ ambiguous = true;
95
+ }
96
+ }
97
+ if (ambiguous)
98
+ throw new Error("Ambiguous project workspace mapping; specify workspace explicitly.");
99
+ return bestSlug;
100
+ }
101
+ /**
102
+ * Default workspace for a tool call: the project containing `cwd` when it is
103
+ * declared, otherwise the previous behaviour so existing single-project setups
104
+ * keep working unchanged.
105
+ */
106
+ export function defaultAnythingLLMWorkspace(config, cwd = process.cwd(), env = process.env, legacyDir) {
107
+ return (workspaceForDirectory(cwd, env, legacyDir) ??
108
+ config.anythingllm_workspaces?.find((workspace) => workspace !== SHARED_WORKSPACE));
109
+ }
@@ -11,6 +11,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
11
11
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
12
12
  import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
13
13
  import { z } from "zod";
14
+ import { defaultAnythingLLMWorkspace as resolveDefaultAnythingLLMWorkspace } from "./anythingllm-workspace.js";
14
15
  import { artifactIdSchema, artifactKindSchema, artifactMediaTypeSchema, artifactNodeRefSchema, artifactProvenanceSchema, artifactSessionRefSchema, compactionSummaryInputContentSchema, diffContentSchema, generatedFileInputContentSchema, isArtifactHttpEnabled, isArtifactStoreEnabled, testOutputContentSchema, } from "./artifact-schema.js";
15
16
  import { isCapabilityContractEnabled, resolveCapabilityStatus, } from "./capability-config.js";
16
17
  import { EmbeddingCache, cosineSimilarity, embedTexts, getEmbeddingConfig, hashText, } from "./embeddings.js";
@@ -25,6 +26,7 @@ import { profileAllowsTool, resolveMcpToolProfile } from "./mcp-tool-profile.js"
25
26
  import { isWorkEvidenceEnabled } from "./work-evidence-schema.js";
26
27
  import { extractMemoryCandidates as extractMemoryCandidatesFromProvider } from "./memory-extraction.js";
27
28
  import { applyReviewedMemory, invalidateReviewedMemory } from "./reviewed-memory-store.js";
29
+ import { rankHybridMemory } from "./memory-retrieval.js";
28
30
  const MINIMUM_NODE_MAJOR = 22;
29
31
  const nodeMajor = Number.parseInt(process.versions.node, 10);
30
32
  if (!Number.isInteger(nodeMajor) || nodeMajor < MINIMUM_NODE_MAJOR) {
@@ -359,8 +361,14 @@ async function readStdin() {
359
361
  }
360
362
  return Buffer.concat(chunks).toString("utf8");
361
363
  }
362
- function defaultAnythingLLMWorkspace(config) {
363
- return config.anythingllm_workspaces?.find((workspace) => workspace !== "engineering-patterns");
364
+ function defaultAnythingLLMWorkspace(config, remote = false) {
365
+ // HTTP callers route by session metadata, never by the server's checkout.
366
+ if (remote || resolveDomainRetrievalProvider() !== "anythingllm") {
367
+ return config.anythingllm_workspaces?.find((workspace) => workspace !== "engineering-patterns");
368
+ }
369
+ // `anythingllm-projects.json` may also sit beside the bundled sync script,
370
+ // which is the legacy location the script still honours.
371
+ return resolveDefaultAnythingLLMWorkspace(config, process.cwd(), process.env, dirname(anythingllmSyncScript));
364
372
  }
365
373
  function entryText(entry) {
366
374
  const metadata = [entry.node_type, ...(entry.tags ?? [])].filter(Boolean).join("\n");
@@ -377,7 +385,14 @@ async function semanticSearch(scope, query, topK, minScore, config = getMemoryCo
377
385
  entries: filterEntries(await listEntries(candidate, "", { projectId, typed: options.typed }), options.filters ?? {}),
378
386
  })));
379
387
  const allEntries = perScopeEntries.flatMap((group) => group.entries);
388
+ const hybrid = (embeddingStatus, semanticScores) => ({
389
+ ...rankHybridMemory(allEntries, query, { topK, minScore, aliases: options.aliases, explain: options.explain, embeddingStatus, semanticScores }),
390
+ mode: "hybrid",
391
+ ...(embeddingConfig ? { model: embeddingConfig.model } : {}),
392
+ });
380
393
  if (allEntries.length === 0) {
394
+ if (options.strategy === "hybrid")
395
+ return hybrid(embeddingConfig ? "not_used" : "unconfigured");
381
396
  return { results: [], mode: embeddingConfig ? "semantic" : "substring" };
382
397
  }
383
398
  const substringFallback = () => {
@@ -390,6 +405,8 @@ async function semanticSearch(scope, query, topK, minScore, config = getMemoryCo
390
405
  };
391
406
  };
392
407
  if (!embeddingConfig) {
408
+ if (options.strategy === "hybrid")
409
+ return hybrid("unconfigured");
393
410
  return substringFallback();
394
411
  }
395
412
  try {
@@ -422,9 +439,23 @@ async function semanticSearch(scope, query, topK, minScore, config = getMemoryCo
422
439
  }
423
440
  }
424
441
  const [queryVector] = await embedTexts(embeddingConfig, [query]);
442
+ if (options.strategy === "hybrid") {
443
+ const validVector = (vector) => {
444
+ if (!vector?.length || vector.length > 65536 || vector.some(value => !Number.isFinite(value)))
445
+ return false;
446
+ const squaredNorm = vector.reduce((total, value) => total + value * value, 0);
447
+ return squaredNorm > 0 && Number.isFinite(squaredNorm);
448
+ };
449
+ if (!validVector(queryVector) || allEntries.some(entry => !validVector(vectors.get(entry)) || vectors.get(entry).length !== queryVector.length)) {
450
+ throw new Error("Embedding vectors are incomplete or invalid.");
451
+ }
452
+ }
425
453
  for (const cache of caches) {
426
454
  cache.save();
427
455
  }
456
+ if (options.strategy === "hybrid") {
457
+ return hybrid("available", new Map(allEntries.map(entry => [entry, Math.max(-1, Math.min(1, cosineSimilarity(queryVector, vectors.get(entry))))])));
458
+ }
428
459
  const exact = options.typed
429
460
  ? allEntries.filter((entry) => isExactTextHit(entry, query)).sort(compareMemoryEntries).map((entry) => ({ ...entry, score: 1 }))
430
461
  : [];
@@ -444,6 +475,8 @@ async function semanticSearch(scope, query, topK, minScore, config = getMemoryCo
444
475
  }
445
476
  catch {
446
477
  // Embedding endpoint failure — degrade gracefully to substring matching.
478
+ if (options.strategy === "hybrid")
479
+ return hybrid("failed");
447
480
  return substringFallback();
448
481
  }
449
482
  }
@@ -1112,6 +1145,9 @@ function buildMemoryServer(context, capabilitySnapshot, withCapability, toolProf
1112
1145
  query: z.string().min(1),
1113
1146
  top_k: z.number().int().min(1).max(50).optional(),
1114
1147
  min_score: z.number().min(0).max(1).optional(),
1148
+ 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."),
1149
+ 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."),
1150
+ explain: z.boolean().optional().describe("Include hybrid match reasons, provenance locators and possible conflicting live values."),
1115
1151
  ...(typedNodesEnabled ? {
1116
1152
  address_space: NODE_ADDRESS_SPACE_SCHEMA.optional(),
1117
1153
  node_types: z.array(nodeTypeSchema).min(1).optional(),
@@ -1124,8 +1160,17 @@ function buildMemoryServer(context, capabilitySnapshot, withCapability, toolProf
1124
1160
  },
1125
1161
  }, async (input) => {
1126
1162
  const { scope, query, top_k, min_score } = input;
1163
+ if (input.strategy !== "hybrid" && (input.query_aliases !== undefined || input.explain !== undefined)) {
1164
+ throw new Error("query_aliases and explain require strategy='hybrid'.");
1165
+ }
1166
+ if (input.strategy === "hybrid") {
1167
+ // Validate bounds before opening scopes or contacting an embedding service.
1168
+ rankHybridMemory([], query, { topK: top_k ?? 8, minScore: min_score ?? 0, aliases: input.query_aliases });
1169
+ }
1127
1170
  const addressSpace = "address_space" in input && input.address_space !== undefined ? NODE_ADDRESS_SPACE_SCHEMA.parse(input.address_space) : undefined;
1128
1171
  if (addressSpace && addressSpace !== "memory") {
1172
+ if (input.strategy === "hybrid")
1173
+ throw new Error("Hybrid retrieval currently supports the memory address space only.");
1129
1174
  const noteStore = await import("./note-store.js");
1130
1175
  const entries = noteStore.searchAddressedNotes({ ...noteTargetOptions(scope, addressSpace), query, ...memoryFiltersFromInput(input) }).slice(0, top_k ?? 8);
1131
1176
  const results = entries.map((entry) => ({ ...entry, score: 1 }));
@@ -1135,11 +1180,14 @@ function buildMemoryServer(context, capabilitySnapshot, withCapability, toolProf
1135
1180
  structuredContent: payload,
1136
1181
  };
1137
1182
  }
1138
- const { results, mode, model } = await semanticSearch(scope, query, top_k ?? 8, min_score ?? 0, memoryConfig(), context.projectId, {
1183
+ const { results, mode, model, retrieval } = await semanticSearch(scope, query, top_k ?? 8, min_score ?? 0, memoryConfig(), context.projectId, {
1139
1184
  typed: typedNodesEnabled,
1140
1185
  filters: typedNodesEnabled ? memoryFiltersFromInput(input) : {},
1186
+ strategy: input.strategy,
1187
+ aliases: input.query_aliases,
1188
+ explain: input.explain,
1141
1189
  });
1142
- const payload = { mode, model, count: results.length, results };
1190
+ const payload = { mode, model, count: results.length, results, ...(retrieval ? { retrieval } : {}) };
1143
1191
  return {
1144
1192
  content: [{ type: "text", text: asToolText(payload) }],
1145
1193
  structuredContent: payload,
@@ -1411,7 +1459,7 @@ function buildMemoryServer(context, capabilitySnapshot, withCapability, toolProf
1411
1459
  readOnlyHint: true,
1412
1460
  },
1413
1461
  }, async ({ workspace, query }) => {
1414
- const workspaceSlug = workspace ?? defaultAnythingLLMWorkspace(memoryConfig());
1462
+ const workspaceSlug = workspace ?? defaultAnythingLLMWorkspace(memoryConfig(), context.remote);
1415
1463
  if (!workspaceSlug) {
1416
1464
  throw new Error("No AnythingLLM workspace provided and no project workspace found in memory config.");
1417
1465
  }
@@ -1439,7 +1487,7 @@ function buildMemoryServer(context, capabilitySnapshot, withCapability, toolProf
1439
1487
  }
1440
1488
  const syncMode = mode ?? "incremental";
1441
1489
  const config = memoryConfig();
1442
- const workspaceSlug = workspace ?? defaultAnythingLLMWorkspace(config);
1490
+ const workspaceSlug = workspace ?? defaultAnythingLLMWorkspace(config, context.remote);
1443
1491
  if (!workspaceSlug) {
1444
1492
  throw new Error("No AnythingLLM workspace provided and no project workspace found in memory config.");
1445
1493
  }
@@ -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
+ }
@@ -15,9 +15,11 @@
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
- "check:remote-context": "node scripts/smoke-remote-context.mjs",
22
+ "check:remote-context": "node scripts/smoke-remote-context.mjs && node scripts/smoke-remote-context-ports.mjs",
21
23
  "check:explore-guard": "node scripts/smoke-explore-guard.mjs",
22
24
  "check:explore-cache": "node scripts/smoke-explore-cache.mjs",
23
25
  "check:explore-crossref": "node scripts/smoke-explore-crossref.mjs",
@@ -56,6 +58,7 @@
56
58
  "check:context-pack-v216-compat": "node scripts/smoke-context-pack-v216-compat.mjs",
57
59
  "check:context-usage": "node scripts/smoke-context-usage.mjs",
58
60
  "check:domain-retrieval": "node scripts/smoke-domain-retrieval.mjs",
61
+ "check:anythingllm-workspace": "node scripts/smoke-anythingllm-workspace.mjs && node scripts/smoke-anythingllm-workspace-mcp.mjs",
59
62
  "check:retrieval-benchmark": "node scripts/smoke-retrieval-benchmark.mjs && node scripts/run-retrieval-benchmark.mjs --check",
60
63
  "benchmark:retrieval": "npm run build && node scripts/run-retrieval-benchmark.mjs",
61
64
  "check:work-evidence": "node scripts/smoke-work-evidence.mjs",
@@ -75,7 +78,7 @@
75
78
  "@modelcontextprotocol/sdk": "^1.32.1",
76
79
  "agentfs-sdk": "^0.6.4",
77
80
  "yaml": "2.9.1",
78
- "zod": "^4.6.5"
81
+ "zod": "^4.6.7"
79
82
  },
80
83
  "devDependencies": {
81
84
  "@types/node": "^22.20.5",
@@ -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.