@cairnkeep/cli 1.0.4 → 1.1.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/README.md +9 -2
- package/bin/cairn +44 -35
- package/docs/building-an-overlay.md +7 -0
- package/docs/domain-knowledge.md +12 -0
- package/docs/operating.md +31 -11
- package/docs/storage.md +163 -0
- package/mcp-memory-server/dist/index.js +84 -32
- package/mcp-memory-server/package.json +2 -1
- package/package.json +5 -3
- package/scripts/bootstrap.sh +8 -4
- package/scripts/doctor.sh +22 -7
- package/scripts/memory-store.sh +10 -9
- package/scripts/probe-memory-server.mjs +69 -0
- package/scripts/test-bootstrap-untracked.sh +4 -0
- package/scripts/test-cli-dispatch.sh +3 -0
- package/scripts/test-package-install.sh +32 -0
- package/scripts/test-shipped-perms.sh +7 -11
- package/scripts/test-uninstall.sh +3 -1
- package/scripts/uninstall.sh +16 -6
- package/templates/agentfs-gitignore.template +3 -0
- package/templates/env.example.template +7 -3
package/README.md
CHANGED
|
@@ -27,7 +27,8 @@ cairnkeep-org sibling project.
|
|
|
27
27
|
embedding-ranked search against any OpenAI-compatible endpoint.
|
|
28
28
|
- **`bin/cairn`** — the CLI. `cairn bootstrap [path]` scaffolds a project's
|
|
29
29
|
`.ai/` launchers + env; `cairn doctor` health-checks the configured pieces;
|
|
30
|
-
`cairn memory export|import` relocates the durable store between machines
|
|
30
|
+
`cairn memory export|import` relocates the durable store between machines
|
|
31
|
+
(`export` requires the optional `sqlite3` CLI);
|
|
31
32
|
`cairn audit-timer` installs the scheduled memory+wiki audit; `cairn uninstall`
|
|
32
33
|
reverses the install (backup-first, revertible).
|
|
33
34
|
- **`templates/`** — project scaffolding (generic launchers, env) plus the
|
|
@@ -60,6 +61,11 @@ operating layer (commands, agents, hooks) installed into your harness, and a
|
|
|
60
61
|
bootstrapped project. `cairn bootstrap` does only the last of these — the full
|
|
61
62
|
ordered walkthrough is in **[docs/operating.md](docs/operating.md)**.
|
|
62
63
|
|
|
64
|
+
This setup is local by default: the registered stdio server stores memory on
|
|
65
|
+
the user's computer. Cairnkeep never discovers or selects a remote host.
|
|
66
|
+
Remote HTTP storage requires an explicit server deployment and client URL; see
|
|
67
|
+
**[Memory storage and deployment](docs/storage.md)** before enabling it.
|
|
68
|
+
|
|
63
69
|
The short version for Claude Code:
|
|
64
70
|
|
|
65
71
|
```bash
|
|
@@ -106,7 +112,7 @@ unless a wrapper opts in:
|
|
|
106
112
|
|
|
107
113
|
**Contributor mode.** Working on a repo you don't own? `cairn bootstrap
|
|
108
114
|
--untracked /path/to/project` additionally writes the scaffolded paths
|
|
109
|
-
(`.ai/`, `.planning/`) into the repo's `.git/info/exclude`, so the workflow
|
|
115
|
+
(`.ai/`, `.planning/`, `.agentfs/`) into the repo's `.git/info/exclude`, so the workflow
|
|
110
116
|
files stay purely local: nothing to commit or push, invisible to every other
|
|
111
117
|
contributor, and no edit to the shared `.gitignore`. The trade-off is that
|
|
112
118
|
untracked planning state lives only on that clone — deleting the clone
|
|
@@ -141,6 +147,7 @@ Without an API key, search degrades gracefully to substring matching.
|
|
|
141
147
|
- **Optional document RAG** (`domain_knowledge_*` via AnythingLLM) — [docs/domain-knowledge.md](docs/domain-knowledge.md)
|
|
142
148
|
- **Building a private overlay** (wrap cairnkeep for your org/provider) — [docs/building-an-overlay.md](docs/building-an-overlay.md)
|
|
143
149
|
- **Full operating guide** — [docs/operating.md](docs/operating.md)
|
|
150
|
+
- **Memory storage and deployment** — [docs/storage.md](docs/storage.md)
|
|
144
151
|
- **Git providers** — [docs/git-providers.md](docs/git-providers.md)
|
|
145
152
|
- **Contributing** — [CONTRIBUTING.md](CONTRIBUTING.md)
|
|
146
153
|
- **Security policy** — [SECURITY.md](SECURITY.md)
|
package/bin/cairn
CHANGED
|
@@ -15,6 +15,43 @@ CAIRN_ROOT=$(cd -P "$(dirname "$SOURCE")/.." && pwd)
|
|
|
15
15
|
cmd="${1:-help}"
|
|
16
16
|
[[ $# -gt 0 ]] && shift
|
|
17
17
|
|
|
18
|
+
usage() {
|
|
19
|
+
cat <<'USAGE'
|
|
20
|
+
cairn — Cairnkeep CLI
|
|
21
|
+
|
|
22
|
+
Usage:
|
|
23
|
+
cairn bootstrap [--untracked] [path]
|
|
24
|
+
Scaffold launchers, planning files, and the private
|
|
25
|
+
project-memory ignore guard (default path: .).
|
|
26
|
+
--untracked keeps the scaffold out of git via the repo's
|
|
27
|
+
.git/info/exclude (contributor mode: nothing to commit,
|
|
28
|
+
invisible to other contributors, local-only).
|
|
29
|
+
cairn memory-server Run the memory MCP server (stdio). Register it with your
|
|
30
|
+
harness, e.g. claude mcp add cairn-memory -s user -- cairn memory-server.
|
|
31
|
+
cairn sync [--apply] [--live-root DIR]
|
|
32
|
+
Install/update the Claude Code operating layer (commands,
|
|
33
|
+
agents, hooks) into ~/.claude, or into DIR for a single
|
|
34
|
+
project (--live-root <project>/.claude).
|
|
35
|
+
cairn doctor Health-check the memory server and configured LLM /
|
|
36
|
+
embedding endpoints, git provider, and store.
|
|
37
|
+
cairn memory <path|export|import>
|
|
38
|
+
Relocate global-scope memory. `path` prints its location;
|
|
39
|
+
`export <file.tgz>` takes a WAL-safe snapshot (requires
|
|
40
|
+
sqlite3); `import <file.tgz>` restores it.
|
|
41
|
+
cairn audit-timer [--on-calendar SPEC] [--para-root PATH] [--render-only DIR]
|
|
42
|
+
Install (opt-in) a systemd user timer that runs the
|
|
43
|
+
memory+wiki audit on a schedule; prints a cron alternative.
|
|
44
|
+
cairn uninstall [--dry-run] [--yes] [--purge-memory] [--live-root DIR] [PROJECT ...]
|
|
45
|
+
Reverse what cairnkeep installed/modified (operating
|
|
46
|
+
layer, hook registrations, MCP registration, audit
|
|
47
|
+
timer, and any given projects' bootstrap scaffold).
|
|
48
|
+
Backs everything up first with a revert.sh; keeps the
|
|
49
|
+
durable memory store unless --purge-memory is given.
|
|
50
|
+
cairn version Print version
|
|
51
|
+
cairn help Show this help
|
|
52
|
+
USAGE
|
|
53
|
+
}
|
|
54
|
+
|
|
18
55
|
case "$cmd" in
|
|
19
56
|
bootstrap)
|
|
20
57
|
exec "$CAIRN_ROOT/scripts/bootstrap.sh" "$@"
|
|
@@ -43,40 +80,12 @@ case "$cmd" in
|
|
|
43
80
|
v=$(sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$CAIRN_ROOT/package.json" 2>/dev/null | head -1)
|
|
44
81
|
echo "cairnkeep ${v:-unknown}"
|
|
45
82
|
;;
|
|
46
|
-
help | --help | -h
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
--untracked keeps the scaffold out of git via the repo's
|
|
54
|
-
.git/info/exclude (contributor mode: nothing to commit,
|
|
55
|
-
invisible to other contributors, local-only).
|
|
56
|
-
cairn memory-server Run the memory MCP server (stdio). Register it with your
|
|
57
|
-
harness, e.g. claude mcp add cairn-memory -s user -- cairn memory-server.
|
|
58
|
-
cairn sync [--apply] [--live-root DIR]
|
|
59
|
-
Install/update the Claude Code operating layer (commands,
|
|
60
|
-
agents, hooks) into ~/.claude, or into DIR for a single
|
|
61
|
-
project (--live-root <project>/.claude).
|
|
62
|
-
cairn doctor Health-check the configured pieces (memory server, LLM /
|
|
63
|
-
embedding endpoints, git provider, store); skips what is
|
|
64
|
-
unconfigured, fails only on a configured-but-unreachable dep.
|
|
65
|
-
cairn memory <path|export|import>
|
|
66
|
-
Relocate the durable memory store. `path` prints its
|
|
67
|
-
location; `export <file.tgz>` takes a WAL-safe snapshot;
|
|
68
|
-
`import <file.tgz>` restores it on another machine.
|
|
69
|
-
cairn audit-timer [--on-calendar SPEC] [--para-root PATH] [--render-only DIR]
|
|
70
|
-
Install (opt-in) a systemd user timer that runs the
|
|
71
|
-
memory+wiki audit on a schedule; prints a cron alternative.
|
|
72
|
-
cairn uninstall [--dry-run] [--yes] [--purge-memory] [--live-root DIR] [PROJECT ...]
|
|
73
|
-
Reverse what cairnkeep installed/modified (operating
|
|
74
|
-
layer, hook registrations, MCP registration, audit
|
|
75
|
-
timer, and any given projects' bootstrap scaffold).
|
|
76
|
-
Backs everything up first with a revert.sh; keeps the
|
|
77
|
-
durable memory store unless --purge-memory is given.
|
|
78
|
-
cairn version Print version
|
|
79
|
-
cairn help Show this help
|
|
80
|
-
USAGE
|
|
83
|
+
help | --help | -h)
|
|
84
|
+
usage
|
|
85
|
+
;;
|
|
86
|
+
*)
|
|
87
|
+
echo "Unknown command: $cmd" >&2
|
|
88
|
+
usage >&2
|
|
89
|
+
exit 2
|
|
81
90
|
;;
|
|
82
91
|
esac
|
|
@@ -107,6 +107,13 @@ All optional, all env-gated — a wrapper turns them on by setting the env:
|
|
|
107
107
|
Point the RAG sync script at your own copy (`CAIRN_ANYTHINGLLM_SYNC_SCRIPT`) so the
|
|
108
108
|
integration scripts live in your overlay, not the core.
|
|
109
109
|
|
|
110
|
+
For a fleet that shares one trusted HTTP service, have the overlay generate
|
|
111
|
+
project-local Claude Code and OpenCode MCP entries with `X-Cairn-Project`,
|
|
112
|
+
`X-Cairn-Scopes`, and `X-Cairn-AnythingLLM-Workspaces`. This preserves local
|
|
113
|
+
project isolation and workspace defaults while keeping the databases and RAG
|
|
114
|
+
credentials on the server host. Keep bearer tokens in environment-backed
|
|
115
|
+
secret storage, not generated project files.
|
|
116
|
+
|
|
110
117
|
## Checklist
|
|
111
118
|
|
|
112
119
|
- [ ] cairnkeep pinned to a version; no core files copied or patched
|
package/docs/domain-knowledge.md
CHANGED
|
@@ -45,6 +45,18 @@ first of `.agent/memory.json`, `.opencode/memory.json`, `.claude/memory.json`, o
|
|
|
45
45
|
configured workspace that is not `engineering-patterns` (the conventional shared
|
|
46
46
|
workspace). With no workspace given and none configured, it errors.
|
|
47
47
|
|
|
48
|
+
In local stdio mode, that config is read from the client project's working
|
|
49
|
+
directory. In remote HTTP mode, the server cannot read files from the client
|
|
50
|
+
PC. Configure `ANYTHINGLLM_BASE_URL`, `ANYTHINGLLM_API_KEY`, and any sync script
|
|
51
|
+
on the HTTP server host, then send the project workspaces in the
|
|
52
|
+
`X-Cairn-AnythingLLM-Workspaces` session header. See
|
|
53
|
+
[Memory storage and deployment](storage.md#per-project-remote-sessions).
|
|
54
|
+
|
|
55
|
+
The HTTP server performs queries and runs the sync script on its own host. A
|
|
56
|
+
remote sync script must therefore obtain project documents from a server-side
|
|
57
|
+
checkout, object store, or git host; it cannot read an arbitrary client PC's
|
|
58
|
+
working tree.
|
|
59
|
+
|
|
48
60
|
## Setup (query)
|
|
49
61
|
|
|
50
62
|
```bash
|
package/docs/operating.md
CHANGED
|
@@ -18,12 +18,19 @@ installs. This guide covers all three in order.
|
|
|
18
18
|
|
|
19
19
|
- Node.js (for the memory server) and a supported harness: Claude Code or
|
|
20
20
|
OpenCode.
|
|
21
|
+
- Optional: the `sqlite3` CLI for `cairn memory export`. Runtime memory and
|
|
22
|
+
`cairn memory import` do not require it.
|
|
21
23
|
- Optional: an OpenAI-compatible LLM endpoint for memory extraction and
|
|
22
24
|
embedding-ranked search. Without it, memory search degrades to substring
|
|
23
25
|
matching — everything else still works.
|
|
24
26
|
|
|
25
27
|
## Setup order (Claude Code)
|
|
26
28
|
|
|
29
|
+
The commands below install the default local topology: `cairn-memory` runs as a
|
|
30
|
+
stdio child process and writes SQLite databases on this computer. Nothing in
|
|
31
|
+
the installer discovers or selects a remote host. Read
|
|
32
|
+
[Memory storage and deployment](storage.md) before choosing remote HTTP mode.
|
|
33
|
+
|
|
27
34
|
**Via npm** (simplest — everything is on `PATH` as `cairn`):
|
|
28
35
|
|
|
29
36
|
```bash
|
|
@@ -52,7 +59,7 @@ claude mcp add cairn-memory -s user -- node "$PWD/mcp-memory-server/dist/index.j
|
|
|
52
59
|
scripts/sync-claude-assets.sh --apply
|
|
53
60
|
|
|
54
61
|
# 4. Scaffold a target project
|
|
55
|
-
cairn bootstrap /path/to/project
|
|
62
|
+
bin/cairn bootstrap /path/to/project
|
|
56
63
|
cp /path/to/project/.ai/env.example /path/to/project/.ai/.env
|
|
57
64
|
$EDITOR /path/to/project/.ai/.env # see "Configuration" below
|
|
58
65
|
# Not the repo's owner? Add --untracked to keep the scaffold out of git
|
|
@@ -139,7 +146,7 @@ vendor or host.
|
|
|
139
146
|
| `CAIRN_LLM_EXTRACTION_MODEL` | Chat model for `memory-capture` extraction |
|
|
140
147
|
| `CAIRN_MEMORY_EMBEDDING_URL` | Embeddings endpoint (falls back to `CAIRN_LLM_API_URL`) |
|
|
141
148
|
| `CAIRN_MEMORY_EMBEDDING_MODEL` | Embedding model name (required for semantic search) |
|
|
142
|
-
| `CAIRN_AGENTFS_BASE_DIR` |
|
|
149
|
+
| `CAIRN_AGENTFS_BASE_DIR` | Server-side base dir for named/global memory scopes (default `~/.cairnkeep`); it does not affect `project` scope |
|
|
143
150
|
| `CAIRN_GIT_PROVIDER` | Git host for collaboration commands: `github`\|`gitlab`\|`codeberg`\|`forgejo`\|`none`. See [git-providers.md](git-providers.md) |
|
|
144
151
|
| `CAIRN_ROUTE_ENDPOINT` | Base URL of an already-running token-miser routing/tiering proxy (unset → the `route_check` tool is inert) |
|
|
145
152
|
| `CAIRN_EXPLORE_BINARY` | Absolute path to the `token_miser` binary used by `context_explore` (unset → the tool throws at call time) |
|
|
@@ -243,10 +250,11 @@ Claude-Code-only path this milestone — there is no OpenCode parity plugin.
|
|
|
243
250
|
|
|
244
251
|
### HTTP transport (opt-in, network-facing)
|
|
245
252
|
|
|
246
|
-
The server runs over stdio by default. Setting `MCP_HTTP_PORT` switches
|
|
247
|
-
streamable HTTP transport so one long-lived process can serve many
|
|
248
|
-
|
|
249
|
-
|
|
253
|
+
The server runs locally over stdio by default. Setting `MCP_HTTP_PORT` switches
|
|
254
|
+
it to a streamable HTTP transport so one long-lived process can serve many
|
|
255
|
+
clients within one trusted storage domain. The databases then live on the HTTP
|
|
256
|
+
server host. Because that exposes every memory tool over the network, HTTP mode
|
|
257
|
+
is guarded and **fails closed**:
|
|
250
258
|
|
|
251
259
|
| Variable | Purpose |
|
|
252
260
|
|---|---|
|
|
@@ -258,7 +266,12 @@ because that exposes every memory tool over the network, HTTP mode is guarded an
|
|
|
258
266
|
|
|
259
267
|
Requests without a valid bearer token get `401`; requests with an unexpected
|
|
260
268
|
`Host` header get `403`. Keep HTTP mode bound to `127.0.0.1` unless you have a
|
|
261
|
-
specific reason to expose it, and use a long random token.
|
|
269
|
+
specific reason to expose it, and use a long random token. HTTP mode has no
|
|
270
|
+
per-user ACL or tenant isolation. Clients may bind sessions to separate project
|
|
271
|
+
databases with validated `X-Cairn-Project` routing metadata, but that metadata
|
|
272
|
+
is not an authorization boundary. See [Memory storage and deployment](storage.md)
|
|
273
|
+
for the placement rules, client registration, TLS requirements, project headers,
|
|
274
|
+
and backup boundaries.
|
|
262
275
|
|
|
263
276
|
## The workflow
|
|
264
277
|
|
|
@@ -335,9 +348,11 @@ its settings file and exports `CAIRN_EXTRA_SETTINGS` — no change to the launch
|
|
|
335
348
|
|
|
336
349
|
### `cairn doctor`
|
|
337
350
|
|
|
338
|
-
Health-checks the
|
|
339
|
-
env
|
|
340
|
-
|
|
351
|
+
Health-checks the bundled local memory server with a real MCP stdio handshake,
|
|
352
|
+
then checks `./.ai/.env` (or the current environment). It does not inspect a
|
|
353
|
+
harness's remote HTTP registration. Unconfigured optional dependencies are
|
|
354
|
+
skipped; it exits non-zero when the local server probe fails or a configured
|
|
355
|
+
dependency (LLM/embedding endpoint, writable store) is unreachable.
|
|
341
356
|
|
|
342
357
|
```bash
|
|
343
358
|
cd /path/to/project && cairn doctor
|
|
@@ -345,7 +360,7 @@ cd /path/to/project && cairn doctor
|
|
|
345
360
|
|
|
346
361
|
### `cairn memory export|import|path`
|
|
347
362
|
|
|
348
|
-
Relocate
|
|
363
|
+
Relocate named/global memory (one SQLite `.db` per scope under
|
|
349
364
|
`CAIRN_AGENTFS_BASE_DIR`) between machines or backends:
|
|
350
365
|
|
|
351
366
|
```bash
|
|
@@ -354,6 +369,11 @@ cairn memory export store.tgz # WAL-safe snapshot of every scope db
|
|
|
354
369
|
cairn memory import store.tgz # restore on another machine (backs up existing)
|
|
355
370
|
```
|
|
356
371
|
|
|
372
|
+
`cairn memory export` requires the `sqlite3` CLI so it can use SQLite's online
|
|
373
|
+
backup operation and produce a consistent snapshot while WAL mode is active.
|
|
374
|
+
It does not include project memory at `<project>/.agentfs/project.db`; see
|
|
375
|
+
[Memory storage and deployment](storage.md) for project backup instructions.
|
|
376
|
+
|
|
357
377
|
### `cairn audit-timer`
|
|
358
378
|
|
|
359
379
|
`memory-wiki-audit.sh` is the deterministic invalidation backstop meant to run on
|
package/docs/storage.md
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# Memory storage and deployment
|
|
2
|
+
|
|
3
|
+
## The placement rule
|
|
4
|
+
|
|
5
|
+
Cairnkeep stores memory on the filesystem of the machine running the
|
|
6
|
+
`cairn-memory` server process. It does not discover a cloud service, VPS, or
|
|
7
|
+
shared host, and installing the npm package does not contact one.
|
|
8
|
+
|
|
9
|
+
The standard registration is local:
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
claude mcp add cairn-memory -s user -- cairn memory-server
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
This starts `cairn memory-server` as a local stdio child process. With that
|
|
16
|
+
registration, memory remains on that computer.
|
|
17
|
+
|
|
18
|
+
| Scope | Database path |
|
|
19
|
+
|---|---|
|
|
20
|
+
| `project` | `<server working directory>/.agentfs/project.db` |
|
|
21
|
+
| Any named/global scope, such as `identity` or `work` | `${CAIRN_AGENTFS_BASE_DIR:-~/.cairnkeep}/<scope>.db` |
|
|
22
|
+
|
|
23
|
+
For the local launchers, the server working directory is normally the project
|
|
24
|
+
root. Bootstrap installs `.agentfs/.gitignore` so private project memory is not
|
|
25
|
+
accidentally committed. SQLite may also create `-wal` and `-shm` sidecar files.
|
|
26
|
+
|
|
27
|
+
`CAIRN_LLM_API_URL` and `CAIRN_MEMORY_EMBEDDING_URL` select optional model
|
|
28
|
+
services used to process/search memory. They do not change where the SQLite
|
|
29
|
+
databases are stored. Git-provider and routing configuration do not change
|
|
30
|
+
storage either.
|
|
31
|
+
|
|
32
|
+
## Remote HTTP mode
|
|
33
|
+
|
|
34
|
+
Remote storage is explicit. An operator starts Cairnkeep in HTTP mode on a
|
|
35
|
+
server, configures storage in that server process's environment, and registers
|
|
36
|
+
the resulting URL in each client harness. For example:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
# On the server host. Put TLS in front of this listener before remote use.
|
|
40
|
+
CAIRN_AGENTFS_BASE_DIR=/var/lib/cairnkeep \
|
|
41
|
+
CAIRN_MEMORY_HTTP_TOKEN="$(openssl rand -hex 32)" \
|
|
42
|
+
CAIRN_MEMORY_HTTP_ALLOWED_HOSTS=memory.example.com \
|
|
43
|
+
MCP_HTTP_HOST=127.0.0.1 MCP_HTTP_PORT=7801 \
|
|
44
|
+
cairn memory-server
|
|
45
|
+
|
|
46
|
+
# On a client. CAIRN_MEMORY_HTTP_TOKEN must contain the server's token.
|
|
47
|
+
claude mcp add --transport http -s user \
|
|
48
|
+
--header "Authorization: Bearer $CAIRN_MEMORY_HTTP_TOKEN" \
|
|
49
|
+
cairn-memory https://memory.example.com/mcp
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
With this topology, the databases are on the server host, not the client PC.
|
|
53
|
+
Setting `CAIRN_AGENTFS_BASE_DIR` on the client does not redirect a remote
|
|
54
|
+
server; set it in the server service environment.
|
|
55
|
+
|
|
56
|
+
### Per-project remote sessions
|
|
57
|
+
|
|
58
|
+
Remote clients can bind an MCP session to a stable project identity and send
|
|
59
|
+
the memory configuration that a local server would read from `memory.json`:
|
|
60
|
+
|
|
61
|
+
| Header | Purpose |
|
|
62
|
+
|---|---|
|
|
63
|
+
| `X-Cairn-Project` | Kebab-case project identity, up to 64 characters |
|
|
64
|
+
| `X-Cairn-Scopes` | Comma-separated scopes used when a tool reads scope `all` |
|
|
65
|
+
| `X-Cairn-AnythingLLM-Workspaces` | Comma-separated AnythingLLM workspace slugs; the first non-`engineering-patterns` workspace is the default |
|
|
66
|
+
|
|
67
|
+
When `X-Cairn-Project` is present, `project` scope is stored at
|
|
68
|
+
`${CAIRN_AGENTFS_BASE_DIR}/projects/<project-id>.db`. Sessions with different
|
|
69
|
+
project identities therefore do not share project memory. Without the header,
|
|
70
|
+
HTTP mode retains the legacy behavior and resolves `project` from the server
|
|
71
|
+
working directory.
|
|
72
|
+
|
|
73
|
+
These headers are session routing metadata, not authorization. The bearer
|
|
74
|
+
token still grants access to the entire server, including the ability to choose
|
|
75
|
+
another valid project identity. Use separate server instances for separate
|
|
76
|
+
trust domains.
|
|
77
|
+
|
|
78
|
+
HTTP mode is one trusted storage domain:
|
|
79
|
+
|
|
80
|
+
- One bearer token grants access to every exposed memory tool.
|
|
81
|
+
- There is no per-user ACL, tenant isolation, or client-specific filesystem.
|
|
82
|
+
- Sessions without `X-Cairn-Project` share the project database resolved from
|
|
83
|
+
the server process's working directory.
|
|
84
|
+
- Use one server instance per isolation boundary. Do not offer one instance to
|
|
85
|
+
mutually untrusted users.
|
|
86
|
+
- Keep the Cairnkeep listener on loopback behind a TLS reverse proxy, or use an
|
|
87
|
+
encrypted private network. Do not expose its raw HTTP listener publicly.
|
|
88
|
+
|
|
89
|
+
## Inspecting and moving memory
|
|
90
|
+
|
|
91
|
+
For a local server, these commands report and move named/global scopes only:
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
cairn memory path
|
|
95
|
+
cairn memory export global-memory.tgz
|
|
96
|
+
cairn memory import global-memory.tgz
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
`cairn memory path` reports the local process's global-scope directory; it
|
|
100
|
+
cannot inspect a harness's remote HTTP registration. Export uses SQLite's
|
|
101
|
+
online backup operation and requires the `sqlite3` CLI.
|
|
102
|
+
|
|
103
|
+
Project memory is separate at `<project>/.agentfs/project.db` and is not
|
|
104
|
+
included in `cairn memory export`. Back it up while the server is stopped, or
|
|
105
|
+
take an online snapshot with SQLite:
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
sqlite3 /path/to/project/.agentfs/project.db \
|
|
109
|
+
".backup '/safe/path/project-memory.db'"
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Treat every database and export archive as sensitive. They may contain source
|
|
113
|
+
paths, decisions, incident details, and other project context.
|
|
114
|
+
|
|
115
|
+
## Automating trusted personal clients
|
|
116
|
+
|
|
117
|
+
Keep fleet-specific values in a private dotfiles repository or secret manager,
|
|
118
|
+
not in Cairnkeep. A personal bootstrap can install the public package and then
|
|
119
|
+
perform the explicit remote registration:
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
# Populate these from a private secret manager on each trusted PC.
|
|
123
|
+
export CAIRN_MEMORY_REMOTE_URL=https://memory.example.com/mcp
|
|
124
|
+
export CAIRN_MEMORY_HTTP_TOKEN=replace-from-secret-manager
|
|
125
|
+
|
|
126
|
+
npm install -g @cairnkeep/cli
|
|
127
|
+
|
|
128
|
+
claude mcp remove cairn-memory -s user 2>/dev/null || true
|
|
129
|
+
claude mcp add --transport http -s user \
|
|
130
|
+
cairn-memory "$CAIRN_MEMORY_REMOTE_URL" \
|
|
131
|
+
--header "Authorization: Bearer $CAIRN_MEMORY_HTTP_TOKEN"
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
OpenCode is configured separately in its per-user `opencode.json`. It supports
|
|
135
|
+
environment references in remote MCP headers, so the token does not need to be
|
|
136
|
+
written literally into the JSON:
|
|
137
|
+
|
|
138
|
+
```json
|
|
139
|
+
{
|
|
140
|
+
"mcp": {
|
|
141
|
+
"cairn-memory": {
|
|
142
|
+
"type": "remote",
|
|
143
|
+
"url": "https://memory.example.com/mcp",
|
|
144
|
+
"oauth": false,
|
|
145
|
+
"headers": {
|
|
146
|
+
"Authorization": "Bearer {env:CAIRN_MEMORY_HTTP_TOKEN}"
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
Ensure the secret-manager bootstrap exports `CAIRN_MEMORY_HTTP_TOKEN` before
|
|
154
|
+
OpenCode starts. Preserve any other keys already present in `opencode.json`
|
|
155
|
+
rather than replacing the whole file. The URL, token, private host names, and
|
|
156
|
+
device-specific configuration must never be committed to this public project.
|
|
157
|
+
|
|
158
|
+
For distinct project memory, install a project-local MCP entry with the same
|
|
159
|
+
URL and authorization header plus the three routing headers. Claude Code
|
|
160
|
+
expands `${VAR}` references in project `.mcp.json` URL and header values;
|
|
161
|
+
OpenCode uses `{env:VAR}` references. A private overlay should generate and
|
|
162
|
+
merge these files so secrets remain in the process environment rather than the
|
|
163
|
+
repository.
|
|
@@ -13,6 +13,8 @@ import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/
|
|
|
13
13
|
import { z } from "zod";
|
|
14
14
|
import { EmbeddingCache, cosineSimilarity, embedTexts, getEmbeddingConfig, hashText, } from "./embeddings.js";
|
|
15
15
|
import { computeRepoState, exploreCacheKey, normalizeExploreQuery, readExploreCache, writeExploreCache, } from "./explore-cache.js";
|
|
16
|
+
class ClientContextError extends Error {
|
|
17
|
+
}
|
|
16
18
|
const moduleDir = dirname(fileURLToPath(import.meta.url));
|
|
17
19
|
const infraRoot = resolve(moduleDir, "..", "..");
|
|
18
20
|
// Path to the domain-knowledge sync script. Defaults to a script bundled next to
|
|
@@ -22,6 +24,8 @@ const anythingllmSyncScript = process.env.CAIRN_ANYTHINGLLM_SYNC_SCRIPT
|
|
|
22
24
|
? resolve(expandHome(process.env.CAIRN_ANYTHINGLLM_SYNC_SCRIPT))
|
|
23
25
|
: join(infraRoot, "anythingllm", "sync_to_anythingllm.py");
|
|
24
26
|
const HISTORY_NAMESPACE = "__history__";
|
|
27
|
+
const PROJECT_ID_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
28
|
+
const WORKSPACE_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
25
29
|
function expandHome(value) {
|
|
26
30
|
if (value === "~") {
|
|
27
31
|
return homedir();
|
|
@@ -86,9 +90,44 @@ function assertSafeScope(scope) {
|
|
|
86
90
|
throw new Error(`Invalid scope "${scope}": must be kebab-case (^[a-z0-9][a-z0-9-]*$), "project", or "all".`);
|
|
87
91
|
}
|
|
88
92
|
}
|
|
89
|
-
function
|
|
93
|
+
function parseHeaderList(headers, name, validate) {
|
|
94
|
+
const raw = headers.get(name)?.trim();
|
|
95
|
+
if (!raw) {
|
|
96
|
+
return undefined;
|
|
97
|
+
}
|
|
98
|
+
if (raw.length > 4096) {
|
|
99
|
+
throw new ClientContextError(`${name} is too long.`);
|
|
100
|
+
}
|
|
101
|
+
const values = Array.from(new Set(raw.split(",").map((value) => value.trim()).filter(Boolean)));
|
|
102
|
+
if (values.length > 32 || values.some((value) => !validate(value))) {
|
|
103
|
+
throw new ClientContextError(`${name} contains an invalid value.`);
|
|
104
|
+
}
|
|
105
|
+
return values;
|
|
106
|
+
}
|
|
107
|
+
function parseServerContext(headers) {
|
|
108
|
+
const rawProjectId = headers.get("x-cairn-project")?.trim();
|
|
109
|
+
if (rawProjectId && !PROJECT_ID_PATTERN.test(rawProjectId)) {
|
|
110
|
+
throw new ClientContextError("X-Cairn-Project must be a kebab-case identifier of at most 64 characters.");
|
|
111
|
+
}
|
|
112
|
+
const scopes = parseHeaderList(headers, "X-Cairn-Scopes", (value) => value !== "all" && (value === "project" || SCOPE_PATTERN.test(value)));
|
|
113
|
+
const anythingllmWorkspaces = parseHeaderList(headers, "X-Cairn-AnythingLLM-Workspaces", (value) => WORKSPACE_PATTERN.test(value));
|
|
114
|
+
const memoryConfig = scopes || anythingllmWorkspaces
|
|
115
|
+
? {
|
|
116
|
+
scopes: scopes ?? ["identity"],
|
|
117
|
+
anythingllm_workspaces: anythingllmWorkspaces ?? [],
|
|
118
|
+
}
|
|
119
|
+
: undefined;
|
|
120
|
+
return {
|
|
121
|
+
projectId: rawProjectId || undefined,
|
|
122
|
+
memoryConfig,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
function resolveScopePath(scope, options = {}) {
|
|
90
126
|
if (scope === "project") {
|
|
91
|
-
|
|
127
|
+
if (options.projectId) {
|
|
128
|
+
return resolve(getBaseDir(), "projects", `${options.projectId}.db`);
|
|
129
|
+
}
|
|
130
|
+
return resolve(options.cwd ?? process.cwd(), ".agentfs", "project.db");
|
|
92
131
|
}
|
|
93
132
|
// "all" is a read-only virtual scope: memory_read/memory_search fan it out
|
|
94
133
|
// over the configured scopes (via getSearchScopes) before resolving. It has
|
|
@@ -123,8 +162,8 @@ function normalizeValue(value) {
|
|
|
123
162
|
}
|
|
124
163
|
return JSON.stringify(value);
|
|
125
164
|
}
|
|
126
|
-
async function openScope(scope, create,
|
|
127
|
-
const dbPath = resolveScopePath(scope,
|
|
165
|
+
async function openScope(scope, create, options = {}) {
|
|
166
|
+
const dbPath = resolveScopePath(scope, options);
|
|
128
167
|
if (!create && !existsSync(dbPath)) {
|
|
129
168
|
return null;
|
|
130
169
|
}
|
|
@@ -149,7 +188,7 @@ function visibleEntries(entries, includeHistory) {
|
|
|
149
188
|
return entries.filter((entry) => !isHistoryKey(entry.key));
|
|
150
189
|
}
|
|
151
190
|
async function listEntries(scope, prefix = "", options = {}) {
|
|
152
|
-
const agent = await openScope(scope, false, options
|
|
191
|
+
const agent = await openScope(scope, false, options);
|
|
153
192
|
if (!agent) {
|
|
154
193
|
return [];
|
|
155
194
|
}
|
|
@@ -165,8 +204,8 @@ async function listEntries(scope, prefix = "", options = {}) {
|
|
|
165
204
|
await agent.close();
|
|
166
205
|
}
|
|
167
206
|
}
|
|
168
|
-
async function readKey(scope, key) {
|
|
169
|
-
const agent = await openScope(scope, false);
|
|
207
|
+
async function readKey(scope, key, options = {}) {
|
|
208
|
+
const agent = await openScope(scope, false, options);
|
|
170
209
|
if (!agent) {
|
|
171
210
|
return [];
|
|
172
211
|
}
|
|
@@ -385,14 +424,16 @@ async function callAnythingLLM(workspace, query) {
|
|
|
385
424
|
function entryText(entry) {
|
|
386
425
|
return entry.value ? `${entry.key}\n${entry.value}` : entry.key;
|
|
387
426
|
}
|
|
388
|
-
function embeddingCachePath(scope) {
|
|
389
|
-
return join(getBaseDir(), ".embeddings", `${hashText(resolveScopePath(scope))}.json`);
|
|
427
|
+
function embeddingCachePath(scope, projectId) {
|
|
428
|
+
return join(getBaseDir(), ".embeddings", `${hashText(resolveScopePath(scope, { projectId }))}.json`);
|
|
390
429
|
}
|
|
391
|
-
async function semanticSearch(scope, query, topK, minScore) {
|
|
392
|
-
const config = getMemoryConfig();
|
|
430
|
+
async function semanticSearch(scope, query, topK, minScore, config = getMemoryConfig(), projectId) {
|
|
393
431
|
const scopes = getSearchScopes(scope, config);
|
|
394
432
|
const embeddingConfig = getEmbeddingConfig();
|
|
395
|
-
const perScopeEntries = await Promise.all(scopes.map(async (candidate) => ({
|
|
433
|
+
const perScopeEntries = await Promise.all(scopes.map(async (candidate) => ({
|
|
434
|
+
scope: candidate,
|
|
435
|
+
entries: await listEntries(candidate, "", { projectId }),
|
|
436
|
+
})));
|
|
396
437
|
const allEntries = perScopeEntries.flatMap((group) => group.entries);
|
|
397
438
|
if (allEntries.length === 0) {
|
|
398
439
|
return { results: [], mode: embeddingConfig ? "semantic" : "substring" };
|
|
@@ -410,7 +451,7 @@ async function semanticSearch(scope, query, topK, minScore) {
|
|
|
410
451
|
const caches = [];
|
|
411
452
|
const vectors = new Map();
|
|
412
453
|
for (const group of perScopeEntries) {
|
|
413
|
-
const cache = new EmbeddingCache(embeddingCachePath(group.scope), embeddingConfig.model);
|
|
454
|
+
const cache = new EmbeddingCache(embeddingCachePath(group.scope, projectId), embeddingConfig.model);
|
|
414
455
|
caches.push(cache);
|
|
415
456
|
const misses = [];
|
|
416
457
|
for (const entry of group.entries) {
|
|
@@ -669,9 +710,11 @@ function gitToplevel(cwd) {
|
|
|
669
710
|
// Factory: each MCP client/session needs its own McpServer instance (the SDK
|
|
670
711
|
// only allows one connected transport per server). All instances share the
|
|
671
712
|
// module-level helpers + AgentFS below. Enables a single long-lived process to
|
|
672
|
-
// serve many concurrent clients
|
|
673
|
-
function createMemoryServer() {
|
|
713
|
+
// serve many concurrent clients within one trusted server-side storage domain.
|
|
714
|
+
function createMemoryServer(context = {}) {
|
|
674
715
|
const server = new McpServer({ name: "cairn-memory", version: "0.1.0" });
|
|
716
|
+
const memoryConfig = () => context.memoryConfig ?? getMemoryConfig();
|
|
717
|
+
const scopeOptions = { projectId: context.projectId };
|
|
675
718
|
server.registerTool("memory_read", {
|
|
676
719
|
description: "Read an exact key or search memory entries across AgentFS scopes.",
|
|
677
720
|
// Plain object schema — a .refine() wrapper (ZodEffects) makes the SDK
|
|
@@ -689,11 +732,11 @@ function createMemoryServer() {
|
|
|
689
732
|
if (Boolean(key) === Boolean(query)) {
|
|
690
733
|
throw new Error("Provide exactly one of key or query.");
|
|
691
734
|
}
|
|
692
|
-
const config =
|
|
735
|
+
const config = memoryConfig();
|
|
693
736
|
const scopes = getSearchScopes(scope, config);
|
|
694
737
|
const results = key
|
|
695
|
-
? (await Promise.all(scopes.map((candidate) => readKey(candidate, key)))).flat()
|
|
696
|
-
: searchEntries((await Promise.all(scopes.map((candidate) => listEntries(candidate)))).flat(), query ?? "");
|
|
738
|
+
? (await Promise.all(scopes.map((candidate) => readKey(candidate, key, scopeOptions)))).flat()
|
|
739
|
+
: searchEntries((await Promise.all(scopes.map((candidate) => listEntries(candidate, "", scopeOptions)))).flat(), query ?? "");
|
|
697
740
|
const sorted = results.sort((left, right) => {
|
|
698
741
|
return `${left.scope}:${left.key}`.localeCompare(`${right.scope}:${right.key}`);
|
|
699
742
|
});
|
|
@@ -722,7 +765,7 @@ function createMemoryServer() {
|
|
|
722
765
|
// disambiguated (rename keys, memory_history to recover).
|
|
723
766
|
const collisions = [];
|
|
724
767
|
for (const target of targets) {
|
|
725
|
-
const agent = await openScope(target, true);
|
|
768
|
+
const agent = await openScope(target, true, scopeOptions);
|
|
726
769
|
if (!agent) {
|
|
727
770
|
throw new Error(`Unable to open scope ${target}.`);
|
|
728
771
|
}
|
|
@@ -762,7 +805,7 @@ function createMemoryServer() {
|
|
|
762
805
|
idempotentHint: true,
|
|
763
806
|
},
|
|
764
807
|
}, async ({ scope, prefix }) => {
|
|
765
|
-
const entries = await listEntries(scope, prefix ?? "");
|
|
808
|
+
const entries = await listEntries(scope, prefix ?? "", scopeOptions);
|
|
766
809
|
const keys = entries.map((entry) => entry.key).sort();
|
|
767
810
|
return {
|
|
768
811
|
content: [{ type: "text", text: asToolText(keys) }],
|
|
@@ -776,7 +819,7 @@ function createMemoryServer() {
|
|
|
776
819
|
key: z.string().min(1),
|
|
777
820
|
}),
|
|
778
821
|
}, async ({ scope, key }) => {
|
|
779
|
-
const agent = await openScope(scope, false);
|
|
822
|
+
const agent = await openScope(scope, false, scopeOptions);
|
|
780
823
|
if (agent) {
|
|
781
824
|
try {
|
|
782
825
|
await agent.kv.delete(key);
|
|
@@ -802,7 +845,7 @@ function createMemoryServer() {
|
|
|
802
845
|
readOnlyHint: true,
|
|
803
846
|
},
|
|
804
847
|
}, async ({ scope, query, top_k, min_score }) => {
|
|
805
|
-
const { results, mode, model } = await semanticSearch(scope, query, top_k ?? 8, min_score ?? 0);
|
|
848
|
+
const { results, mode, model } = await semanticSearch(scope, query, top_k ?? 8, min_score ?? 0, memoryConfig(), context.projectId);
|
|
806
849
|
const payload = { mode, model, count: results.length, results };
|
|
807
850
|
return {
|
|
808
851
|
content: [{ type: "text", text: asToolText(payload) }],
|
|
@@ -842,7 +885,7 @@ function createMemoryServer() {
|
|
|
842
885
|
if (isHistoryKey(key)) {
|
|
843
886
|
throw new Error(`Keys under ${HISTORY_NAMESPACE}/ are reserved for memory history.`);
|
|
844
887
|
}
|
|
845
|
-
const agent = await openScope(scope, true);
|
|
888
|
+
const agent = await openScope(scope, true, scopeOptions);
|
|
846
889
|
if (!agent) {
|
|
847
890
|
throw new Error(`Unable to open scope ${scope}.`);
|
|
848
891
|
}
|
|
@@ -892,8 +935,11 @@ function createMemoryServer() {
|
|
|
892
935
|
idempotentHint: true,
|
|
893
936
|
},
|
|
894
937
|
}, async ({ scope, key }) => {
|
|
895
|
-
const current = await readKey(scope, key);
|
|
896
|
-
const history = (await listEntries(scope, historyPrefix(key), {
|
|
938
|
+
const current = await readKey(scope, key, scopeOptions);
|
|
939
|
+
const history = (await listEntries(scope, historyPrefix(key), {
|
|
940
|
+
includeHistory: true,
|
|
941
|
+
...scopeOptions,
|
|
942
|
+
}))
|
|
897
943
|
.sort((left, right) => left.key.localeCompare(right.key));
|
|
898
944
|
const payload = {
|
|
899
945
|
scope,
|
|
@@ -909,17 +955,21 @@ function createMemoryServer() {
|
|
|
909
955
|
server.registerTool("domain_knowledge_query", {
|
|
910
956
|
description: "Query an AnythingLLM workspace in query mode for domain knowledge.",
|
|
911
957
|
inputSchema: z.object({
|
|
912
|
-
workspace: z.string().min(1),
|
|
958
|
+
workspace: z.string().min(1).optional(),
|
|
913
959
|
query: z.string().min(1),
|
|
914
960
|
}),
|
|
915
961
|
annotations: {
|
|
916
962
|
readOnlyHint: true,
|
|
917
963
|
},
|
|
918
964
|
}, async ({ workspace, query }) => {
|
|
919
|
-
const
|
|
965
|
+
const workspaceSlug = workspace ?? defaultAnythingLLMWorkspace(memoryConfig());
|
|
966
|
+
if (!workspaceSlug) {
|
|
967
|
+
throw new Error("No AnythingLLM workspace provided and no project workspace found in memory config.");
|
|
968
|
+
}
|
|
969
|
+
const answer = await callAnythingLLM(workspaceSlug, query);
|
|
920
970
|
return {
|
|
921
971
|
content: [{ type: "text", text: answer }],
|
|
922
|
-
structuredContent: { workspace, answer },
|
|
972
|
+
structuredContent: { workspace: workspaceSlug, answer },
|
|
923
973
|
};
|
|
924
974
|
});
|
|
925
975
|
server.registerTool("domain_knowledge_sync", {
|
|
@@ -932,7 +982,7 @@ function createMemoryServer() {
|
|
|
932
982
|
}),
|
|
933
983
|
}, async ({ workspace, mode, confirm_replace, timeout_seconds }) => {
|
|
934
984
|
const syncMode = mode ?? "incremental";
|
|
935
|
-
const config =
|
|
985
|
+
const config = memoryConfig();
|
|
936
986
|
const workspaceSlug = workspace ?? defaultAnythingLLMWorkspace(config);
|
|
937
987
|
if (!workspaceSlug) {
|
|
938
988
|
throw new Error("No AnythingLLM workspace provided and no project workspace found in memory config.");
|
|
@@ -1183,12 +1233,13 @@ if (httpPort > 0) {
|
|
|
1183
1233
|
}
|
|
1184
1234
|
// New session (first request = initialize). The transport mints a session
|
|
1185
1235
|
// id and the SDK returns it via the response header; client echoes it back.
|
|
1236
|
+
const context = parseServerContext(request.headers);
|
|
1186
1237
|
const transport = new WebStandardStreamableHTTPServerTransport({
|
|
1187
1238
|
sessionIdGenerator: () => randomUUID(),
|
|
1188
1239
|
onsessioninitialized: (id) => { sessions.set(id, transport); },
|
|
1189
1240
|
onsessionclosed: (id) => { sessions.delete(id); },
|
|
1190
1241
|
});
|
|
1191
|
-
const session = createMemoryServer();
|
|
1242
|
+
const session = createMemoryServer(context);
|
|
1192
1243
|
await session.connect(transport);
|
|
1193
1244
|
return transport.handleRequest(request);
|
|
1194
1245
|
};
|
|
@@ -1198,7 +1249,7 @@ if (httpPort > 0) {
|
|
|
1198
1249
|
res.setHeader("Access-Control-Allow-Origin", allowOrigin);
|
|
1199
1250
|
res.setHeader("Vary", "Origin");
|
|
1200
1251
|
res.setHeader("Access-Control-Allow-Methods", "POST, GET, DELETE, OPTIONS");
|
|
1201
|
-
res.setHeader("Access-Control-Allow-Headers", "Content-Type, mcp-session-id, Accept, Authorization");
|
|
1252
|
+
res.setHeader("Access-Control-Allow-Headers", "Content-Type, mcp-session-id, Accept, Authorization, X-Cairn-Project, X-Cairn-Scopes, X-Cairn-AnythingLLM-Workspaces");
|
|
1202
1253
|
}
|
|
1203
1254
|
if (req.method === "OPTIONS") {
|
|
1204
1255
|
res.writeHead(allowOrigin ? 204 : 403).end();
|
|
@@ -1235,7 +1286,8 @@ if (httpPort > 0) {
|
|
|
1235
1286
|
res.end(Buffer.from(await response.arrayBuffer()));
|
|
1236
1287
|
}
|
|
1237
1288
|
catch (err) {
|
|
1238
|
-
|
|
1289
|
+
const status = err instanceof ClientContextError ? 400 : 500;
|
|
1290
|
+
res.writeHead(status).end(err instanceof Error ? err.message : String(err));
|
|
1239
1291
|
}
|
|
1240
1292
|
});
|
|
1241
1293
|
httpServer.listen(httpPort, httpHost, () => {
|
|
@@ -14,12 +14,13 @@
|
|
|
14
14
|
"check:search": "node scripts/smoke-search-e2e.mjs",
|
|
15
15
|
"check:scope-guard": "node scripts/smoke-scope-guard.mjs",
|
|
16
16
|
"check:http-guard": "node scripts/smoke-http-guard.mjs",
|
|
17
|
+
"check:remote-context": "node scripts/smoke-remote-context.mjs",
|
|
17
18
|
"check:explore-guard": "node scripts/smoke-explore-guard.mjs",
|
|
18
19
|
"check:explore-cache": "node scripts/smoke-explore-cache.mjs",
|
|
19
20
|
"check:explore-crossref": "node scripts/smoke-explore-crossref.mjs",
|
|
20
21
|
"check:route-guard": "node scripts/smoke-route-guard.mjs",
|
|
21
22
|
"test": "npm run test:smoke",
|
|
22
|
-
"test:smoke": "npm run build && npm run check:embeddings && npm run check:extract && npm run check:scope-guard && npm run check:http-guard && npm run check:explore-guard && npm run check:explore-cache && npm run check:explore-crossref && npm run check:route-guard",
|
|
23
|
+
"test:smoke": "npm run build && npm run check:embeddings && npm run check:extract && npm run check:scope-guard && npm run check:http-guard && npm run check:remote-context && npm run check:explore-guard && npm run check:explore-cache && npm run check:explore-crossref && npm run check:route-guard",
|
|
23
24
|
"test:search": "npm run build && npm run check:search",
|
|
24
25
|
"test:memory": "npm run build && npm run check:embeddings && npm run check:extract && npm run check:search",
|
|
25
26
|
"test:all": "npm run test:memory"
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cairnkeep/cli",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "A durable, harness-agnostic memory + context layer for coding agents (Claude Code, OpenCode).",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -32,10 +32,12 @@
|
|
|
32
32
|
"zod": "^3.25.76"
|
|
33
33
|
},
|
|
34
34
|
"scripts": {
|
|
35
|
-
"
|
|
35
|
+
"build:server": "npm --prefix mcp-memory-server ci --include=dev --global=false --dry-run=false && npm --prefix mcp-memory-server run build --global=false --dry-run=false",
|
|
36
|
+
"fix:perms": "git ls-files -s | awk '$1==\"100755\"{print $4}' | xargs chmod 755",
|
|
37
|
+
"prepack": "npm run build:server && npm run fix:perms",
|
|
36
38
|
"test": "for test in scripts/test-*.sh; do \"$test\" || exit 1; done",
|
|
37
39
|
"check:public": "scripts/verify-no-private-references.sh && scripts/verify-docs-parity.sh && npm test",
|
|
38
|
-
"prepublishOnly": "npm run check:public && npm
|
|
40
|
+
"prepublishOnly": "npm run check:public && npm run build:server && npm --prefix mcp-memory-server test --global=false --dry-run=false"
|
|
39
41
|
},
|
|
40
42
|
"repository": {
|
|
41
43
|
"type": "git",
|
package/scripts/bootstrap.sh
CHANGED
|
@@ -57,6 +57,10 @@ install_file "$TPL/start-claude.sh.template" "$target/.ai/start-claude.sh" 0
|
|
|
57
57
|
install_file "$TPL/start-opencode.sh.template" "$target/.ai/start-opencode.sh" 0755
|
|
58
58
|
install_file "$TPL/env.example.template" "$target/.ai/env.example" 0644
|
|
59
59
|
|
|
60
|
+
# Project-scope memory is private runtime data. Keep it out of version control
|
|
61
|
+
# even before the first .agentfs/project.db is created.
|
|
62
|
+
install_file "$TPL/agentfs-gitignore.template" "$target/.agentfs/.gitignore" 0644
|
|
63
|
+
|
|
60
64
|
# .planning/ derived-knowledge layer (template : destination)
|
|
61
65
|
planning_pairs=(
|
|
62
66
|
"planning-config.json.template:.planning/config.json"
|
|
@@ -77,7 +81,7 @@ done
|
|
|
77
81
|
|
|
78
82
|
if [[ $untracked -eq 1 ]]; then
|
|
79
83
|
mkdir -p "$(dirname "$exclude_file")"
|
|
80
|
-
for entry in "/${exclude_prefix}.ai/" "/${exclude_prefix}.planning/"; do
|
|
84
|
+
for entry in "/${exclude_prefix}.ai/" "/${exclude_prefix}.planning/" "/${exclude_prefix}.agentfs/"; do
|
|
81
85
|
if grep -qxF "$entry" "$exclude_file" 2>/dev/null; then
|
|
82
86
|
echo "skip (already excluded): $entry"
|
|
83
87
|
else
|
|
@@ -93,7 +97,7 @@ echo
|
|
|
93
97
|
echo "Cairnkeep bootstrapped into $target"
|
|
94
98
|
echo "Next steps (full guide: docs/operating.md):"
|
|
95
99
|
echo " 1. cp .ai/env.example .ai/.env and edit"
|
|
96
|
-
echo " 2.
|
|
97
|
-
echo "
|
|
98
|
-
echo "
|
|
100
|
+
echo " 2. Register the memory server: claude mcp add cairn-memory -s user -- cairn memory-server"
|
|
101
|
+
echo " 3. Install the operating layer (commands/agents/hooks): cairn sync --apply"
|
|
102
|
+
echo " From a source clone, use bin/cairn in place of cairn."
|
|
99
103
|
echo " 4. Launch: $target/.ai/start-claude.sh"
|
package/scripts/doctor.sh
CHANGED
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
#
|
|
4
4
|
# Reports pass/warn/skip/fail for the pieces cairnkeep needs, reading only what
|
|
5
5
|
# is already configured in ./.ai/.env (or the current environment). Unconfigured
|
|
6
|
-
# optional dependencies are SKIPPED, never failed.
|
|
7
|
-
#
|
|
8
|
-
#
|
|
6
|
+
# optional dependencies are SKIPPED, never failed. The required memory server
|
|
7
|
+
# is checked with a real MCP initialize exchange. Exits non-zero if that probe
|
|
8
|
+
# fails or a configured dependency is unreachable.
|
|
9
9
|
set -uo pipefail
|
|
10
10
|
|
|
11
11
|
CAIRN_ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
|
|
@@ -30,11 +30,18 @@ reachable() { curl -sS -m 5 -o /dev/null "$1" >/dev/null 2>&1; }
|
|
|
30
30
|
|
|
31
31
|
echo "cairn doctor"
|
|
32
32
|
|
|
33
|
-
# 1.
|
|
34
|
-
|
|
35
|
-
|
|
33
|
+
# 1. Required memory server: prove that the shipped build and dependencies can
|
|
34
|
+
# complete an MCP stdio handshake, rather than only checking for one file.
|
|
35
|
+
server="$CAIRN_ROOT/mcp-memory-server/dist/index.js"
|
|
36
|
+
probe="$CAIRN_ROOT/scripts/probe-memory-server.mjs"
|
|
37
|
+
if ! command -v node >/dev/null 2>&1; then
|
|
38
|
+
fail "Node.js not found (Node.js 18 or newer is required)"
|
|
39
|
+
elif [[ ! -f "$server" ]]; then
|
|
40
|
+
fail "memory server not built — run: (cd \"$CAIRN_ROOT/mcp-memory-server\" && npm install && npm run build)"
|
|
41
|
+
elif node "$probe" "$server" >/dev/null 2>&1; then
|
|
42
|
+
pass "bundled local memory server responds over MCP stdio"
|
|
36
43
|
else
|
|
37
|
-
|
|
44
|
+
fail "memory server failed its MCP stdio probe — reinstall cairnkeep or rebuild mcp-memory-server"
|
|
38
45
|
fi
|
|
39
46
|
|
|
40
47
|
# 2. LLM extraction endpoint (optional; unset → substring-only memory search).
|
|
@@ -83,6 +90,14 @@ else
|
|
|
83
90
|
else fail "memory store parent not writable: $parent"; fi
|
|
84
91
|
fi
|
|
85
92
|
|
|
93
|
+
# 6. Optional backup dependency. Runtime memory and imports do not need it,
|
|
94
|
+
# but exports use sqlite3's .backup command for a consistent WAL snapshot.
|
|
95
|
+
if command -v sqlite3 >/dev/null 2>&1; then
|
|
96
|
+
pass "sqlite3 available (memory export enabled)"
|
|
97
|
+
else
|
|
98
|
+
warn "sqlite3 not found — memory export unavailable (runtime and import unaffected)"
|
|
99
|
+
fi
|
|
100
|
+
|
|
86
101
|
echo
|
|
87
102
|
if [[ "$fails" -gt 0 ]]; then
|
|
88
103
|
echo "cairn doctor: $fails configured dependency check(s) failed."
|
package/scripts/memory-store.sh
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
|
-
# cairn memory <path|export|import> — relocate
|
|
3
|
-
# machines or backends.
|
|
2
|
+
# cairn memory <path|export|import> — relocate global-scope memory between
|
|
3
|
+
# machines or backends. Project-scope memory lives in <project>/.agentfs/ and
|
|
4
|
+
# is intentionally outside this command's base directory.
|
|
4
5
|
#
|
|
5
|
-
# The store is one SQLite .db per scope under
|
|
6
|
-
# (default ~/.cairnkeep). `export` takes a WAL-safe
|
|
7
|
-
# into a portable archive; `import` restores
|
|
8
|
-
#
|
|
6
|
+
# The global store is one SQLite .db per named scope under
|
|
7
|
+
# CAIRN_AGENTFS_BASE_DIR (default ~/.cairnkeep). `export` takes a WAL-safe
|
|
8
|
+
# snapshot of every global-scope db into a portable archive; `import` restores
|
|
9
|
+
# them on another machine; `path` prints the global store location.
|
|
9
10
|
set -euo pipefail
|
|
10
11
|
|
|
11
12
|
base="${CAIRN_AGENTFS_BASE_DIR:-$HOME/.cairnkeep}"
|
|
@@ -14,9 +15,9 @@ base="${base/#\~/$HOME}"
|
|
|
14
15
|
usage() {
|
|
15
16
|
cat <<'EOF'
|
|
16
17
|
Usage: cairn memory <path|export|import>
|
|
17
|
-
path Print the memory
|
|
18
|
-
export <file.tgz> WAL-safe snapshot of every scope .db
|
|
19
|
-
import <file.tgz> Restore scope .db files
|
|
18
|
+
path Print the global-scope memory base directory.
|
|
19
|
+
export <file.tgz> WAL-safe snapshot of every global-scope .db.
|
|
20
|
+
import <file.tgz> Restore global-scope .db files (backs up existing).
|
|
20
21
|
EOF
|
|
21
22
|
}
|
|
22
23
|
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
|
|
4
|
+
const serverPath = process.argv[2];
|
|
5
|
+
if (!serverPath) {
|
|
6
|
+
console.error("usage: probe-memory-server.mjs PATH_TO_SERVER");
|
|
7
|
+
process.exit(2);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const child = spawn(process.execPath, [serverPath], {
|
|
11
|
+
env: process.env,
|
|
12
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
let stderr = "";
|
|
16
|
+
child.stderr.setEncoding("utf8");
|
|
17
|
+
child.stderr.on("data", (chunk) => {
|
|
18
|
+
stderr = (stderr + chunk).slice(-2000);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
const response = new Promise((resolve, reject) => {
|
|
22
|
+
let buffered = "";
|
|
23
|
+
child.stdout.setEncoding("utf8");
|
|
24
|
+
child.stdout.on("data", (chunk) => {
|
|
25
|
+
buffered += chunk;
|
|
26
|
+
const lines = buffered.split("\n");
|
|
27
|
+
buffered = lines.pop() ?? "";
|
|
28
|
+
for (const line of lines) {
|
|
29
|
+
if (!line.trim()) continue;
|
|
30
|
+
try {
|
|
31
|
+
const message = JSON.parse(line);
|
|
32
|
+
if (message.id === 1 && message.result?.serverInfo?.name === "cairn-memory") {
|
|
33
|
+
resolve();
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
} catch {
|
|
37
|
+
// Ignore non-protocol output while waiting for the initialize response.
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
child.once("error", reject);
|
|
42
|
+
child.once("exit", (code) => reject(new Error(`server exited with status ${code}`)));
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const timeout = new Promise((_, reject) => {
|
|
46
|
+
setTimeout(() => reject(new Error("MCP initialize timed out")), 5000).unref();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const initialize = {
|
|
50
|
+
jsonrpc: "2.0",
|
|
51
|
+
id: 1,
|
|
52
|
+
method: "initialize",
|
|
53
|
+
params: {
|
|
54
|
+
protocolVersion: "2024-11-05",
|
|
55
|
+
capabilities: {},
|
|
56
|
+
clientInfo: { name: "cairn-doctor", version: "1.0" },
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
child.stdin.end(`${JSON.stringify(initialize)}\n`);
|
|
62
|
+
await Promise.race([response, timeout]);
|
|
63
|
+
} catch (error) {
|
|
64
|
+
const detail = stderr.trim() || error.message;
|
|
65
|
+
console.error(`memory server probe failed: ${detail}`);
|
|
66
|
+
process.exitCode = 1;
|
|
67
|
+
} finally {
|
|
68
|
+
child.kill();
|
|
69
|
+
}
|
|
@@ -24,18 +24,22 @@ git -C "$tmp/repo" init -q
|
|
|
24
24
|
"$ROOT/scripts/bootstrap.sh" --untracked "$tmp/repo" >/dev/null
|
|
25
25
|
[[ -f "$tmp/repo/.ai/start-claude.sh" ]] || fail "scaffold missing"
|
|
26
26
|
[[ -f "$tmp/repo/.planning/config.json" ]] || fail "planning layer missing"
|
|
27
|
+
[[ -f "$tmp/repo/.agentfs/.gitignore" ]] || fail "project-memory ignore guard missing"
|
|
27
28
|
[[ -z "$(git -C "$tmp/repo" status --porcelain)" ]] || fail "scaffold visible to git"
|
|
28
29
|
grep -qxF "/.ai/" "$tmp/repo/.git/info/exclude" || fail "missing /.ai/ exclude entry"
|
|
29
30
|
grep -qxF "/.planning/" "$tmp/repo/.git/info/exclude" || fail "missing /.planning/ exclude entry"
|
|
31
|
+
grep -qxF "/.agentfs/" "$tmp/repo/.git/info/exclude" || fail "missing /.agentfs/ exclude entry"
|
|
30
32
|
|
|
31
33
|
# Re-run is idempotent: no duplicate exclude entries
|
|
32
34
|
"$ROOT/scripts/bootstrap.sh" --untracked "$tmp/repo" >/dev/null
|
|
33
35
|
[[ $(grep -cxF "/.ai/" "$tmp/repo/.git/info/exclude") -eq 1 ]] || fail "duplicate exclude entries"
|
|
36
|
+
[[ $(grep -cxF "/.agentfs/" "$tmp/repo/.git/info/exclude") -eq 1 ]] || fail "duplicate .agentfs exclude entry"
|
|
34
37
|
|
|
35
38
|
# Default mode is unchanged: scaffold stays visible to git
|
|
36
39
|
mkdir "$tmp/repo2"
|
|
37
40
|
git -C "$tmp/repo2" init -q
|
|
38
41
|
"$ROOT/scripts/bootstrap.sh" "$tmp/repo2" >/dev/null
|
|
39
42
|
git -C "$tmp/repo2" status --porcelain | grep -q "\.ai/" || fail "default mode should leave the scaffold tracked"
|
|
43
|
+
git -C "$tmp/repo2" status --porcelain | grep -q "\.agentfs/" || fail "default mode should track the memory ignore guard"
|
|
40
44
|
|
|
41
45
|
echo "PASS: bootstrap --untracked contributor mode"
|
|
@@ -11,6 +11,9 @@ cairn="$ROOT/bin/cairn"
|
|
|
11
11
|
"$cairn" help | grep -q "cairn sync" || fail "help missing sync"
|
|
12
12
|
"$cairn" help | grep -q "cairn memory-server" || fail "help missing memory-server"
|
|
13
13
|
"$cairn" sync --help >/dev/null 2>&1 || fail "cairn sync dispatch"
|
|
14
|
+
if "$cairn" not-a-command >/dev/null 2>&1; then
|
|
15
|
+
fail "unknown command should exit non-zero"
|
|
16
|
+
fi
|
|
14
17
|
|
|
15
18
|
tmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXIT
|
|
16
19
|
CAIRN_AGENTFS_BASE_DIR="$tmp/store" "$cairn" memory path | grep -qx "$tmp/store" || fail "cairn memory path dispatch"
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Prove that the actual npm tarball installs and runs without relying on files
|
|
3
|
+
# or dependencies from the source checkout.
|
|
4
|
+
set -euo pipefail
|
|
5
|
+
|
|
6
|
+
ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
|
|
7
|
+
tmp=$(mktemp -d)
|
|
8
|
+
trap 'rm -rf "$tmp"' EXIT
|
|
9
|
+
fail() { echo "FAIL: $1" >&2; exit 1; }
|
|
10
|
+
|
|
11
|
+
cd "$ROOT"
|
|
12
|
+
npm pack --silent --pack-destination "$tmp" --dry-run=false >/dev/null
|
|
13
|
+
tarballs=("$tmp"/*.tgz)
|
|
14
|
+
[[ ${#tarballs[@]} -eq 1 && -f "${tarballs[0]}" ]] || fail "npm pack did not produce exactly one tarball"
|
|
15
|
+
|
|
16
|
+
npm install -g --silent --prefix "$tmp/prefix" --dry-run=false "${tarballs[0]}"
|
|
17
|
+
export HOME="$tmp/home"
|
|
18
|
+
export PATH="$tmp/prefix/bin:$PATH"
|
|
19
|
+
mkdir -p "$HOME" "$tmp/project"
|
|
20
|
+
|
|
21
|
+
expected=$(node -p 'require(process.argv[1]).version' "$ROOT/package.json")
|
|
22
|
+
[[ "$(cairn version)" == "cairnkeep $expected" ]] || fail "installed CLI reports the wrong version"
|
|
23
|
+
|
|
24
|
+
bootstrap_output=$(cairn bootstrap "$tmp/project")
|
|
25
|
+
[[ "$bootstrap_output" == *"cairn memory-server"* ]] || fail "bootstrap printed invalid memory-server setup instructions"
|
|
26
|
+
[[ "$bootstrap_output" == *"cairn sync --apply"* ]] || fail "bootstrap printed invalid operating-layer setup instructions"
|
|
27
|
+
[[ -x "$tmp/project/.ai/start-claude.sh" ]] || fail "bootstrap did not install an executable Claude launcher"
|
|
28
|
+
[[ -f "$tmp/project/.planning/config.json" ]] || fail "bootstrap did not install the planning scaffold"
|
|
29
|
+
|
|
30
|
+
(cd "$tmp/project" && cairn doctor) >/dev/null || fail "installed package failed cairn doctor"
|
|
31
|
+
|
|
32
|
+
echo "PASS: npm tarball installs a self-contained CLI and MCP server"
|
|
@@ -2,24 +2,20 @@
|
|
|
2
2
|
# Regression guard for the 744-perms packaging bug. A root-owned `sudo npm i -g`
|
|
3
3
|
# makes root the file owner, so a non-root user can only run cairn if "other"
|
|
4
4
|
# has the execute bit. A publisher umask can strip it at pack time; the
|
|
5
|
-
# `
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
# Deliberately does NOT run `npm pack`: this test runs inside `npm test`, which
|
|
9
|
-
# runs inside `npm publish`'s prepublishOnly, and nesting npm that deep is not
|
|
10
|
-
# portable. Running the prepack command directly tests the same guarantee.
|
|
5
|
+
# `fix:perms` script re-applies git's tracked executable bit. This verifies it
|
|
6
|
+
# heals a umask-mangled entrypoint back to 755 (world-executable).
|
|
11
7
|
set -euo pipefail
|
|
12
8
|
ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
|
|
13
9
|
cd "$ROOT"
|
|
14
10
|
fail() { echo "FAIL: $1" >&2; exit 1; }
|
|
15
11
|
|
|
16
|
-
|
|
17
|
-
[[ -n "$
|
|
12
|
+
fix_perms=$(node -e 'process.stdout.write(require("./package.json").scripts["fix:perms"]||"")')
|
|
13
|
+
[[ -n "$fix_perms" ]] || fail "package.json has no fix:perms script"
|
|
18
14
|
|
|
19
15
|
entry=bin/cairn
|
|
20
16
|
trap 'chmod 755 "$entry" 2>/dev/null || true' EXIT
|
|
21
17
|
chmod 744 "$entry" # simulate a stripping umask
|
|
22
|
-
sh -c "$
|
|
18
|
+
sh -c "$fix_perms" # run the real packaging command
|
|
23
19
|
mode=$(stat -c '%a' "$entry" 2>/dev/null || stat -f '%Lp' "$entry") # GNU || BSD/macOS
|
|
24
|
-
[[ "$mode" == "755" ]] || fail "
|
|
25
|
-
echo "OK:
|
|
20
|
+
[[ "$mode" == "755" ]] || fail "fix:perms did not restore $entry to 755 (got $mode) -- 744 packaging bug"
|
|
21
|
+
echo "OK: fix:perms restores shipped executables to 755"
|
|
@@ -58,11 +58,13 @@ EXCL="$PROJ/.git/info/exclude"
|
|
|
58
58
|
mkdir -p "$SB/home/.cairnkeep"; echo data >"$SB/home/.cairnkeep/db"
|
|
59
59
|
"$ROOT_DIR/scripts/uninstall.sh" --yes --purge-memory --live-root "$SB/home/.claude" "$PROJ" >/dev/null 2>&1
|
|
60
60
|
check "project .ai removed" "$([[ -e "$PROJ/.ai" ]] && echo no || echo yes)" "yes"
|
|
61
|
-
check "
|
|
61
|
+
check "project .agentfs removed" "$([[ -e "$PROJ/.agentfs" ]] && echo no || echo yes)" "yes"
|
|
62
|
+
check "exclude lines stripped" "$(grep -cE '\.ai/|\.planning/|\.agentfs/' "$EXCL" 2>/dev/null || true)" "0"
|
|
62
63
|
check "memory store purged" "$([[ -e "$SB/home/.cairnkeep" ]] && echo no || echo yes)" "yes"
|
|
63
64
|
BK2=$(ls -dt "$SB/home/.cairnkeep-uninstall-"* 2>/dev/null | head -1)
|
|
64
65
|
bash "$BK2/revert.sh" >/dev/null 2>&1
|
|
65
66
|
check "project scaffold restored" "$([[ -d "$PROJ/.ai" ]] && echo yes || echo no)" "yes"
|
|
67
|
+
check "project ignore restored" "$([[ -f "$PROJ/.agentfs/.gitignore" ]] && echo yes || echo no)" "yes"
|
|
66
68
|
check "memory store restored" "$(cat "$SB/home/.cairnkeep/db" 2>/dev/null)" "data"
|
|
67
69
|
|
|
68
70
|
echo
|
package/scripts/uninstall.sh
CHANGED
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
# Undoes: the operating layer (`cairn sync --apply` — commands, agents,
|
|
11
11
|
# templates, hooks + their settings.json registrations), the MCP registration
|
|
12
12
|
# (`claude mcp add cairn-memory`), the opt-in audit timer, and — for any project
|
|
13
|
-
# paths you pass — the `cairn bootstrap` scaffold.
|
|
13
|
+
# paths you pass — the `cairn bootstrap` scaffold. Project `.agentfs/` memory is
|
|
14
|
+
# retained unless `--purge-memory` is given. The npm package itself is
|
|
14
15
|
# npm's to remove; the final step is printed, not run (it would delete this
|
|
15
16
|
# script mid-execution).
|
|
16
17
|
set -uo pipefail
|
|
@@ -39,9 +40,8 @@ $HOME/.cairnkeep-uninstall-<ts>/ with a revert.sh before any change.
|
|
|
39
40
|
|
|
40
41
|
--dry-run Print what would happen; touch nothing.
|
|
41
42
|
--yes Do not prompt for confirmation.
|
|
42
|
-
--purge-memory Also remove the
|
|
43
|
-
|
|
44
|
-
accumulated memory — off by default.
|
|
43
|
+
--purge-memory Also remove the global memory store and .agentfs/ memory in
|
|
44
|
+
each PROJECT. Backed up first; off by default.
|
|
45
45
|
--live-root PATH Claude root to clean (default: $CLAUDE_CONFIG_DIR or ~/.claude).
|
|
46
46
|
PROJECT ... Also revert `cairn bootstrap` in these project dirs
|
|
47
47
|
(.ai/, .planning/, and any .git/info/exclude entries).
|
|
@@ -209,16 +209,26 @@ for proj in "${PROJECTS[@]:-}"; do
|
|
|
209
209
|
echo "Project scaffold: $proj"
|
|
210
210
|
remove_path "$proj/.ai"
|
|
211
211
|
remove_path "$proj/.planning"
|
|
212
|
+
if [[ $PURGE_MEMORY -eq 1 ]]; then
|
|
213
|
+
remove_path "$proj/.agentfs"
|
|
214
|
+
elif [[ -d "$proj/.agentfs" ]]; then
|
|
215
|
+
echo " kept project memory: $proj/.agentfs"
|
|
216
|
+
fi
|
|
212
217
|
# Strip the .git/info/exclude lines contributor-mode bootstrap added.
|
|
213
218
|
if excl=$(cd "$proj" && git rev-parse --git-path info/exclude 2>/dev/null); then
|
|
214
219
|
[[ "$excl" == /* ]] || excl="$proj/$excl"
|
|
215
220
|
prefix=$(cd "$proj" && git rev-parse --show-prefix 2>/dev/null)
|
|
216
221
|
if [[ -f "$excl" ]] && grep -qxF "/${prefix}.ai/" "$excl" 2>/dev/null; then
|
|
217
222
|
if [[ $DRY_RUN -eq 1 ]]; then
|
|
218
|
-
echo " would strip
|
|
223
|
+
echo " would strip scaffold entries from ${excl}"
|
|
219
224
|
else
|
|
220
225
|
back_up "$excl"
|
|
221
|
-
|
|
226
|
+
if [[ $PURGE_MEMORY -eq 1 ]]; then
|
|
227
|
+
grep -vxF -e "/${prefix}.ai/" -e "/${prefix}.planning/" -e "/${prefix}.agentfs/" "$excl" >"$excl.tmp" || true
|
|
228
|
+
else
|
|
229
|
+
grep -vxF -e "/${prefix}.ai/" -e "/${prefix}.planning/" "$excl" >"$excl.tmp" || true
|
|
230
|
+
fi
|
|
231
|
+
mv "$excl.tmp" "$excl"
|
|
222
232
|
echo " stripped exclude entries from ${excl}"
|
|
223
233
|
fi
|
|
224
234
|
fi
|
|
@@ -12,9 +12,13 @@
|
|
|
12
12
|
# One of: github | gitlab | codeberg | forgejo | none. See docs/git-providers.md.
|
|
13
13
|
# CAIRN_GIT_PROVIDER=github
|
|
14
14
|
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
17
|
-
#
|
|
15
|
+
# Storage belongs to the host running the memory-server process. Named/global
|
|
16
|
+
# scopes default to ~/.cairnkeep; project scope uses <server-cwd>/.agentfs/.
|
|
17
|
+
# CAIRN_AGENTFS_BASE_DIR=~/.cairnkeep
|
|
18
|
+
#
|
|
19
|
+
# The memory server runs locally over stdio by default. These HTTP settings are
|
|
20
|
+
# for the SERVER host of an explicit remote deployment, not a normal local
|
|
21
|
+
# client. Setting MCP_HTTP_PORT REQUIRES a bearer token, or startup fails.
|
|
18
22
|
# CAIRN_MEMORY_HTTP_TOKEN=change-me-to-a-long-random-secret
|
|
19
23
|
# MCP_HTTP_PORT=7801
|
|
20
24
|
# MCP_HTTP_HOST=127.0.0.1
|