@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.
- package/CHANGELOG.md +43 -0
- package/claude/commands/recall.md +8 -1
- package/docs/README.md +3 -0
- package/docs/agents.md +9 -0
- package/docs/benchmarks/memory-retrieval-v1.json +115 -0
- package/docs/domain-knowledge.md +42 -4
- package/docs/learning/CURRICULUM-MAP.md +3 -3
- package/docs/learning/FEATURE-GUIDE.md +7 -1
- package/docs/learning/lessons/L00-why-cairnkeep.md +1 -1
- package/docs/learning/lessons/L01-safe-trial.md +3 -3
- package/docs/learning/lessons/L02-installation.md +2 -2
- package/docs/learning/lessons/L03-first-project.md +1 -1
- package/docs/learning/lessons/L04-memory-fundamentals.md +20 -0
- package/docs/learning/lessons/L09-domain-knowledge.md +11 -0
- package/docs/learning/lessons/L19-validated-skill-improvement.md +1 -1
- package/docs/learning/lessons/L20-mcp-tool-profiles.md +1 -1
- package/docs/learning/lessons/L21-context-packs.md +1 -1
- package/docs/learning/lessons/L22-native-windows.md +2 -2
- package/docs/learning/lessons/L23-guided-setup.md +1 -1
- package/docs/learning/lessons/L24-okf-exchange.md +1 -1
- package/docs/learning/lessons/L25-playbooks.md +1 -1
- package/docs/learning/lessons/L26-context-intelligence.md +1 -1
- package/docs/learning/lessons/L27-ecosystem-evidence.md +1 -1
- package/docs/learning/lessons/L28-security-assurance.md +2 -2
- package/docs/learning/video-scripts/V04-memory-retrieval-supplement.md +34 -0
- package/docs/learning/video-scripts/V09-workspace-routing-supplement.md +36 -0
- package/docs/memory-retrieval.md +174 -0
- package/docs/operating.md +5 -0
- package/docs/releasing.md +7 -0
- package/docs/storage.md +5 -0
- package/examples/eval/bundled-fake.json +1 -1
- package/mcp-memory-server/dist/anythingllm-workspace.js +109 -0
- package/mcp-memory-server/dist/index.js +54 -6
- package/mcp-memory-server/dist/memory-retrieval.js +128 -0
- package/mcp-memory-server/package.json +5 -2
- package/opencode/command/recall.md +8 -1
- package/package.json +2 -2
- package/scripts/test-dependency-security.mjs +40 -0
- package/scripts/test-dependency-security.sh +4 -0
- package/scripts/test-memory-retrieval-docs.sh +19 -0
- package/scripts/test-release-sbom.sh +32 -0
- package/scripts/verify-dependency-security.mjs +19 -0
- 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.
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|