@cairnkeep/cli 1.0.5 → 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.
@@ -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
@@ -267,9 +267,11 @@ is guarded and **fails closed**:
267
267
  Requests without a valid bearer token get `401`; requests with an unexpected
268
268
  `Host` header get `403`. Keep HTTP mode bound to `127.0.0.1` unless you have a
269
269
  specific reason to expose it, and use a long random token. HTTP mode has no
270
- per-user ACL or tenant isolation, and every client shares the server process's
271
- `project` database. See [Memory storage and deployment](storage.md) for the
272
- placement rules, client registration, TLS requirements, and backup boundaries.
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.
273
275
 
274
276
  ## The workflow
275
277
 
package/docs/storage.md CHANGED
@@ -53,12 +53,34 @@ With this topology, the databases are on the server host, not the client PC.
53
53
  Setting `CAIRN_AGENTFS_BASE_DIR` on the client does not redirect a remote
54
54
  server; set it in the server service environment.
55
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
+
56
78
  HTTP mode is one trusted storage domain:
57
79
 
58
80
  - One bearer token grants access to every exposed memory tool.
59
81
  - There is no per-user ACL, tenant isolation, or client-specific filesystem.
60
- - `project` scope resolves from the server process's working directory, so all
61
- HTTP clients of that process share the same `project.db`.
82
+ - Sessions without `X-Cairn-Project` share the project database resolved from
83
+ the server process's working directory.
62
84
  - Use one server instance per isolation boundary. Do not offer one instance to
63
85
  mutually untrusted users.
64
86
  - Keep the Cairnkeep listener on loopback behind a TLS reverse proxy, or use an
@@ -132,3 +154,10 @@ Ensure the secret-manager bootstrap exports `CAIRN_MEMORY_HTTP_TOKEN` before
132
154
  OpenCode starts. Preserve any other keys already present in `opencode.json`
133
155
  rather than replacing the whole file. The URL, token, private host names, and
134
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) {
@@ -670,8 +711,10 @@ function gitToplevel(cwd) {
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
713
  // serve many concurrent clients within one trusted server-side storage domain.
673
- function createMemoryServer() {
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.5",
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",