@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 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
- cat <<'USAGE'
48
- cairn — Cairnkeep CLI
49
-
50
- Usage:
51
- cairn bootstrap [--untracked] [path]
52
- Scaffold .ai/ launchers + env into a project (default: .).
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
@@ -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` | Base dir for global memory scopes (default `~/.cairnkeep`) |
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 it to a
247
- streamable HTTP transport so one long-lived process can serve many clients — but
248
- because that exposes every memory tool over the network, HTTP mode is guarded and
249
- **fails closed**:
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 configured pieces, reading only `./.ai/.env` (or the current
339
- env). Unconfigured optional dependencies are skipped; it exits non-zero only when
340
- a configured dependency (LLM/embedding endpoint, writable store) is unreachable.
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 the durable memory store (one SQLite `.db` per scope under
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
@@ -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 resolveScopePath(scope, cwd = process.cwd()) {
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
- return resolve(cwd, ".agentfs", "project.db");
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, cwd) {
127
- const dbPath = resolveScopePath(scope, cwd);
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.cwd);
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) => ({ scope: candidate, entries: await listEntries(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 (centralized AgentFS on the VPS).
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 = getMemoryConfig();
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), { includeHistory: true }))
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 answer = await callAnythingLLM(workspace, query);
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 = getMemoryConfig();
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
- res.writeHead(500).end(err instanceof Error ? err.message : String(err));
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.4",
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
- "prepack": "git ls-files -s | awk '$1==\"100755\"{print $4}' | xargs chmod 755",
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 --prefix mcp-memory-server ci && npm --prefix mcp-memory-server test"
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",
@@ -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. Build + register the memory server: cd mcp-memory-server && npm install && npm run build,"
97
- echo " then: claude mcp add cairn-memory -s user -- node \"\$PWD/dist/index.js\""
98
- echo " 3. Install the operating layer (commands/agents/hooks): scripts/sync-claude-assets.sh --apply"
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. Exits non-zero only if a
7
- # CONFIGURED dependency is unreachable, so a green run means "the things you
8
- # turned on actually work".
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. Memory server built (local prerequisite; actionable warning, not a failure).
34
- if [[ -f "$CAIRN_ROOT/mcp-memory-server/dist/index.js" ]]; then
35
- pass "memory server built (mcp-memory-server/dist/index.js)"
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
- warn "memory server not built — run: (cd \"$CAIRN_ROOT/mcp-memory-server\" && npm install && npm run build)"
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."
@@ -1,11 +1,12 @@
1
1
  #!/usr/bin/env bash
2
- # cairn memory <path|export|import> — relocate the durable memory store between
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 CAIRN_AGENTFS_BASE_DIR
6
- # (default ~/.cairnkeep). `export` takes a WAL-safe snapshot of every scope db
7
- # into a portable archive; `import` restores them on another machine; `path`
8
- # prints the store location so nothing has to guess where it lives.
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 store base directory.
18
- export <file.tgz> WAL-safe snapshot of every scope .db into a portable archive.
19
- import <file.tgz> Restore scope .db files from an archive (backs up existing).
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
- # `prepack` script re-applies git's tracked executable bit. This verifies
6
- # prepack heals a umask-mangled entrypoint back to 755 (world-executable).
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
- prepack=$(node -e 'process.stdout.write(require("./package.json").scripts.prepack||"")')
17
- [[ -n "$prepack" ]] || fail "package.json has no prepack script (perms fix missing)"
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 "$prepack" # run the real prepack command
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 "prepack did not restore $entry to 755 (got $mode) -- 744 packaging bug"
25
- echo "OK: prepack restores shipped executables to 755"
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 "exclude lines stripped" "$(grep -cE '\.ai/|\.planning/' "$EXCL" 2>/dev/null || true)" "0"
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
@@ -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. The npm package itself is
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 durable memory store ($HOME/.cairnkeep or
43
- $CAIRN_AGENTFS_BASE_DIR). Backed up first, but this is your
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 .ai/ + .planning/ from ${excl}"
223
+ echo " would strip scaffold entries from ${excl}"
219
224
  else
220
225
  back_up "$excl"
221
- grep -vxF -e "/${prefix}.ai/" -e "/${prefix}.planning/" "$excl" >"$excl.tmp" && mv "$excl.tmp" "$excl"
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
@@ -0,0 +1,3 @@
1
+ # Project memory is private runtime data.
2
+ *
3
+ !.gitignore
@@ -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
- # The memory server runs over stdio by default. HTTP mode is opt-in and network
16
- # facing, so it is guarded: setting MCP_HTTP_PORT REQUIRES a bearer token, or the
17
- # server refuses to start. Clients must send `Authorization: Bearer <token>`.
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