@cairnkeep/cli 2.23.1 → 2.24.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.
- package/CHANGELOG.md +27 -0
- package/docs/containers.md +4 -0
- package/docs/learning/CURRICULUM-MAP.md +1 -1
- package/docs/learning/FEATURE-GUIDE.md +8 -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 +12 -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 +13 -0
- package/docs/mcp-tool-profiles.md +7 -0
- package/docs/memory-retrieval.md +84 -2
- package/docs/operating.md +5 -0
- package/docs/privacy-and-data-flow.md +13 -4
- package/examples/eval/bundled-fake.json +1 -1
- package/mcp-memory-server/dist/index.js +30 -2
- package/mcp-memory-server/dist/mcp-tool-catalog.js +4 -1
- package/mcp-memory-server/dist/memory-reranker.js +109 -0
- package/mcp-memory-server/package.json +1 -0
- package/package.json +1 -1
- package/scripts/doctor.sh +17 -0
- package/scripts/probe-memory-reranker.mjs +22 -0
- package/scripts/test-container-health.mjs +2 -0
- package/scripts/test-container.sh +21 -1
- package/scripts/test-dependency-security.mjs +11 -1
- package/scripts/test-doctor.sh +28 -0
- package/scripts/test-windows-native.mjs +5 -0
- package/scripts/verify-dependency-security.mjs +3 -2
- package/scripts/windows-platform.mjs +8 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,33 @@ All notable user-facing changes are documented here. This project follows
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [2.24.0] - 2026-10-24
|
|
9
|
+
|
|
10
|
+
### Memory retrieval
|
|
11
|
+
|
|
12
|
+
- Add optional provider-neutral learned reranking for ordinary memory search,
|
|
13
|
+
with explicit endpoint/model consent and a separate optional credential.
|
|
14
|
+
Preserve existing scope, current-value, typed and relevance admission;
|
|
15
|
+
bound candidate text, streamed responses and deadlines. Provider failures
|
|
16
|
+
preserve the original ranking. Exact keys, including explicit aliases,
|
|
17
|
+
retain priority. Default search behavior stays unchanged.
|
|
18
|
+
- Add functional POSIX/native-Windows diagnostics, real MCP isolation/fallback
|
|
19
|
+
controls and a frozen synthetic provider comparison. Document both improved
|
|
20
|
+
first-result ordering and the regression; do not claim better general recall
|
|
21
|
+
or agent productivity. Update retrieval/privacy guidance and L04/V04 material.
|
|
22
|
+
- Preserve frozen fixture bytes on Windows and verify linked-install probes.
|
|
23
|
+
No new runtime dependency, storage migration, automatic provider discovery
|
|
24
|
+
or team-feature activation is included.
|
|
25
|
+
|
|
26
|
+
### Security
|
|
27
|
+
|
|
28
|
+
- Refresh compatible transitive HTTP parser, URI and IP-address dependencies
|
|
29
|
+
in both lock graphs. Enforce the patched minimum versions, including nested
|
|
30
|
+
copies, with dependency-policy regression controls.
|
|
31
|
+
- Normalize the container health-check directory's traversal permissions after
|
|
32
|
+
copying its probe. Verify the legacy entrypoint imports as the runtime UID;
|
|
33
|
+
do not count module-load failures as successful credential rejection.
|
|
34
|
+
|
|
8
35
|
## [2.23.1] - 2026-10-22
|
|
9
36
|
|
|
10
37
|
### Domain knowledge
|
package/docs/containers.md
CHANGED
|
@@ -86,6 +86,10 @@ configuration fails without issuing a request; probes target only
|
|
|
86
86
|
`127.0.0.1` and refuse redirects. The existing
|
|
87
87
|
`/usr/local/lib/cairnkeep/container-healthcheck.mjs` command remains available
|
|
88
88
|
as an alias to the packaged probe. Stdio mode requires no HTTP probe.
|
|
89
|
+
The image explicitly makes the probe directory searchable by its unprivileged
|
|
90
|
+
runtime user after copying the file. The OCI regression imports that legacy
|
|
91
|
+
entrypoint as UID 10001 before testing credentials, so a missing or inaccessible
|
|
92
|
+
module cannot be mistaken for a successful authentication rejection.
|
|
89
93
|
File-based probes require a private token readable by the container user;
|
|
90
94
|
world-readable secret mounts fail closed. The Quadlet example explicitly mounts
|
|
91
95
|
its secret as UID/GID 10001 with mode `0400`. For other engines, verify the
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Cairnkeep feature guide
|
|
2
2
|
|
|
3
|
-
**Baseline:** Cairnkeep 2.
|
|
3
|
+
**Baseline:** Cairnkeep 2.24.0
|
|
4
4
|
|
|
5
5
|
This is the step-back map for choosing and operating Cairnkeep features. The
|
|
6
6
|
[course labs](https://github.com/cairnkeep/cairnkeep-course-labs) provide one
|
|
@@ -42,6 +42,13 @@ match/source/conflict explanations without changing legacy defaults. See
|
|
|
42
42
|
and its recording supplement. The synthetic Markdown comparison measures
|
|
43
43
|
retrieval policy, not general task quality or a storage-format advantage.
|
|
44
44
|
|
|
45
|
+
Version 2.24 adds an optional, separately configured ranking
|
|
46
|
+
service, not a chat agent or memory authority. It remains default-off. See
|
|
47
|
+
[its bounded provider comparison and regression](../memory-retrieval.md#optional-learned-reranking)
|
|
48
|
+
before enabling it; L04 and its recording supplement cover the privacy,
|
|
49
|
+
doctor and fallback controls. The default-off team preview is separate and is
|
|
50
|
+
not included in this release.
|
|
51
|
+
|
|
45
52
|
## The three-layer model
|
|
46
53
|
|
|
47
54
|
1. **Memory server:** an MCP process that owns scoped durable storage. The
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
**Status:** Ready
|
|
4
4
|
**Track:** Quickstart
|
|
5
5
|
**Time:** 15 minutes
|
|
6
|
-
**Tested with:** Cairnkeep 2.
|
|
6
|
+
**Tested with:** Cairnkeep 2.24.0
|
|
7
7
|
|
|
8
8
|
## Outcome
|
|
9
9
|
|
|
@@ -26,7 +26,7 @@ ephemeral; the named course volume is persistent.
|
|
|
26
26
|
1. Pull the public image:
|
|
27
27
|
|
|
28
28
|
```bash
|
|
29
|
-
podman pull ghcr.io/cairnkeep/cairnkeep:2.
|
|
29
|
+
podman pull ghcr.io/cairnkeep/cairnkeep:2.24.0
|
|
30
30
|
```
|
|
31
31
|
|
|
32
32
|
2. Start the stdio server with a read-only root filesystem and no Linux
|
|
@@ -38,7 +38,7 @@ ephemeral; the named course volume is persistent.
|
|
|
38
38
|
--read-only --cap-drop=all --security-opt=no-new-privileges \
|
|
39
39
|
--tmpfs=/tmp:rw,noexec,nosuid,size=64m,mode=1777 \
|
|
40
40
|
--volume cairnkeep-course-data:/data:Z,U \
|
|
41
|
-
ghcr.io/cairnkeep/cairnkeep:2.
|
|
41
|
+
ghcr.io/cairnkeep/cairnkeep:2.24.0 stdio
|
|
42
42
|
```
|
|
43
43
|
|
|
44
44
|
3. The process waits quietly for MCP input. Press `Ctrl-C` after confirming it
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
**Status:** Ready
|
|
4
4
|
**Track:** Quickstart
|
|
5
5
|
**Time:** 25 minutes
|
|
6
|
-
**Tested with:** Cairnkeep 2.
|
|
6
|
+
**Tested with:** Cairnkeep 2.24.0 and Node.js 22 or newer
|
|
7
7
|
|
|
8
8
|
## Outcome
|
|
9
9
|
|
|
@@ -47,7 +47,7 @@ sync. Codex project trust remains an explicit operator decision.
|
|
|
47
47
|
2. Install Cairnkeep:
|
|
48
48
|
|
|
49
49
|
```bash
|
|
50
|
-
npm install --global @cairnkeep/cli@2.
|
|
50
|
+
npm install --global @cairnkeep/cli@2.24.0
|
|
51
51
|
cairn --version
|
|
52
52
|
```
|
|
53
53
|
|
|
@@ -53,6 +53,18 @@ fact and starting a fresh client, verifying the new value and explicit history.
|
|
|
53
53
|
- No extracted candidate becomes durable without an explicit review action.
|
|
54
54
|
- Enabling typed nodes changes discovery structure, not the authority of a fact.
|
|
55
55
|
|
|
56
|
+
## Optional reranker extension
|
|
57
|
+
|
|
58
|
+
From a source checkout of 2.24.0 or newer, run `npm --prefix mcp-memory-server run check:memory-reranker`
|
|
59
|
+
without a provider. Then, only with explicit synthetic-data/provider consent,
|
|
60
|
+
compare the frozen live study in the retrieval guide. Explain both the 6/16
|
|
61
|
+
to 15/16 first-result improvement and the single regression; this is not a
|
|
62
|
+
general accuracy or agent-productivity claim. Show that reranking is off by
|
|
63
|
+
default, has a separate credential, preserves admission and exact-key priority,
|
|
64
|
+
and falls back without mutating stored facts. A failed provider must make
|
|
65
|
+
`cairn doctor` fail even though search still works. Read-only is not a promise
|
|
66
|
+
of zero network traffic: configured search advertises open-world behavior.
|
|
67
|
+
|
|
56
68
|
## Source material
|
|
57
69
|
|
|
58
70
|
- [Operating guide](../../operating.md#the-workflow)
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
**Status:** Ready
|
|
4
4
|
**Track:** Operator
|
|
5
5
|
**Time:** 30 minutes
|
|
6
|
-
**Tested with:** Cairnkeep 2.
|
|
6
|
+
**Tested with:** Cairnkeep 2.24.0, Windows x64, and Node.js 22 or newer
|
|
7
7
|
|
|
8
8
|
## Outcome
|
|
9
9
|
|
|
@@ -16,7 +16,7 @@ perform a reversible uninstall without relying on WSL or Git Bash.
|
|
|
16
16
|
From PowerShell:
|
|
17
17
|
|
|
18
18
|
```powershell
|
|
19
|
-
npm install --global @cairnkeep/cli@2.
|
|
19
|
+
npm install --global @cairnkeep/cli@2.24.0
|
|
20
20
|
cairn version
|
|
21
21
|
|
|
22
22
|
$Lab = Join-Path $env:TEMP 'Cairnkeep Windows Lab'
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# L28 - Verify Cairnkeep's security posture
|
|
2
2
|
|
|
3
3
|
**Status:** Ready
|
|
4
|
-
**Tested with:** Cairnkeep 2.
|
|
4
|
+
**Tested with:** Cairnkeep 2.24.0 and Node.js 22 or newer
|
|
5
5
|
|
|
6
6
|
## Outcome
|
|
7
7
|
|
|
@@ -11,7 +11,7 @@ and choose least authority for a network-facing MCP client.
|
|
|
11
11
|
|
|
12
12
|
## Prerequisites
|
|
13
13
|
|
|
14
|
-
- Install Cairnkeep 2.
|
|
14
|
+
- Install Cairnkeep 2.24.0 or use a disposable source checkout.
|
|
15
15
|
- Complete [L20 - Least-authority MCP tool profiles](L20-mcp-tool-profiles.md).
|
|
16
16
|
- Use synthetic values only; never paste a real bearer token into a recording,
|
|
17
17
|
terminal transcript, or issue.
|
|
@@ -32,3 +32,16 @@ not confidence, and that the attempted live study failed with HTTP 502. Do not
|
|
|
32
32
|
invent a successful live result or claim general task-quality improvements.
|
|
33
33
|
Use short terminal zooms and side-by-side result comparisons instead of static
|
|
34
34
|
slides; keep credentials and real memory off screen.
|
|
35
|
+
|
|
36
|
+
## Optional reranker segment (2.24.0 or newer source checkout)
|
|
37
|
+
|
|
38
|
+
Record real `check:memory-reranker` output, pausing on the no-network default,
|
|
39
|
+
scope/history canaries and provider-failure fallback. With explicit synthetic
|
|
40
|
+
provider configuration, run `run-memory-reranker-study.mjs --live --summary`.
|
|
41
|
+
Do not show credentials or endpoint addresses. Compare the two arms using the
|
|
42
|
+
same embeddings: the first frozen run improved first hits from 6/16 to 15/16,
|
|
43
|
+
but `current-not-history` regressed. Zoom into that failure rather than hiding
|
|
44
|
+
it. Explain that the earlier HTTP-502 study is separate and remains a failed
|
|
45
|
+
run. Demonstrate the doctor's synthetic request, then disable reranking and
|
|
46
|
+
show unchanged memory text and provenance. This is a recording brief, not a
|
|
47
|
+
claim that this segment has been filmed or published.
|
|
@@ -41,3 +41,10 @@ The default `full` profile preserves the same tool names, schemas, responses,
|
|
|
41
41
|
feature gates, and ordering as an unconfigured server. The annotations help
|
|
42
42
|
conservative clients distinguish observations from actions; they do not grant
|
|
43
43
|
permission or replace the client's approval policy.
|
|
44
|
+
|
|
45
|
+
With a valid, explicitly enabled memory reranker, `memory_search` advertises
|
|
46
|
+
`openWorldHint: true` because query and bounded candidate excerpts can reach
|
|
47
|
+
the configured service. It remains `readOnlyHint: true`, so a read-only
|
|
48
|
+
profile still exposes it. Tool restriction is not network consent: omit it
|
|
49
|
+
from a custom profile or disable reranking if that data flow is unwanted.
|
|
50
|
+
With reranking off, existing discovery metadata is unchanged.
|
package/docs/memory-retrieval.md
CHANGED
|
@@ -161,8 +161,90 @@ Its sample threshold of 0.75 is an evaluation arm, not a recommended default.
|
|
|
161
161
|
An attempted live run during development accepted short probes but failed with
|
|
162
162
|
HTTP 502 on some fixture inputs. No successful live-model result is claimed.
|
|
163
163
|
The offline suite never requires a model, API key, paid service or network beyond
|
|
164
|
-
its own loopback fixture.
|
|
165
|
-
|
|
164
|
+
its own loopback fixture. A separate optional reranker comparison below does
|
|
165
|
+
not replace this older study or retroactively make that failed run successful.
|
|
166
|
+
|
|
167
|
+
## Optional learned reranking
|
|
168
|
+
|
|
169
|
+
Available since Cairnkeep 2.24.0. This feature improves ordering, not guaranteed
|
|
170
|
+
recall: the small live comparison below retained the same Recall@3 in both arms.
|
|
171
|
+
|
|
172
|
+
The optional reranker is default-off and independent of extraction and
|
|
173
|
+
embeddings. Set `CAIRN_MEMORY_RERANK=1`, `CAIRN_MEMORY_RERANK_URL` (a base URL,
|
|
174
|
+
normally ending in `/v1`) and `CAIRN_MEMORY_RERANK_MODEL` on the memory-server
|
|
175
|
+
process. The client appends `/rerank`. If authentication is needed, set the
|
|
176
|
+
separate `CAIRN_MEMORY_RERANK_API_KEY`; extraction/embedding credentials are
|
|
177
|
+
never reused. `CAIRN_MEMORY_RERANK_TIMEOUT_MS` defaults to 2,000 ms and accepts
|
|
178
|
+
100–10,000 ms. Restart the server after configuration changes.
|
|
179
|
+
|
|
180
|
+
Only `memory_search` in the `memory` address space is reranked, for either
|
|
181
|
+
legacy or hybrid strategy. Notes, context packs, documents and the separate
|
|
182
|
+
team store are unchanged. Existing scope, current-value, typed and relevance
|
|
183
|
+
filters admit candidates first. The provider receives the original query
|
|
184
|
+
(at most 4 KiB) and at most 50 admitted key/value excerpts (at most 4 KiB each),
|
|
185
|
+
not the whole store. Candidate depth is `min(50, max(32, 4 * top_k))`.
|
|
186
|
+
Exact-key priority in hybrid search (including explicit aliases) and typed legacy exact-hit priority remain
|
|
187
|
+
ahead of model scores. The provider only reorders admitted records; it cannot
|
|
188
|
+
add facts, change authority, write memory or recover a candidate already
|
|
189
|
+
rejected by admission. A small final `top_k` can include a candidate outside
|
|
190
|
+
the original top-k prefix after reranking.
|
|
191
|
+
|
|
192
|
+
The endpoint must return every candidate index exactly once in a `results`
|
|
193
|
+
array with finite `relevance_score` numbers. Negative raw scores are accepted;
|
|
194
|
+
these are ranking values, not calibrated confidence. Returned memory text,
|
|
195
|
+
provenance and existing retrieval scores are unchanged; those scores need not
|
|
196
|
+
be descending after learned reranking. Ties preserve the
|
|
197
|
+
existing order. Timeout, HTTP errors, redirects, oversized responses, invalid
|
|
198
|
+
configuration or malformed/partial indices preserve the original order.
|
|
199
|
+
No retries or alternate provider fallback are made. Unset/disabled configuration
|
|
200
|
+
performs no reranker request and preserves existing discovery and responses.
|
|
201
|
+
When configured, `memory_search` remains read-only but advertises
|
|
202
|
+
`openWorldHint: true` because it can export candidate text.
|
|
203
|
+
|
|
204
|
+
`cairn doctor` uses synthetic text to check configured reranking and fails if
|
|
205
|
+
the configuration or response is invalid. Ordinary searches still fall back
|
|
206
|
+
safely; doctor does not mistake fallback for a healthy provider.
|
|
207
|
+
|
|
208
|
+
### Bounded provider comparison
|
|
209
|
+
|
|
210
|
+
The frozen `memory-reranker-study-v1.json` fixture contains 20 synthetic
|
|
211
|
+
continuity cases, including 16 positive and four negative cases. Both arms use
|
|
212
|
+
the same embedding vectors, admission rules, aliases and final top three.
|
|
213
|
+
Labels and the fixture digest were frozen before the first live ranking run.
|
|
214
|
+
|
|
215
|
+
| Metric | Hybrid + embeddings | Same candidates + reranker |
|
|
216
|
+
|---|---:|---:|
|
|
217
|
+
| Relevant first result, 16 positive cases | 6/16 | 15/16 |
|
|
218
|
+
| MRR@3 | 0.635417 | 0.968750 |
|
|
219
|
+
| NDCG@3 | 0.728416 | 0.976933 |
|
|
220
|
+
| Recall@3 | 1.0 | 1.0 |
|
|
221
|
+
| Negative queries returning results, 4 cases | 0/4 | 0/4 |
|
|
222
|
+
|
|
223
|
+
Ten cases improved and **one regressed**: `current-not-history` ranked a
|
|
224
|
+
topical interface distractor above the correct current fact. Hidden history
|
|
225
|
+
was still excluded. No scope/history violation or provider failure occurred.
|
|
226
|
+
Observed additional ranking latency was 9 ms median and 46 ms p95 in this
|
|
227
|
+
small local run; this is not a deployment latency guarantee.
|
|
228
|
+
|
|
229
|
+
This is an engineering-labeled synthetic comparison with deliberately
|
|
230
|
+
ambiguous topical distractors, not an independent holdout, a human study or
|
|
231
|
+
proof of general agent-quality gains. It justifies an optional experiment,
|
|
232
|
+
not default enablement. Evaluate your provider and corpus before opting in;
|
|
233
|
+
disable `CAIRN_MEMORY_RERANK` to reverse the change.
|
|
234
|
+
|
|
235
|
+
From a source checkout (these developer study scripts are not installed CLI
|
|
236
|
+
commands):
|
|
237
|
+
|
|
238
|
+
```sh
|
|
239
|
+
npm --prefix mcp-memory-server run check:memory-reranker
|
|
240
|
+
# Requires explicit existing embedding and reranker configuration:
|
|
241
|
+
node mcp-memory-server/scripts/run-memory-reranker-study.mjs --live --summary
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
Without `--live`, the study makes no provider calls even when configured.
|
|
245
|
+
Reports include fixture, implementation and model-ID digests, no URLs or keys.
|
|
246
|
+
The frozen fixture SHA-256 is
|
|
247
|
+
`f6a5d2de8d2846dd5a8de764096d92255716592805e8abfdc771181eac23beb1`.
|
|
166
248
|
|
|
167
249
|
## Research connection
|
|
168
250
|
|
package/docs/operating.md
CHANGED
|
@@ -495,6 +495,11 @@ vendor or host.
|
|
|
495
495
|
| `CAIRN_MEMORY_EMBEDDING_URL` | Embeddings endpoint (falls back to `CAIRN_LLM_API_URL`) |
|
|
496
496
|
| `CAIRN_MEMORY_EMBEDDING_MODEL` | Embedding model name (required for semantic search) |
|
|
497
497
|
| `CAIRN_MEMORY_EMBEDDING_TIMEOUT_MS` | Embedding request timeout before substring fallback (default `15000`) |
|
|
498
|
+
| `CAIRN_MEMORY_RERANK` | Explicit `1` enables optional learned memory-search reranking; off by default |
|
|
499
|
+
| `CAIRN_MEMORY_RERANK_URL` | Explicit HTTP(S) base URL; client appends `/rerank`; no embedded credentials, query or fragment |
|
|
500
|
+
| `CAIRN_MEMORY_RERANK_MODEL` | Required model identifier when reranking is enabled |
|
|
501
|
+
| `CAIRN_MEMORY_RERANK_API_KEY` | Optional separate reranker credential; no extraction/embedding key fallback |
|
|
502
|
+
| `CAIRN_MEMORY_RERANK_TIMEOUT_MS` | Reranking deadline, default `2000`, allowed `100`–`10000`; failure preserves existing search order |
|
|
498
503
|
| `CAIRN_AGENTFS_BASE_DIR` | Server-side base dir for named/global memory scopes (default `~/.cairnkeep`); it does not affect `project` scope |
|
|
499
504
|
| `CAIRN_TRAJECTORY_CAPTURE` | Opt in to local structured session capture (`1`, `true`, `yes`, or `on`; unset/default → no capture work) |
|
|
500
505
|
| `CAIRN_TRAJECTORY_SESSION_MAX_BYTES` | Maximum serialized bytes per session (default `5242880`, 5 MiB; minimum 1024) |
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
Cairnkeep has no telemetry, analytics collector, hosted control plane, or
|
|
4
4
|
automatic remote-service discovery. A default local stdio installation stores
|
|
5
5
|
memory in SQLite on the machine running the MCP server and makes no model or RAG
|
|
6
|
-
request unless the corresponding
|
|
6
|
+
request unless the corresponding feature is explicitly configured as described
|
|
7
|
+
below (some operator-owned endpoints do not require a credential).
|
|
7
8
|
|
|
8
9
|
## Data flows
|
|
9
10
|
|
|
@@ -11,6 +12,7 @@ request unless the corresponding endpoint and credential are configured.
|
|
|
11
12
|
|---|---|---|
|
|
12
13
|
| Local substring search | None | Local SQLite only |
|
|
13
14
|
| Embedding-ranked search | Stored memory values that are not cached, plus the search query | `CAIRN_MEMORY_EMBEDDING_URL` or `CAIRN_LLM_API_URL` |
|
|
15
|
+
| Separately opted-in memory reranking | Query and at most 50 scope/current/filter-admitted key/value excerpts, each at most 4 KiB | Only the explicit `CAIRN_MEMORY_RERANK_URL`; separate optional credential, no retries/redirects/alternate hosts |
|
|
14
16
|
| `memory_extract` | The transcript supplied to the tool | `CAIRN_LLM_API_URL` |
|
|
15
17
|
| `domain_knowledge_query` | Workspace slug and query | `ANYTHINGLLM_BASE_URL` |
|
|
16
18
|
| `domain_knowledge_sync` | Files selected by the sync configuration | `ANYTHINGLLM_BASE_URL` |
|
|
@@ -50,6 +52,10 @@ Model endpoints may be local or remote. Cairnkeep cannot determine a provider's
|
|
|
50
52
|
retention, training, or logging policy; verify it before sending confidential
|
|
51
53
|
material. Disabling `CAIRN_LLM_API_KEY`, `ANYTHINGLLM_API_KEY`, remote HTTP
|
|
52
54
|
registration, and delegated tools keeps the core memory workflow local.
|
|
55
|
+
Also leave `CAIRN_MEMORY_RERANK` unset to disable its independent data flow;
|
|
56
|
+
it does not require or inherit `CAIRN_LLM_API_KEY`. A local provider is still a
|
|
57
|
+
separate process that receives these excerpts. Neither a chat agent nor a
|
|
58
|
+
research assistant is part of reranking or authorization.
|
|
53
59
|
|
|
54
60
|
## Playbook decision and receipt flow
|
|
55
61
|
|
|
@@ -91,11 +97,14 @@ backups/journals are sensitive local data at rest. Dry-run and commit responses
|
|
|
91
97
|
contain scopes/address spaces, digests, keys, actions, and counts—not supplied
|
|
92
98
|
or displaced values. No model, API key, endpoint, or network is required.
|
|
93
99
|
|
|
94
|
-
|
|
95
|
-
|
|
100
|
+
Explicitly configured embedding search can send eligible key/value/type/tag
|
|
101
|
+
text and the query to
|
|
96
102
|
`CAIRN_MEMORY_EMBEDDING_URL` (or its documented fallback). Hard filters run
|
|
97
103
|
before that request; endpoint failure returns to local substring search. Note
|
|
98
|
-
search is always local and never uses embeddings.
|
|
104
|
+
search is always local and never uses embeddings or reranking. Separately
|
|
105
|
+
opting into the [bounded reranker](memory-retrieval.md#optional-learned-reranking)
|
|
106
|
+
can also send admitted memory excerpts and the query. It only changes ordering,
|
|
107
|
+
never scope, history admission, approval, factual authority or durable memory.
|
|
99
108
|
|
|
100
109
|
Canonical note storage includes manifest records, Markdown, indexes, history,
|
|
101
110
|
import replay state, staged bytes, backups, and journals. Prepared/committing
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"identifier":"cairn-offline-fake-v1","package_version":"2.
|
|
1
|
+
{"identifier":"cairn-offline-fake-v1","package_version":"2.24.0","schema_version":1,"task_set_digest":"13e95379f35839593047ec34521a65a8fa3ab92bd3993bca9dc8bf5c4a9ff36f"}
|
|
@@ -16,6 +16,7 @@ import { artifactIdSchema, artifactKindSchema, artifactMediaTypeSchema, artifact
|
|
|
16
16
|
import { isCapabilityContractEnabled, resolveCapabilityStatus, } from "./capability-config.js";
|
|
17
17
|
import { EmbeddingCache, cosineSimilarity, embedTexts, getEmbeddingConfig, hashText, } from "./embeddings.js";
|
|
18
18
|
import { queryDomainKnowledge, resolveDomainRetrievalProvider } from "./domain-retrieval-provider.js";
|
|
19
|
+
import { getMemoryRerankerConfig, memoryRerankCandidateCount, rerankMemoryResults } from "./memory-reranker.js";
|
|
19
20
|
import { formatHostAuthority, normalizeHostAuthority, normalizeHttpHost, parseHttpPort, resolveHttpToken } from "./http-security.js";
|
|
20
21
|
import { computeRepoState, exploreCacheKey, normalizeExploreQuery, readExploreCache, writeExploreCache, } from "./explore-cache.js";
|
|
21
22
|
import { canonicalTagsSchema, isTypedMemoryNodesEnabled, memoryImportEnvelopeSchema, noteNodeSchema, nodeTypeSchema, } from "./node-schema.js";
|
|
@@ -377,7 +378,7 @@ function entryText(entry) {
|
|
|
377
378
|
function embeddingCachePath(scope, projectId) {
|
|
378
379
|
return join(getBaseDir(), ".embeddings", `${hashText(resolveScopePath(scope, { projectId }))}.json`);
|
|
379
380
|
}
|
|
380
|
-
async function
|
|
381
|
+
async function semanticSearchCandidates(scope, query, topK, minScore, config = getMemoryConfig(), projectId, options = {}) {
|
|
381
382
|
const scopes = getSearchScopes(scope, config);
|
|
382
383
|
const embeddingConfig = getEmbeddingConfig();
|
|
383
384
|
const perScopeEntries = await Promise.all(scopes.map(async (candidate) => ({
|
|
@@ -480,6 +481,32 @@ async function semanticSearch(scope, query, topK, minScore, config = getMemoryCo
|
|
|
480
481
|
return substringFallback();
|
|
481
482
|
}
|
|
482
483
|
}
|
|
484
|
+
async function semanticSearch(...args) {
|
|
485
|
+
const reranker = getMemoryRerankerConfig();
|
|
486
|
+
if (!reranker)
|
|
487
|
+
return semanticSearchCandidates(...args);
|
|
488
|
+
const [, query, topK] = args;
|
|
489
|
+
const options = args[6] ?? {};
|
|
490
|
+
const candidateArgs = [...args];
|
|
491
|
+
candidateArgs[2] = memoryRerankCandidateCount(topK, reranker);
|
|
492
|
+
// The existing path still owns scope, current-value, typed and relevance
|
|
493
|
+
// admission. The external ranker never sees records rejected by that path.
|
|
494
|
+
const candidate = await semanticSearchCandidates(...candidateArgs);
|
|
495
|
+
const exactKeys = new Set([query, ...(options.aliases ?? [])].map(value => value.normalize('NFKC').toLowerCase().trim()));
|
|
496
|
+
const pinned = (entry) => options.strategy === 'hybrid'
|
|
497
|
+
? exactKeys.has(entry.key.normalize('NFKC').toLowerCase())
|
|
498
|
+
: Boolean(options.typed && isExactTextHit(entry, query));
|
|
499
|
+
const { results } = await rerankMemoryResults(candidate.results, query, topK, reranker, pinned);
|
|
500
|
+
const retrieval = candidate.retrieval;
|
|
501
|
+
if (retrieval?.possible_conflicts) {
|
|
502
|
+
const keys = new Set(results.map(entry => entry.key));
|
|
503
|
+
// Explain only selected keys, including when the larger candidate pool
|
|
504
|
+
// falls back. Do not leak explanations for unreturned candidates.
|
|
505
|
+
return { ...candidate, results, retrieval: { ...retrieval,
|
|
506
|
+
possible_conflicts: retrieval.possible_conflicts.filter(row => keys.has(row.key)) } };
|
|
507
|
+
}
|
|
508
|
+
return { ...candidate, results };
|
|
509
|
+
}
|
|
483
510
|
// Confines a candidate wiki source file to inside `<repoRoot>/.planning/wiki/sources`
|
|
484
511
|
// via relative()-based containment (Phase 2 SEC-0001 idiom, reused from
|
|
485
512
|
// opencode/plugins/memory-recall.ts) — `resolve() === join()` misses `../`
|
|
@@ -708,8 +735,9 @@ function gitToplevel(cwd) {
|
|
|
708
735
|
// serve many concurrent clients within one trusted server-side storage domain.
|
|
709
736
|
function buildMemoryServer(context, capabilitySnapshot, withCapability, toolProfile = resolveMcpToolProfile({ projectRoot: process.cwd() })) {
|
|
710
737
|
const server = new McpServer({ name: "cairn-memory", version: "0.1.0" });
|
|
738
|
+
const memoryReranking = getMemoryRerankerConfig() !== null;
|
|
711
739
|
const registerTool = (name, config, callback) => {
|
|
712
|
-
const metadata = metadataForTool(name);
|
|
740
|
+
const metadata = metadataForTool(name, { memoryReranking });
|
|
713
741
|
if (!profileAllowsTool(toolProfile, name))
|
|
714
742
|
return;
|
|
715
743
|
server.registerTool(name, {
|
|
@@ -45,8 +45,11 @@ export const MCP_TOOL_NAMES = Object.freeze(Object.keys(MCP_TOOL_CATALOG));
|
|
|
45
45
|
export function isCairnToolName(value) {
|
|
46
46
|
return Object.hasOwn(MCP_TOOL_CATALOG, value);
|
|
47
47
|
}
|
|
48
|
-
export function metadataForTool(name) {
|
|
48
|
+
export function metadataForTool(name, options = {}) {
|
|
49
49
|
if (!isCairnToolName(name))
|
|
50
50
|
throw new Error(`MCP tool ${name} has no annotation contract.`);
|
|
51
|
+
if (name === 'memory_search' && options.memoryReranking) {
|
|
52
|
+
return { ...MCP_TOOL_CATALOG[name], annotations: { ...MCP_TOOL_CATALOG[name].annotations, openWorldHint: true } };
|
|
53
|
+
}
|
|
51
54
|
return MCP_TOOL_CATALOG[name];
|
|
52
55
|
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
const MAX_CANDIDATES = 50;
|
|
3
|
+
const MAX_TEXT_BYTES = 4096;
|
|
4
|
+
const MAX_REQUEST_BYTES = 256 * 1024;
|
|
5
|
+
const MAX_RESPONSE_BYTES = 64 * 1024;
|
|
6
|
+
const responseSchema = z.object({ results: z.array(z.object({
|
|
7
|
+
index: z.number().int().min(0).max(MAX_CANDIDATES - 1),
|
|
8
|
+
relevance_score: z.number().finite(),
|
|
9
|
+
})).max(MAX_CANDIDATES) });
|
|
10
|
+
/** Configuration alone never grants permission: explicit opt-in is required. */
|
|
11
|
+
export function getMemoryRerankerConfig(env = process.env) {
|
|
12
|
+
if (env.CAIRN_MEMORY_RERANK !== '1')
|
|
13
|
+
return null;
|
|
14
|
+
try {
|
|
15
|
+
const url = new URL(env.CAIRN_MEMORY_RERANK_URL ?? '');
|
|
16
|
+
const model = env.CAIRN_MEMORY_RERANK_MODEL?.trim();
|
|
17
|
+
const rawTimeout = env.CAIRN_MEMORY_RERANK_TIMEOUT_MS ?? '2000';
|
|
18
|
+
const timeoutMs = Number(rawTimeout);
|
|
19
|
+
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash
|
|
20
|
+
|| !model || Buffer.byteLength(model) > 256 || !/^\d+$/.test(rawTimeout)
|
|
21
|
+
|| !Number.isInteger(timeoutMs) || timeoutMs < 100 || timeoutMs > 10000)
|
|
22
|
+
return null;
|
|
23
|
+
const apiKey = env.CAIRN_MEMORY_RERANK_API_KEY?.trim();
|
|
24
|
+
if (apiKey && (apiKey.length > 8192 || /[\r\n]/.test(apiKey)))
|
|
25
|
+
return null;
|
|
26
|
+
return { apiUrl: url.href.replace(/\/+$/, ''), model, ...(apiKey ? { apiKey } : {}), timeoutMs };
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
export function memoryRerankCandidateCount(topK, config) {
|
|
33
|
+
if (!Number.isInteger(topK) || topK < 1 || topK > MAX_CANDIDATES)
|
|
34
|
+
throw new Error('Reranking requires a bounded result count.');
|
|
35
|
+
return config ? Math.min(MAX_CANDIDATES, Math.max(32, topK * 4)) : topK;
|
|
36
|
+
}
|
|
37
|
+
function textPrefix(value, maxUnits) {
|
|
38
|
+
let end = Math.min(value.length, maxUnits);
|
|
39
|
+
const last = value.charCodeAt(end - 1);
|
|
40
|
+
if (end < value.length && last >= 0xD800 && last <= 0xDBFF)
|
|
41
|
+
end--;
|
|
42
|
+
return value.slice(0, end);
|
|
43
|
+
}
|
|
44
|
+
function rankingText(entry) {
|
|
45
|
+
// Bound allocation before UTF-8 encoding, then discard an incomplete final
|
|
46
|
+
// code point rather than introducing replacement characters at the cutoff.
|
|
47
|
+
const text = `${textPrefix(entry.key, 1024)}\n${textPrefix(entry.value, MAX_TEXT_BYTES)}`;
|
|
48
|
+
const bytes = Buffer.from(text);
|
|
49
|
+
return new TextDecoder().decode(bytes.subarray(0, MAX_TEXT_BYTES), { stream: true });
|
|
50
|
+
}
|
|
51
|
+
async function readRankingResponse(response) {
|
|
52
|
+
if (!response.ok || !response.body || Number(response.headers.get('content-length')) > MAX_RESPONSE_BYTES) {
|
|
53
|
+
await response.body?.cancel();
|
|
54
|
+
throw new Error('Reranker response unavailable.');
|
|
55
|
+
}
|
|
56
|
+
const reader = response.body.getReader();
|
|
57
|
+
const chunks = [];
|
|
58
|
+
let size = 0;
|
|
59
|
+
try {
|
|
60
|
+
while (true) {
|
|
61
|
+
const { done, value } = await reader.read();
|
|
62
|
+
if (done)
|
|
63
|
+
break;
|
|
64
|
+
size += value.byteLength;
|
|
65
|
+
if (size > MAX_RESPONSE_BYTES)
|
|
66
|
+
throw new Error('Reranker response exceeds limit.');
|
|
67
|
+
chunks.push(value);
|
|
68
|
+
}
|
|
69
|
+
const bytes = Buffer.concat(chunks, size);
|
|
70
|
+
return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes));
|
|
71
|
+
}
|
|
72
|
+
finally {
|
|
73
|
+
await reader.cancel().catch(() => { });
|
|
74
|
+
reader.releaseLock();
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
/** Reorder admitted records only. No input object, score, scope or value is changed. */
|
|
78
|
+
export async function rerankMemoryResults(entries, query, topK, config, pinned = () => false) {
|
|
79
|
+
const baseline = entries.slice(0, topK);
|
|
80
|
+
if (!config)
|
|
81
|
+
return { results: baseline, status: 'disabled' };
|
|
82
|
+
if (entries.length < 2 || entries.length > MAX_CANDIDATES || !query.trim() || Buffer.byteLength(query) > MAX_TEXT_BYTES) {
|
|
83
|
+
return { results: baseline, status: 'skipped' };
|
|
84
|
+
}
|
|
85
|
+
try {
|
|
86
|
+
const body = JSON.stringify({ model: config.model, query, documents: entries.map(rankingText), top_n: entries.length });
|
|
87
|
+
if (Buffer.byteLength(body) > MAX_REQUEST_BYTES)
|
|
88
|
+
return { results: baseline, status: 'skipped' };
|
|
89
|
+
const response = await fetch(`${config.apiUrl}/rerank`, {
|
|
90
|
+
method: 'POST', redirect: 'manual', signal: AbortSignal.timeout(config.timeoutMs),
|
|
91
|
+
headers: { 'Content-Type': 'application/json', ...(config.apiKey ? { Authorization: `Bearer ${config.apiKey}` } : {}) },
|
|
92
|
+
body,
|
|
93
|
+
});
|
|
94
|
+
const parsed = responseSchema.parse(await readRankingResponse(response));
|
|
95
|
+
if (parsed.results.length !== entries.length
|
|
96
|
+
|| new Set(parsed.results.map(row => row.index)).size !== entries.length
|
|
97
|
+
|| parsed.results.some(row => row.index >= entries.length))
|
|
98
|
+
throw new Error('Invalid ranking indices.');
|
|
99
|
+
const scores = new Map(parsed.results.map(row => [row.index, row.relevance_score]));
|
|
100
|
+
const ordered = entries.map((entry, index) => ({ entry, index, pinned: pinned(entry) }));
|
|
101
|
+
ordered.sort((a, b) => Number(b.pinned) - Number(a.pinned)
|
|
102
|
+
|| scores.get(b.index) - scores.get(a.index) || a.index - b.index);
|
|
103
|
+
return { results: ordered.slice(0, topK).map(row => row.entry), status: 'reranked' };
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
// Never return endpoint errors, response bodies or credentials to clients.
|
|
107
|
+
return { results: baseline, status: 'failed' };
|
|
108
|
+
}
|
|
109
|
+
}
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
"check:extract": "node scripts/smoke-extract-cli.mjs",
|
|
17
17
|
"check:search": "node scripts/smoke-search-e2e.mjs",
|
|
18
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
|
+
"check:memory-reranker": "node scripts/smoke-memory-reranker.mjs && node scripts/smoke-memory-reranker-mcp.mjs && node scripts/run-memory-reranker-study.mjs --check --summary",
|
|
19
20
|
"study:memory-retrieval": "npm run build && node scripts/run-memory-retrieval-study.mjs --summary",
|
|
20
21
|
"check:scope-guard": "node scripts/smoke-scope-guard.mjs",
|
|
21
22
|
"check:http-guard": "node scripts/smoke-http-guard.mjs",
|
package/package.json
CHANGED
package/scripts/doctor.sh
CHANGED
|
@@ -112,6 +112,23 @@ else
|
|
|
112
112
|
skip "embedding endpoint (CAIRN_MEMORY_EMBEDDING_URL unset)"
|
|
113
113
|
fi
|
|
114
114
|
|
|
115
|
+
# Optional learned reranking: use the runtime validator and a synthetic request.
|
|
116
|
+
# Never print provider bodies, credentials, URLs or user memory in diagnostics.
|
|
117
|
+
if [[ "${CAIRN_MEMORY_RERANK:-}" == 1 ]]; then
|
|
118
|
+
if node "$CAIRN_ROOT/scripts/probe-memory-reranker.mjs" >/dev/null 2>&1; then
|
|
119
|
+
pass "memory reranker accepted a synthetic ranking request"
|
|
120
|
+
else
|
|
121
|
+
rerank_status=$?
|
|
122
|
+
if [[ "$rerank_status" == 2 ]]; then
|
|
123
|
+
fail "memory reranker configuration invalid — set CAIRN_MEMORY_RERANK_URL and CAIRN_MEMORY_RERANK_MODEL; timeout must be 100–10000 ms"
|
|
124
|
+
else
|
|
125
|
+
fail "memory reranker unavailable or returned invalid ranking data — search falls back to its existing order"
|
|
126
|
+
fi
|
|
127
|
+
fi
|
|
128
|
+
else
|
|
129
|
+
skip "memory reranker (CAIRN_MEMORY_RERANK unset/not 1)"
|
|
130
|
+
fi
|
|
131
|
+
|
|
115
132
|
# 4. Git provider (collaboration commands). Report configuration only — auth is
|
|
116
133
|
# provider- and host-specific and lives in the wrapper, not the core.
|
|
117
134
|
case "${CAIRN_GIT_PROVIDER:-}" in
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// Exit codes are a value-free doctor protocol: 0 disabled/healthy,
|
|
2
|
+
// 2 invalid opt-in configuration, 3 unavailable or invalid ranking response.
|
|
3
|
+
import { getMemoryRerankerConfig, rerankMemoryResults } from '../mcp-memory-server/dist/memory-reranker.js';
|
|
4
|
+
import { realpathSync } from 'node:fs';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
|
|
7
|
+
export async function probeMemoryReranker() {
|
|
8
|
+
if (process.env.CAIRN_MEMORY_RERANK !== '1') return 0;
|
|
9
|
+
const config = getMemoryRerankerConfig();
|
|
10
|
+
if (!config) return 2;
|
|
11
|
+
const result = await rerankMemoryResults([
|
|
12
|
+
{ key: 'health/policy', value: 'Corrections append a new release ledger entry.' },
|
|
13
|
+
{ key: 'health/theme', value: 'The release ledger interface uses a dark theme.' },
|
|
14
|
+
], 'How are release ledger corrections recorded?', 2, config);
|
|
15
|
+
return result.status === 'reranked' ? 0 : 3;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
let invokedDirectly = false;
|
|
19
|
+
try { invokedDirectly = Boolean(process.argv[1] && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)); } catch {}
|
|
20
|
+
if (invokedDirectly) {
|
|
21
|
+
process.exitCode = await probeMemoryReranker();
|
|
22
|
+
}
|
|
@@ -11,6 +11,8 @@ assert.match(quadlet, /^Secret=cairnkeep-http-token,type=mount,target=http-token
|
|
|
11
11
|
"the managed container secret must satisfy the probe's private-file contract");
|
|
12
12
|
assert.match(containerfile, /COPY --chmod=644 containers\/healthcheck\.mjs \/opt\/cairnkeep\/containers\/healthcheck\.mjs/, "probe imports must resolve within the packaged server");
|
|
13
13
|
assert.match(containerfile, /ln -s \/opt\/cairnkeep\/containers\/healthcheck\.mjs \/usr\/local\/lib\/cairnkeep\/container-healthcheck\.mjs/, "keep the established container probe path");
|
|
14
|
+
assert.match(containerfile, /COPY --chmod=644 containers\/healthcheck\.mjs[^\n]*\nRUN chmod 755 \/opt\/cairnkeep\/containers\b/,
|
|
15
|
+
"normalize the probe directory's traversal mode after COPY for unprivileged runtime users");
|
|
14
16
|
assert.match(source, /parseHttpPort/, "health destination must use the server's numeric port parser");
|
|
15
17
|
assert.match(source, /resolveHttpToken/, "health tokens must use the server's bounded resolver");
|
|
16
18
|
const { containerHealthcheck } = await import("../containers/healthcheck.mjs");
|
|
@@ -43,6 +43,21 @@ image_user=$("$ENGINE" image inspect --format '{{.Config.User}}' "$server_image"
|
|
|
43
43
|
exit 1
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
# Check the real legacy entrypoint before a failing credential probe can mask
|
|
47
|
+
# a module-load or directory-traversal error as an expected authentication denial.
|
|
48
|
+
"$ENGINE" run --rm --entrypoint node "$server_image" --input-type=module -e '
|
|
49
|
+
import assert from "node:assert/strict";
|
|
50
|
+
import { accessSync, constants, realpathSync } from "node:fs";
|
|
51
|
+
import { pathToFileURL } from "node:url";
|
|
52
|
+
assert.equal(process.getuid(), 10001);
|
|
53
|
+
accessSync("/opt/cairnkeep/containers", constants.R_OK | constants.X_OK);
|
|
54
|
+
const target = realpathSync("/usr/local/lib/cairnkeep/container-healthcheck.mjs");
|
|
55
|
+
assert.equal(target, "/opt/cairnkeep/containers/healthcheck.mjs");
|
|
56
|
+
accessSync(target, constants.R_OK);
|
|
57
|
+
await import(pathToFileURL(target).href);
|
|
58
|
+
console.log("container health entrypoint: readable, searchable and importable as runtime UID");
|
|
59
|
+
'
|
|
60
|
+
|
|
46
61
|
"$ENGINE" volume create "$volume" >/dev/null
|
|
47
62
|
node scripts/probe-memory-server.mjs --command \
|
|
48
63
|
"$ENGINE" run --rm -i --volume "$volume:/data" "$server_image" stdio
|
|
@@ -112,10 +127,15 @@ done
|
|
|
112
127
|
cat "$tmp/write.out"
|
|
113
128
|
# Exec probes see the original container environment, not the entrypoint's
|
|
114
129
|
# loaded secret. The deliberately public bind fixture must be rejected.
|
|
115
|
-
if "$ENGINE" exec "$name" node /usr/local/lib/cairnkeep/container-healthcheck.mjs; then
|
|
130
|
+
if "$ENGINE" exec "$name" node /usr/local/lib/cairnkeep/container-healthcheck.mjs >"$tmp/health.out" 2>"$tmp/health.err"; then
|
|
116
131
|
echo "FAIL: healthcheck accepted a non-private token file" >&2
|
|
117
132
|
exit 1
|
|
118
133
|
fi
|
|
134
|
+
[[ ! -s "$tmp/health.out" && ! -s "$tmp/health.err" ]] || {
|
|
135
|
+
cat "$tmp/health.out" "$tmp/health.err" >&2
|
|
136
|
+
echo "FAIL: healthcheck must reject unsafe credentials without a runtime error" >&2
|
|
137
|
+
exit 1
|
|
138
|
+
}
|
|
119
139
|
# Exercise the same legacy entrypoint against a private, container-owned file.
|
|
120
140
|
"$ENGINE" exec "$name" sh -c 'umask 077; cat /run/secrets/http-token > /tmp/health-token; CAIRN_MEMORY_HTTP_TOKEN_FILE=/tmp/health-token node /usr/local/lib/cairnkeep/container-healthcheck.mjs; status=$?; rm -f /tmp/health-token; exit "$status"'
|
|
121
141
|
"$ENGINE" rm -f "$name" >/dev/null
|
|
@@ -25,6 +25,16 @@ function check(label, mutate, expected) {
|
|
|
25
25
|
}
|
|
26
26
|
try {
|
|
27
27
|
check("valid dependency graph", () => {}, null);
|
|
28
|
+
for (const lockPath of ['package-lock.json', 'mcp-memory-server/package-lock.json']) {
|
|
29
|
+
for (const [name, vulnerable] of [['body-parser', '2.3.0'], ['fast-uri', '3.1.8'], ['ip-address', '10.7.3']]) {
|
|
30
|
+
check(`${lockPath} rejects vulnerable ${name}`, x => {
|
|
31
|
+
x[lockPath].packages[`node_modules/${name}`].version = vulnerable;
|
|
32
|
+
}, new RegExp(`resolved ${name}: requires`));
|
|
33
|
+
check(`${lockPath} rejects nested vulnerable ${name}`, x => {
|
|
34
|
+
x[lockPath].packages[`node_modules/fixture/node_modules/${name}`] = { version: vulnerable };
|
|
35
|
+
}, new RegExp(`resolved ${name}: requires`));
|
|
36
|
+
}
|
|
37
|
+
}
|
|
28
38
|
check("root manifest/lock mismatch", x => { x["package.json"].dependencies.zod = "^4.0.0"; }, /manifest and lock root Zod ranges differ/);
|
|
29
39
|
check("server manifest/lock mismatch", x => { x["mcp-memory-server/package-lock.json"].packages[""].dependencies.zod = "^4.0.0"; }, /manifest and lock root Zod ranges differ/);
|
|
30
40
|
check("matched server range differs from packaged CLI", x => {
|
|
@@ -34,7 +44,7 @@ try {
|
|
|
34
44
|
check("different resolved schema version", x => { x["mcp-memory-server/package-lock.json"].packages["node_modules/zod"].version = "4.0.0"; }, /root and server resolved Zod versions differ/);
|
|
35
45
|
check("missing direct schema", x => { delete x["package-lock.json"].packages["node_modules/zod"]; }, /direct Zod: version must be a string/);
|
|
36
46
|
check("existing SDK advisory check retained", x => { x["package-lock.json"].packages["node_modules/@modelcontextprotocol/sdk"].version = "1.0.0"; }, /resolved @modelcontextprotocol\/sdk: requires/);
|
|
37
|
-
console.log("PASS: dependency policy positive control,
|
|
47
|
+
console.log("PASS: dependency policy positive control, both-lock/nested advisory rejection and schema parity mutations");
|
|
38
48
|
} finally {
|
|
39
49
|
rmSync(scratch, { recursive: true, force: true });
|
|
40
50
|
}
|
package/scripts/test-doctor.sh
CHANGED
|
@@ -18,12 +18,14 @@ export HOME="$tmp/home"
|
|
|
18
18
|
mkdir -p "$HOME"
|
|
19
19
|
# Clean the inherited env so the test controls what is "configured".
|
|
20
20
|
unset CAIRN_LLM_API_URL CAIRN_MEMORY_EMBEDDING_URL CAIRN_GIT_PROVIDER CAIRN_AGENTFS_BASE_DIR
|
|
21
|
+
unset CAIRN_MEMORY_RERANK CAIRN_MEMORY_RERANK_URL CAIRN_MEMORY_RERANK_MODEL CAIRN_MEMORY_RERANK_API_KEY CAIRN_MEMORY_RERANK_TIMEOUT_MS
|
|
21
22
|
|
|
22
23
|
# 1. Nothing configured → only SKIP/PASS/WARN, exit 0.
|
|
23
24
|
proj="$tmp/clean"; mkdir -p "$proj"
|
|
24
25
|
( cd "$proj" && "$doctor" ) >"$tmp/out1" 2>&1 || fail "doctor exited non-zero with nothing configured:\n$(cat "$tmp/out1")"
|
|
25
26
|
grep -q "\[SKIP\]" "$tmp/out1" || fail "expected SKIP lines when nothing is configured"
|
|
26
27
|
grep -q "typed metadata and note transaction state" "$tmp/out1" || fail "expected typed/note diagnostics in the public doctor output"
|
|
28
|
+
grep -qF "[SKIP] memory reranker" "$tmp/out1" || fail "expected default-off reranker diagnostic"
|
|
27
29
|
grep -q "artifact store (not present" "$tmp/out1" || fail "expected absent opt-in artifact diagnostics"
|
|
28
30
|
grep -q "capability configuration (not present — managed overrides are unused)" "$tmp/out1" ||
|
|
29
31
|
fail "expected absent capability configuration diagnostics"
|
|
@@ -92,6 +94,12 @@ const server = createServer((request, response) => {
|
|
|
92
94
|
request.on("end", () => {
|
|
93
95
|
let body = {};
|
|
94
96
|
try { body = JSON.parse(Buffer.concat(chunks).toString("utf8")); } catch {}
|
|
97
|
+
if (request.url === "/v1/rerank") {
|
|
98
|
+
response.writeHead(200, { "Content-Type": "application/json" });
|
|
99
|
+
response.end(JSON.stringify({ results: body.model === "invalid-ranker" ? [] :
|
|
100
|
+
body.documents.map((_, index) => ({ index, relevance_score: -index })) }));
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
95
103
|
const routeValid = request.url === "/v1/embeddings";
|
|
96
104
|
const keyValid = request.headers.authorization === "Bearer test-key";
|
|
97
105
|
const modelValid = body.model === "test-embedding";
|
|
@@ -153,6 +161,26 @@ EOF
|
|
|
153
161
|
fail "missing-model diagnostic leaked an upstream body or request id"
|
|
154
162
|
fi
|
|
155
163
|
|
|
164
|
+
# Reranker diagnostics validate configuration and actual data, not HTTP status alone.
|
|
165
|
+
if [[ -n "${port:-}" ]]; then
|
|
166
|
+
( cd "$proj" && CAIRN_MEMORY_RERANK=1 CAIRN_MEMORY_RERANK_URL="http://127.0.0.1:$port/v1" \
|
|
167
|
+
CAIRN_MEMORY_RERANK_MODEL=test-ranker "$doctor" ) >"$tmp/reranker-ok" 2>&1 ||
|
|
168
|
+
fail "doctor rejected a valid reranking response"
|
|
169
|
+
grep -qF "[PASS] memory reranker accepted a synthetic ranking request" "$tmp/reranker-ok" ||
|
|
170
|
+
fail "doctor did not probe reranking"
|
|
171
|
+
if ( cd "$proj" && CAIRN_MEMORY_RERANK=1 "$doctor" ) >"$tmp/reranker-config" 2>&1; then
|
|
172
|
+
fail "doctor accepted an incomplete opted-in reranker"
|
|
173
|
+
fi
|
|
174
|
+
grep -qF "[FAIL] memory reranker configuration invalid" "$tmp/reranker-config" ||
|
|
175
|
+
fail "doctor omitted the reranker configuration diagnostic"
|
|
176
|
+
if ( cd "$proj" && CAIRN_MEMORY_RERANK=1 CAIRN_MEMORY_RERANK_URL="http://127.0.0.1:$port/v1" \
|
|
177
|
+
CAIRN_MEMORY_RERANK_MODEL=invalid-ranker "$doctor" ) >"$tmp/reranker-invalid" 2>&1; then
|
|
178
|
+
fail "doctor mistook fallback for a healthy reranker"
|
|
179
|
+
fi
|
|
180
|
+
grep -qF "[FAIL] memory reranker unavailable or returned invalid ranking data" "$tmp/reranker-invalid" ||
|
|
181
|
+
fail "doctor omitted the invalid ranking response diagnostic"
|
|
182
|
+
fi
|
|
183
|
+
|
|
156
184
|
# 5. Artifact doctor passes a valid store, repairs only derived state, and
|
|
157
185
|
# refuses authoritative full-record corruption without leaking stored values.
|
|
158
186
|
artifact_cli="$ROOT/mcp-memory-server/dist/artifact-cli.js"
|
|
@@ -88,10 +88,12 @@ try {
|
|
|
88
88
|
writeFileSync(join(invalidDerivedPack, `${"e".repeat(64)}.json`), "{\"corrupt\":true}\n");
|
|
89
89
|
process.env.CAIRN_PACK_BASE_DIR = packBase;
|
|
90
90
|
const inheritedLlmUrl = process.env.CAIRN_LLM_API_URL;
|
|
91
|
+
const inheritedRerank = process.env.CAIRN_MEMORY_RERANK;
|
|
91
92
|
const exitCodeBeforeDoctor = process.exitCode;
|
|
92
93
|
const doctorWrite = process.stdout.write.bind(process.stdout);
|
|
93
94
|
let doctorOutput = "";
|
|
94
95
|
delete process.env.CAIRN_LLM_API_URL;
|
|
96
|
+
delete process.env.CAIRN_MEMORY_RERANK;
|
|
95
97
|
process.stdout.write = ((chunk) => { doctorOutput += String(chunk); return true; });
|
|
96
98
|
try {
|
|
97
99
|
await runWindowsCommand({ command: "doctor", args: ["--repair"], root });
|
|
@@ -100,9 +102,12 @@ try {
|
|
|
100
102
|
process.exitCode = exitCodeBeforeDoctor;
|
|
101
103
|
if (inheritedLlmUrl === undefined) delete process.env.CAIRN_LLM_API_URL;
|
|
102
104
|
else process.env.CAIRN_LLM_API_URL = inheritedLlmUrl;
|
|
105
|
+
if (inheritedRerank === undefined) delete process.env.CAIRN_MEMORY_RERANK;
|
|
106
|
+
else process.env.CAIRN_MEMORY_RERANK = inheritedRerank;
|
|
103
107
|
}
|
|
104
108
|
assert.equal(existsSync(invalidDerivedPack), false, "native Windows doctor forwards safe context-pack cache repair");
|
|
105
109
|
assert.match(doctorOutput, /\[PASS\] context pack diagnostics passed/);
|
|
110
|
+
assert.match(doctorOutput, /\[SKIP\] memory reranker unset\/not 1/);
|
|
106
111
|
|
|
107
112
|
let containerArgs = [];
|
|
108
113
|
runNativeContainer(["stdio", "--image", "example/windows:1", "--volume", "windows-data"], root, (_engine, args) => { containerArgs = args; });
|
|
@@ -7,9 +7,10 @@ const ROOT = resolve(process.cwd());
|
|
|
7
7
|
const MINIMUM_SDK = [1, 32, 1];
|
|
8
8
|
const MINIMUM_NODE_SERVER = [2, 0, 5];
|
|
9
9
|
const TRANSITIVE_MINIMUMS = new Map([
|
|
10
|
-
["
|
|
10
|
+
["body-parser", [2, 4, 0]],
|
|
11
|
+
["fast-uri", [3, 1, 9]],
|
|
11
12
|
["hono", [4, 13, 13]],
|
|
12
|
-
["ip-address", [10, 7,
|
|
13
|
+
["ip-address", [10, 7, 4]],
|
|
13
14
|
["proxy-addr", [2, 0, 8]],
|
|
14
15
|
]);
|
|
15
16
|
const DIRECT_HONO_PACKAGES = new Set(["hono", "@hono/node-server"]);
|
|
@@ -526,6 +526,14 @@ async function doctorWindows(root, args) {
|
|
|
526
526
|
} catch { line("FAIL", `embedding endpoint request failed (${process.env.CAIRN_MEMORY_EMBEDDING_URL})`); }
|
|
527
527
|
}
|
|
528
528
|
} else line("SKIP", "embedding endpoint unset");
|
|
529
|
+
if (process.env.CAIRN_MEMORY_RERANK === "1") {
|
|
530
|
+
let status = 3;
|
|
531
|
+
try { status = await (await import("./probe-memory-reranker.mjs")).probeMemoryReranker(); } catch {}
|
|
532
|
+
line(status === 0 ? "PASS" : "FAIL", status === 0
|
|
533
|
+
? "memory reranker accepted a synthetic ranking request"
|
|
534
|
+
: status === 2 ? "memory reranker configuration invalid — set URL/model and a timeout within 100–10000 ms"
|
|
535
|
+
: "memory reranker unavailable or returned invalid ranking data — search falls back to its existing order");
|
|
536
|
+
} else line("SKIP", "memory reranker unset/not 1");
|
|
529
537
|
const provider = process.env.CAIRN_GIT_PROVIDER?.trim();
|
|
530
538
|
if (!provider || provider === "none") line("SKIP", "git provider unset/none; collaboration commands are off");
|
|
531
539
|
else if (["github", "gitlab", "codeberg", "forgejo"].includes(provider)) line("PASS", `git provider configured: ${provider}`);
|