sealkeep 0.9.0 → 0.11.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.
Files changed (274) hide show
  1. package/ARCHITECTURE.md +163 -14
  2. package/CHANGELOG.md +223 -1
  3. package/CONTROL_PLANE.md +2 -2
  4. package/LICENSE +1 -1
  5. package/README.md +108 -22
  6. package/THIRD_PARTY.md +2 -2
  7. package/THREAT_MODEL.md +23 -4
  8. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/chunk-access.d.ts +26 -4
  9. package/dist/packages/sealkeep-crypto/src/chunk-access.js +219 -0
  10. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/envelope.d.ts +11 -1
  11. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/envelope.js +58 -21
  12. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/format.d.ts +2 -2
  13. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/index.d.ts +1 -0
  14. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/index.js +1 -0
  15. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/recipients.js +4 -4
  16. package/dist/packages/sealkeep-crypto/src/squeeze.d.ts +6 -0
  17. package/dist/packages/sealkeep-crypto/src/squeeze.js +39 -0
  18. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/stream.d.ts +36 -3
  19. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/stream.js +196 -37
  20. package/dist/site/index.html +1808 -1904
  21. package/dist/site/llms.txt +67 -0
  22. package/dist/site/trust/architecture-data-flow.html +53 -0
  23. package/dist/site/trust/audit-roadmap.html +37 -0
  24. package/dist/site/trust/deployment-responsibility.html +11 -0
  25. package/dist/site/trust/dpa-sample.html +30 -0
  26. package/dist/site/trust/release-provenance.html +21 -0
  27. package/dist/site/trust/subprocessors.html +15 -0
  28. package/dist/site/trust/threat-model.html +34 -0
  29. package/dist/site/trust/toms.html +41 -0
  30. package/dist/site/trust-document.css +32 -0
  31. package/dist/site/trust.html +73 -0
  32. package/dist/site/visual/assets/index-8Nxnkc7o.js +207 -0
  33. package/dist/site/visual/assets/index-oxLy1bpy.css +1 -0
  34. package/dist/site/visual/index.html +18 -0
  35. package/dist/site.zip +0 -0
  36. package/dist/src/activity.d.ts +9 -0
  37. package/dist/src/activity.js +90 -1
  38. package/dist/src/adapters.d.ts +175 -5
  39. package/dist/src/adapters.js +961 -73
  40. package/dist/src/agent-context.d.ts +135 -0
  41. package/dist/src/agent-context.js +1059 -0
  42. package/dist/src/archive-copies.d.ts +47 -0
  43. package/dist/src/archive-copies.js +179 -0
  44. package/dist/src/audit.d.ts +1 -1
  45. package/dist/src/audit.js +29 -4
  46. package/dist/src/autopilot.d.ts +52 -7
  47. package/dist/src/autopilot.js +143 -25
  48. package/dist/src/background-bandwidth.d.ts +46 -0
  49. package/dist/src/background-bandwidth.js +301 -0
  50. package/dist/src/background-cpu.d.ts +82 -0
  51. package/dist/src/background-cpu.js +212 -0
  52. package/dist/src/background-worker-error.d.ts +12 -0
  53. package/dist/src/background-worker-error.js +18 -0
  54. package/dist/src/branding.d.ts +24 -1
  55. package/dist/src/branding.js +31 -1
  56. package/dist/src/bridge.d.ts +233 -0
  57. package/dist/src/bridge.js +604 -0
  58. package/dist/src/byte-stream.d.ts +91 -0
  59. package/dist/src/byte-stream.js +385 -0
  60. package/dist/src/chunk-store.d.ts +41 -8
  61. package/dist/src/chunk-store.js +161 -65
  62. package/dist/src/cli.js +1698 -163
  63. package/dist/src/cloud.d.ts +841 -31
  64. package/dist/src/cloud.js +3196 -277
  65. package/dist/src/context-background.d.ts +37 -0
  66. package/dist/src/context-background.js +309 -0
  67. package/dist/src/context-drain-child.d.ts +1 -0
  68. package/dist/src/context-drain-child.js +98 -0
  69. package/dist/src/context-reader.d.ts +118 -0
  70. package/dist/src/context-reader.js +447 -0
  71. package/dist/src/control-plane/auth.d.ts +32 -4
  72. package/dist/src/control-plane/auth.js +85 -24
  73. package/dist/src/control-plane/server.js +19 -6
  74. package/dist/src/control-plane.d.ts +17 -1
  75. package/dist/src/control-plane.js +32 -6
  76. package/dist/src/crypto.d.ts +1 -1
  77. package/dist/src/crypto.js +5 -5
  78. package/dist/src/daemon-lease.d.ts +70 -0
  79. package/dist/src/daemon-lease.js +420 -0
  80. package/dist/src/daemon.d.ts +94 -1
  81. package/dist/src/daemon.js +1082 -105
  82. package/dist/src/darwin-service-policy.d.ts +41 -0
  83. package/dist/src/darwin-service-policy.js +60 -0
  84. package/dist/src/dashboard-cli.js +15 -15
  85. package/dist/src/device-authorization.d.ts +37 -0
  86. package/dist/src/device-authorization.js +199 -0
  87. package/dist/src/device-enrollment.d.ts +91 -0
  88. package/dist/src/device-enrollment.js +349 -0
  89. package/dist/src/disk.d.ts +17 -12
  90. package/dist/src/disk.js +43 -17
  91. package/dist/src/doctor.d.ts +35 -1
  92. package/dist/src/doctor.js +316 -41
  93. package/dist/src/durable-ticket-lock.d.ts +24 -0
  94. package/dist/src/durable-ticket-lock.js +232 -0
  95. package/dist/src/enroll.d.ts +1 -1
  96. package/dist/src/enroll.js +13 -7
  97. package/dist/src/env.d.ts +10 -1
  98. package/dist/src/env.js +11 -3
  99. package/dist/src/errors.d.ts +8 -8
  100. package/dist/src/errors.js +6 -6
  101. package/dist/src/flush.d.ts +12 -0
  102. package/dist/src/flush.js +37 -0
  103. package/dist/src/heartbeat.d.ts +86 -12
  104. package/dist/src/heartbeat.js +415 -29
  105. package/dist/src/index-background-watchdog.d.ts +1 -0
  106. package/dist/src/index-background-watchdog.js +94 -0
  107. package/dist/src/index-background-work.d.ts +21 -0
  108. package/dist/src/index-background-work.js +25 -0
  109. package/dist/src/index-background.d.ts +64 -0
  110. package/dist/src/index-background.js +394 -0
  111. package/dist/src/index-build-child.d.ts +1 -0
  112. package/dist/src/index-build-child.js +109 -0
  113. package/dist/src/index-manifest.d.ts +52 -0
  114. package/dist/src/index-manifest.js +444 -0
  115. package/dist/src/index-publication-proof.d.ts +84 -0
  116. package/dist/src/index-publication-proof.js +380 -0
  117. package/dist/src/index-publication-state.d.ts +149 -0
  118. package/dist/src/index-publication-state.js +696 -0
  119. package/dist/src/index-publication-verifier.d.ts +89 -0
  120. package/dist/src/index-publication-verifier.js +341 -0
  121. package/dist/src/index-publish.d.ts +62 -0
  122. package/dist/src/index-publish.js +540 -0
  123. package/dist/src/index-scratch-cleanup.d.ts +19 -0
  124. package/dist/src/index-scratch-cleanup.js +166 -0
  125. package/dist/src/index-segment-types.d.ts +132 -0
  126. package/dist/src/index-segment-types.js +21 -0
  127. package/dist/src/index-segments.d.ts +9 -0
  128. package/dist/src/index-segments.js +516 -0
  129. package/dist/src/index-store.d.ts +123 -0
  130. package/dist/src/index-store.js +495 -0
  131. package/dist/src/index-sync.d.ts +91 -15
  132. package/dist/src/index-sync.js +286 -53
  133. package/dist/src/index-upgrade-publication.d.ts +30 -0
  134. package/dist/src/index-upgrade-publication.js +179 -0
  135. package/dist/src/integration-manager.d.ts +32 -0
  136. package/dist/src/integration-manager.js +394 -0
  137. package/dist/src/leakscan.js +1 -1
  138. package/dist/src/local-api.d.ts +56 -15
  139. package/dist/src/local-api.js +4979 -541
  140. package/dist/src/machine-settings.d.ts +51 -0
  141. package/dist/src/machine-settings.js +166 -0
  142. package/dist/src/managed-chunks.d.ts +5 -2
  143. package/dist/src/managed-chunks.js +14 -14
  144. package/dist/src/mcp-install.d.ts +11 -9
  145. package/dist/src/mcp-install.js +73 -28
  146. package/dist/src/mcp-workspace.d.ts +18 -0
  147. package/dist/src/mcp-workspace.js +50 -0
  148. package/dist/src/mcp.js +294 -25
  149. package/dist/src/migrate.js +27 -21
  150. package/dist/src/notify.d.ts +1 -1
  151. package/dist/src/notify.js +5 -5
  152. package/dist/src/offload.d.ts +201 -14
  153. package/dist/src/offload.js +1848 -140
  154. package/dist/src/onboarding.d.ts +8 -1
  155. package/dist/src/onboarding.js +4 -4
  156. package/dist/src/packages.d.ts +2 -2
  157. package/dist/src/packages.js +10 -2
  158. package/dist/src/passkey.d.ts +0 -1
  159. package/dist/src/passkey.js +2 -7
  160. package/dist/src/password-lock.d.ts +2 -2
  161. package/dist/src/password-lock.js +6 -6
  162. package/dist/src/paths.d.ts +2 -0
  163. package/dist/src/paths.js +2 -0
  164. package/dist/src/presence.d.ts +86 -0
  165. package/dist/src/presence.js +240 -0
  166. package/dist/src/progress-deadline.d.ts +21 -0
  167. package/dist/src/progress-deadline.js +91 -0
  168. package/dist/src/project-repair.d.ts +55 -0
  169. package/dist/src/project-repair.js +131 -0
  170. package/dist/src/providers/gcs.d.ts +28 -7
  171. package/dist/src/providers/gcs.js +35 -24
  172. package/dist/src/providers/gdrive.d.ts +71 -8
  173. package/dist/src/providers/gdrive.js +223 -50
  174. package/dist/src/providers/index.d.ts +11 -3
  175. package/dist/src/providers/index.js +30 -10
  176. package/dist/src/providers/s3.d.ts +30 -8
  177. package/dist/src/providers/s3.js +41 -30
  178. package/dist/src/providers/safe-storage-fetch.d.ts +12 -0
  179. package/dist/src/providers/safe-storage-fetch.js +72 -0
  180. package/dist/src/queue.d.ts +136 -19
  181. package/dist/src/queue.js +862 -96
  182. package/dist/src/reclaim-transaction.d.ts +156 -0
  183. package/dist/src/reclaim-transaction.js +1027 -0
  184. package/dist/src/recovery-codes.d.ts +32 -0
  185. package/dist/src/recovery-codes.js +338 -0
  186. package/dist/src/recovery.js +12 -9
  187. package/dist/src/rehydrate.d.ts +25 -22
  188. package/dist/src/rehydrate.js +319 -23
  189. package/dist/src/restore.d.ts +57 -4
  190. package/dist/src/restore.js +272 -36
  191. package/dist/src/resume-stub.d.ts +92 -0
  192. package/dist/src/resume-stub.js +417 -0
  193. package/dist/src/retention.d.ts +98 -7
  194. package/dist/src/retention.js +1066 -62
  195. package/dist/src/rotate.js +3 -3
  196. package/dist/src/search.d.ts +566 -8
  197. package/dist/src/search.js +5897 -290
  198. package/dist/src/secrets.d.ts +51 -7
  199. package/dist/src/secrets.js +316 -24
  200. package/dist/src/service.d.ts +49 -11
  201. package/dist/src/service.js +776 -35
  202. package/dist/src/share.js +3 -3
  203. package/dist/src/shared-spaces.d.ts +98 -0
  204. package/dist/src/shared-spaces.js +214 -0
  205. package/dist/src/source-reader.d.ts +73 -0
  206. package/dist/src/source-reader.js +715 -0
  207. package/dist/src/spool.d.ts +1 -1
  208. package/dist/src/spool.js +1 -1
  209. package/dist/src/start-tui.js +2 -1
  210. package/dist/src/start.js +2 -2
  211. package/dist/src/storage-endpoint.d.ts +21 -0
  212. package/dist/src/storage-endpoint.js +122 -0
  213. package/dist/src/storage-setup.js +12 -12
  214. package/dist/src/storage-targets.d.ts +109 -6
  215. package/dist/src/storage-targets.js +975 -67
  216. package/dist/src/stream-to-cloud.d.ts +5 -1
  217. package/dist/src/stream-to-cloud.js +34 -14
  218. package/dist/src/sync-rules.d.ts +31 -6
  219. package/dist/src/sync-rules.js +153 -14
  220. package/dist/src/team-backfill-scheduling.d.ts +8 -0
  221. package/dist/src/team-backfill-scheduling.js +33 -0
  222. package/dist/src/team-backfill.d.ts +116 -0
  223. package/dist/src/team-backfill.js +1429 -0
  224. package/dist/src/team-index-cache.d.ts +16 -0
  225. package/dist/src/team-index-cache.js +152 -0
  226. package/dist/src/team-offboarding.d.ts +38 -0
  227. package/dist/src/team-offboarding.js +1043 -0
  228. package/dist/src/team-presence.d.ts +127 -0
  229. package/dist/src/team-presence.js +904 -0
  230. package/dist/src/team-publication-policy.d.ts +20 -0
  231. package/dist/src/team-publication-policy.js +140 -0
  232. package/dist/src/team-realtime.d.ts +68 -0
  233. package/dist/src/team-realtime.js +816 -0
  234. package/dist/src/team-source-facts-cache.d.ts +23 -0
  235. package/dist/src/team-source-facts-cache.js +255 -0
  236. package/dist/src/trash.d.ts +1 -1
  237. package/dist/src/trash.js +2 -2
  238. package/dist/src/tui.js +11 -12
  239. package/dist/src/types.d.ts +173 -7
  240. package/dist/src/types.js +20 -0
  241. package/dist/src/ui-server.d.ts +163 -35
  242. package/dist/src/ui-server.js +712 -72
  243. package/dist/src/ui.d.ts +1 -2
  244. package/dist/src/ui.js +1 -2
  245. package/dist/src/upload.d.ts +27 -0
  246. package/dist/src/upload.js +383 -43
  247. package/dist/src/vault.d.ts +226 -30
  248. package/dist/src/vault.js +1776 -192
  249. package/dist/src/watcher.d.ts +7 -1
  250. package/dist/src/watcher.js +198 -55
  251. package/dist/src/worker.d.ts +27 -3
  252. package/dist/src/worker.js +274 -55
  253. package/package.json +33 -12
  254. package/scripts/native-reboot-rehearsal.mjs +90 -0
  255. package/web/app.js +6032 -343
  256. package/web/bootstrap.js +17 -0
  257. package/web/index.html +255 -57
  258. package/web/rail.js +317 -40
  259. package/web/retention.html +2 -2
  260. package/web/rules-view.js +188 -16
  261. package/web/sessions-view.js +485 -62
  262. package/web/sessions.html +2 -2
  263. package/web/setup-api.js +152 -29
  264. package/web/setup-logic.js +68 -9
  265. package/web/setup.html +113 -44
  266. package/web/setup.js +604 -71
  267. package/web/style.css +513 -98
  268. package/dist/packages/vaultline-crypto/src/chunk-access.js +0 -93
  269. /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/aead.d.ts +0 -0
  270. /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/aead.js +0 -0
  271. /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/format.js +0 -0
  272. /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/recipients.d.ts +0 -0
  273. /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/sha256-stream.d.ts +0 -0
  274. /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/sha256-stream.js +0 -0
package/dist/src/mcp.js CHANGED
@@ -1,60 +1,329 @@
1
1
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
3
  import { z } from "zod";
4
- import { archiveFile, defaultDataDir, listArchives, previewRetention, vaultStatus } from "./vault.js";
4
+ import { archiveFile, defaultDataDir, listArchives, previewRetention, readConfig, vaultStatus } from "./vault.js";
5
5
  import { restoreArchive } from "./restore.js";
6
6
  import { providers } from "./control-plane.js";
7
7
  import { envVar } from "./env.js";
8
+ import { search } from "./search.js";
9
+ import { watchOnce, publishOnce, defaultMirrorPath, publishBridgeMessage, pullBridgeMessages, drainPending, promptInjection } from "./bridge.js";
10
+ import { readFile } from "node:fs/promises";
11
+ import { readContext } from "./context-reader.js";
12
+ import { resolveMcpWorkspace } from "./mcp-workspace.js";
13
+ const sourceSchema = z.object({
14
+ version: z.literal(1), archiveId: z.string().uuid(), envelopeArchiveId: z.string().uuid(),
15
+ sourceSha256: z.string().regex(/^[a-f0-9]{64}$/i), sourceBytes: z.number().int().nonnegative(),
16
+ ciphertextSha256: z.string().regex(/^[a-f0-9]{64}$/i), project: z.string().optional(),
17
+ projectKey: z.string().optional(), spaceKey: z.string().optional(),
18
+ });
8
19
  const dataDir = envVar("DATA_DIR") ?? defaultDataDir();
9
- const phrase = envVar("RECOVERY_PHRASE");
10
- const server = new McpServer({ name: "vaultline", version: "0.1.0" });
11
- function text(value) { return { content: [{ type: "text", text: JSON.stringify(value, null, 2) }] }; }
12
- function requirePhrase() { if (!phrase)
13
- throw new Error("SEALKEEP_RECOVERY_PHRASE is required for archive and recovery actions"); return phrase; }
14
- server.registerTool("vaultline_status", {
20
+ /**
21
+ * The phrase, from the env var or from this machine's keystore.
22
+ *
23
+ * Reading ONLY the env var meant an agent's search was metadata-only forever
24
+ * unless the person pasted their recovery phrase in plaintext into the
25
+ * agent's MCP config — the exact place a phrase must not live. The CLI's own
26
+ * commands already resolve env-then-keystore; the MCP now resolves the same
27
+ * way, so a vault that "seals on its own" also searches on its own.
28
+ */
29
+ let phrasePromise = null;
30
+ function phraseAvailable() {
31
+ phrasePromise ??= (async () => {
32
+ const fromEnv = envVar("RECOVERY_PHRASE");
33
+ if (fromEnv)
34
+ return fromEnv;
35
+ try {
36
+ const { readConfig } = await import("./vault.js");
37
+ const { resolveRecoveryPhrase } = await import("./secrets.js");
38
+ const { vaultId } = await readConfig(dataDir);
39
+ return (await resolveRecoveryPhrase(dataDir, vaultId)) ?? undefined;
40
+ }
41
+ catch {
42
+ return undefined;
43
+ }
44
+ })();
45
+ return phrasePromise;
46
+ }
47
+ const server = new McpServer({ name: "sealkeep", version: "0.1.0" });
48
+ function text(value, compact = false) { return { content: [{ type: "text", text: JSON.stringify(value, null, compact ? undefined : 2) }] }; }
49
+ const PROJECT_KEY_RE = /^(?:g1|p1):[a-f0-9]{64}$/;
50
+ /** A confirmed checkout can name its team before it has any local archives.
51
+ * Labels remain hints, never a reason to union distinct stable identities. */
52
+ async function resolveProject(project, projectKey) {
53
+ const records = project ? await listArchives(dataDir) : [];
54
+ if (projectKey && !project) {
55
+ return { records, note: "projectKey must be paired with its display project" };
56
+ }
57
+ if (!project || projectKey)
58
+ return { records, projectKey };
59
+ const config = await readConfig(dataDir);
60
+ const matchingKeys = new Set(records.flatMap((record) => record.version === 2
61
+ && record.source.project === project && record.source.projectKey ? [record.source.projectKey] : []));
62
+ for (const [key, binding] of Object.entries(config.teamSpaces ?? {})) {
63
+ if (PROJECT_KEY_RE.test(key) && binding.localConfirmed === true && binding.localProject === project) {
64
+ matchingKeys.add(key);
65
+ }
66
+ }
67
+ if (matchingKeys.size > 1) {
68
+ return { records, note: `More than one stable project is named ${project}; supply projectKey so their memories are not combined.` };
69
+ }
70
+ const exactProjectKey = [...matchingKeys][0];
71
+ const unconfirmedBinding = Object.entries(config.teamSpaces ?? {}).some(([key, binding]) => binding.localConfirmed !== true && (key === project || binding.localProject === project));
72
+ if (!exactProjectKey && unconfirmedBinding) {
73
+ return { records, note: `No locally confirmed project identity for ${project}; supply project and projectKey from a confirmed checkout.` };
74
+ }
75
+ // No binding evidence is not a refusal: a fresh personal device can have a
76
+ // readable account index without any local archive records or team config.
77
+ return { records, projectKey: exactProjectKey };
78
+ }
79
+ async function defaultWorkspace(project, projectKey, scope) {
80
+ if (project || projectKey)
81
+ return { project, projectKey };
82
+ if (scope === "account")
83
+ return { project, projectKey };
84
+ const config = await readConfig(dataDir);
85
+ const bindings = config.teamSpaces ?? {};
86
+ // A confirmed team project is the ONLY thing that makes an unqualified search
87
+ // ambiguous: which member's project did the agent mean? With none, this is a
88
+ // personal vault, and its own account history is unambiguously the answer.
89
+ // Returning the "resolve a project first" note here instead handed an empty
90
+ // result to the single most important question — "what did I decide before" —
91
+ // which reads exactly like "you never discussed that", the failure the phrase
92
+ // path exists to prevent. So default to account scope unless a real team
93
+ // project could be confused for another.
94
+ const hasConfirmedProject = Object.entries(bindings).some(([key, binding]) => /^(g1|p1):[a-f0-9]{64}$/.test(key) && binding.localConfirmed && Boolean(binding.localProject));
95
+ if (!hasConfirmedProject)
96
+ return { project: undefined, projectKey: undefined };
97
+ let roots;
98
+ if (server.server.getClientCapabilities()?.roots) {
99
+ // Capability advertised but failed/empty roots is not permission to use a
100
+ // possibly unrelated process directory. Ask for an explicit scope instead.
101
+ roots = await server.server.listRoots(undefined, { timeout: 2000 }).then((result) => result.roots, () => []);
102
+ }
103
+ return resolveMcpWorkspace({ roots, cwd: process.cwd(), bindings });
104
+ }
105
+ async function requirePhrase() {
106
+ const phrase = await phraseAvailable();
107
+ if (!phrase)
108
+ throw new Error("No recovery phrase: set SEALKEEP_RECOVERY_PHRASE, or store the phrase in this machine's keystore (Settings → remember, or `sealkeep autopilot`)");
109
+ return phrase;
110
+ }
111
+ /** Registers one tool under one name. */
112
+ function registerTool(name, config,
113
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
114
+ handler) {
115
+ server.registerTool(name, config, handler);
116
+ }
117
+ registerTool("sealkeep_status", {
15
118
  title: "Sealkeep status",
16
119
  description: "Read local Sealkeep health, archive count, and encrypted storage usage. No session content is returned.",
17
120
  inputSchema: {}
18
121
  }, async () => text(await vaultStatus(dataDir)));
19
- server.registerTool("vaultline_search", {
122
+ /**
123
+ * The one tool that justifies an agent having this server at all.
124
+ *
125
+ * It used to substring-match the archive id, the source path and the agent
126
+ * name — which, for a Codex rollout named `rollout-<timestamp>-<uuid>.jsonl`,
127
+ * means an agent could search its own history for a date or a UUID it has no
128
+ * way of knowing. Nothing it would actually ask for could ever match. The
129
+ * capability existed the whole time in the CLI; it was simply never wired to
130
+ * the surface where an agent could reach it.
131
+ *
132
+ * Content mode decrypts locally, needs the recovery phrase, and is the
133
+ * default when one is available — because "what did I decide about retention
134
+ * last week" is the question, and metadata cannot answer it. Without a phrase
135
+ * it degrades to the old metadata match and says so, rather than returning an
136
+ * empty list that reads like "you never discussed that".
137
+ */
138
+ registerTool("sealkeep_search", {
20
139
  title: "Search archived sessions",
21
- description: "Searches local encrypted archive metadata. It returns identifiers and source metadata, never decrypts transcript contents.",
22
- inputSchema: { query: z.string().min(1).max(200), agent: z.string().optional() }
23
- }, async ({ query, agent }) => {
24
- const normalized = query.toLowerCase();
25
- const matches = (await listArchives(dataDir)).filter((item) => (!agent || item.source.agent === agent) && `${item.id} ${item.source.path} ${item.source.agent}`.toLowerCase().includes(normalized));
26
- return text(matches.map(({ id, createdAt, source }) => ({ id, createdAt, agent: source.agent, path: source.path, bytes: source.bytes })));
27
- });
28
- server.registerTool("vaultline_prepare_archive", {
140
+ description: "Searches archived sessions and returns matching quotes plus immutable source/context references. To understand a found decision, pass the hit's context object to sealkeep_read_context; it expands the source, user prompt and neighboring messages without repeated searches. With no recovery phrase, only metadata is searched. Decryption is local.",
141
+ inputSchema: {
142
+ query: z.string().min(1).max(200),
143
+ agent: z.string().optional(),
144
+ archiveId: z.string().uuid().optional()
145
+ .describe("Search only one immutable archive snapshot. This remains available for callers that already know an archive id."),
146
+ sessionId: z.string().uuid().optional()
147
+ .describe("Search every preserved snapshot of this exact Codex session; resume pointers supply it automatically."),
148
+ project: z.string().optional().describe("Explicit project override. Otherwise resolve the current client workspace to its confirmed stable project."),
149
+ scope: z.enum(["workspace", "account"]).optional().describe("Default workspace. Use account only to explicitly search across personal account history without a project filter."),
150
+ projectKey: z.string().regex(/^(?:g1|p1):[a-f0-9]{64}$/).optional()
151
+ .describe("Stable project ref supplied by Sealkeep context; use with project when same-named folders exist."),
152
+ mode: z.enum(["content", "metadata"]).optional()
153
+ }
154
+ }, async ({ query, agent, archiveId, sessionId, project, projectKey, mode, scope }) => {
155
+ const phrase = await phraseAvailable();
156
+ const wanted = mode ?? (phrase ? "content" : "metadata");
157
+ const workspace = await defaultWorkspace(project, projectKey, scope);
158
+ if ("note" in workspace)
159
+ return text({ mode: wanted, ...workspace, hits: [] });
160
+ ({ project, projectKey } = workspace);
161
+ const resolved = await resolveProject(project, projectKey);
162
+ if (resolved.note)
163
+ return text({ mode: wanted, note: resolved.note, hits: [] });
164
+ const { records, projectKey: exactProjectKey } = resolved;
165
+ if (wanted === "metadata" || !phrase) {
166
+ const hits = await search(dataDir, query, {
167
+ mode: "metadata",
168
+ ...(agent ? { agent } : {}),
169
+ ...(archiveId ? { archiveId } : {}),
170
+ ...(sessionId ? { sessionId } : {}),
171
+ });
172
+ const scoped = exactProjectKey
173
+ ? hits.filter((hit) => records.some((record) => record.id === hit.id && record.version === 2 && record.source.projectKey === exactProjectKey))
174
+ : project
175
+ ? hits.filter((hit) => records.some((record) => record.id === hit.id && record.version === 2
176
+ && !record.source.projectKey && record.source.project === project))
177
+ : hits;
178
+ return text({
179
+ mode: "metadata",
180
+ ...(!phrase ? { note: "No recovery phrase on this machine: store it in the keystore (Settings → remember) or set SEALKEEP_RECOVERY_PHRASE; matched on file metadata only." } : {}),
181
+ hits: scoped,
182
+ });
183
+ }
184
+ const hits = await search(dataDir, query, {
185
+ mode: wanted,
186
+ ...(phrase ? { phrase } : {}),
187
+ ...(agent ? { agent } : {}),
188
+ ...(archiveId ? { archiveId } : {}),
189
+ ...(sessionId ? { sessionId } : {}),
190
+ ...(project ? { project } : {}),
191
+ ...(exactProjectKey ? { projectKey: exactProjectKey } : {}),
192
+ });
193
+ return text({ mode: wanted, hits, next: "Pass a hit's context object to sealkeep_read_context to read its decision and surrounding messages." });
194
+ });
195
+ registerTool("sealkeep_read_context", {
196
+ title: "Read context around a search result",
197
+ description: "Pass a search hit's context object to read its immutable source passage, actual user prompt when available, nearby messages and tool relationships. Follow the returned continuation as cursor with the same source to read more. Each call checks current Team access and enforces transfer, decompression and response budgets. This is a local read; it writes no restored transcript.",
198
+ inputSchema: {
199
+ source: sourceSchema, query: z.string().max(200).optional(),
200
+ byteStart: z.number().int().nonnegative().optional(), chunkIndex: z.number().int().nonnegative().optional(),
201
+ cursor: z.object({ sourceSha256: z.string().regex(/^[a-f0-9]{64}$/i), byteStart: z.number().int().nonnegative(), textOffset: z.number().int().nonnegative().optional() }).optional(),
202
+ maxTokens: z.number().int().min(2048).max(32768).optional().describe("Default 8192. Conservative UTF-8 byte ceiling for the complete JSON response, including provenance and continuation; this safely bounds text tokens."),
203
+ maxWireBytes: z.number().int().min(1).max(64 * 1024 * 1024).optional().describe("Default 8 MiB. Aggregate encrypted index and archive payload transfers; small authorization responses are excluded. The buffered index must also fit this ceiling even when cached."),
204
+ maxDecodedBytes: z.number().int().min(1).max(64 * 1024 * 1024).optional().describe("Default 16 MiB. Aggregate decoded index/source-facts plus source/dependency bytes, including chunk padding and rejected cache decode attempts. Team source-facts cache hits decode only generation-bound facts after current head/access checks; a miss verifies the whole checkpoint. index_budget_exceeded means checkpoint work exceeds the budget, not that the cited passage is absent."),
205
+ neighboringMessages: z.number().int().min(0).max(12).optional(),
206
+ }, annotations: { readOnlyHint: true },
207
+ }, async (args) => text(await readContext(dataDir, await requirePhrase(), { ...args, signal: AbortSignal.timeout(30_000) }), true));
208
+ registerTool("sealkeep_prepare_archive", {
29
209
  title: "Archive a session",
30
210
  description: "Encrypts and queues a local transcript for archive. The transcript is encrypted before it reaches any storage provider.",
31
211
  inputSchema: { path: z.string().min(1), agent: z.string().default("custom") },
32
212
  annotations: { destructiveHint: false }
33
- }, async ({ path, agent }) => text(await archiveFile(dataDir, path, requirePhrase(), agent)));
34
- server.registerTool("vaultline_recover", {
213
+ }, async ({ path, agent }) => text(await archiveFile(dataDir, path, await requirePhrase(), agent, { indexInline: true })));
214
+ registerTool("sealkeep_recover", {
35
215
  title: "Recover an archived session",
36
- description: "Decrypts one selected archive into a destination path on this device. This is a local action. It refuses to overwrite an existing file unless the caller explicitly chooses backup or replace.",
216
+ description: "Dry-run by default: reports the archive, byte count, and destination a restore WOULD produce, writing nothing — pass dryRun: false to actually decrypt into the destination path. For a Team result, include the search hit's source reference or its project/projectKey. Team restores currently support up to 64 MiB of transferred and decoded bytes; larger sources report an explicit unsupported limit. Use sealkeep_read_context for a bounded reading without restoring a file. Existing files are refused by default; backup/replace retains the displaced inode.",
37
217
  inputSchema: {
38
218
  archiveId: z.string().uuid(),
219
+ source: sourceSchema.optional(), project: z.string().optional(), projectKey: z.string().optional(),
39
220
  destination: z.string().min(1).optional(),
40
221
  native: z.boolean().default(false).describe("Restore to the adapter's original transcript path instead of a chosen destination"),
41
- overwrite: z.enum(["refuse", "backup", "replace"]).default("refuse")
222
+ overwrite: z.enum(["refuse", "backup", "replace"]).default("refuse"),
223
+ dryRun: z.boolean().default(true).describe("Preview only (the default): nothing is written. Pass false to perform the restore.")
42
224
  },
43
225
  annotations: { destructiveHint: true }
44
- }, async ({ archiveId, destination, native, overwrite }) => {
45
- const outcome = await restoreArchive(dataDir, archiveId, requirePhrase(), { destination, native, overwrite });
46
- return text({ archiveId: outcome.record.id, output: outcome.output, bytes: outcome.bytes, native: outcome.native, backupPath: outcome.backupPath });
226
+ }, async ({ archiveId, source, project, projectKey, destination, native, overwrite, dryRun }) => {
227
+ const outcome = await restoreArchive(dataDir, archiveId, await requirePhrase(), { source, project, projectKey, destination, native, overwrite, dryRun });
228
+ return text({
229
+ archiveId: outcome.record.id, output: outcome.output, bytes: outcome.bytes, native: outcome.native, backupPath: outcome.backupPath,
230
+ ...(outcome.dryRun ? {
231
+ dryRun: true,
232
+ ...(outcome.destinationExists !== undefined ? { destinationExists: outcome.destinationExists } : {}),
233
+ note: "Preview only — nothing was written. Call again with dryRun: false to restore.",
234
+ } : {}),
235
+ });
47
236
  });
48
- server.registerTool("vaultline_create_upload", {
237
+ registerTool("sealkeep_create_upload", {
49
238
  title: "Get upload lease status",
50
239
  description: "Reports the storage lease mechanism used by Sealkeep. In the local MVP provider, no remote URL is necessary; the future hosted control plane returns a short-lived, object-scoped signed URL.",
240
+ // "vaultline" here is the built-in local storage provider's wire identifier, not the product
241
+ // name — every archive record and lease already on disk says "vaultline", so this id does not change.
51
242
  inputSchema: { provider: z.enum(["vaultline", "s3", "r2", "gcs"]).default("vaultline"), bucket: z.string().min(1), prefix: z.string().min(1), ciphertextSha256: z.string().regex(/^[a-f0-9]{64}$/), bytes: z.number().int().positive() }
52
243
  }, async ({ provider, bucket, prefix, ciphertextSha256, bytes }) => text(providers[provider].createUploadLease({ provider: provider, bucket, prefix }, { ciphertextSha256, bytes })));
53
- server.registerTool("vaultline_retention_preview", {
244
+ registerTool("sealkeep_retention_preview", {
54
245
  title: "Preview local session reclamation",
55
246
  description: "Lists old source transcripts that could eventually be reclaimed. This is read-only and always preserves sources until a verified remote archive exists.",
56
247
  inputSchema: { olderThanDays: z.number().min(0).default(30) },
57
248
  annotations: { readOnlyHint: true }
58
249
  }, async ({ olderThanDays }) => text(await previewRetention(dataDir, olderThanDays)));
250
+ /**
251
+ * Realtime Bridge — the tools that let a member's OWN agent follow a shared
252
+ * session live. `sealkeep_bridge_watch` advances this machine's mirror of a
253
+ * channel and hands back the freshly-arrived lines, so an agent can reason
254
+ * about a colleague's ongoing session ("what has Paul's run concluded so far?")
255
+ * while it is still running. `sealkeep_bridge_publish_pass` is the driver-side
256
+ * counterpart for a companion that wants to push the next delta by hand.
257
+ *
258
+ * Read-only over the session: neither tool can drive another person's agent.
259
+ * See docs/realtime-bridge.md for the scope ladder.
260
+ */
261
+ registerTool("sealkeep_bridge_watch", {
262
+ title: "Follow a shared session (bridge)",
263
+ description: "Pulls any new sealed deltas of a live bridge channel, verifies the hash chain, and appends them to this machine's local mirror. Returns the mirror path and the newly-arrived text so your agent can follow a colleague's ongoing session. Needs the recovery phrase to unseal; content is decrypted locally.",
264
+ inputSchema: { channel: z.string().regex(/^[A-Za-z0-9-]{1,64}$/), fromSeq: z.number().int().min(0).default(0) },
265
+ annotations: { readOnlyHint: true }
266
+ }, async ({ channel, fromSeq }) => {
267
+ const phrase = await requirePhrase();
268
+ const mirrorPath = defaultMirrorPath(dataDir, channel);
269
+ const before = await readFile(mirrorPath).then((b) => b.length).catch(() => 0);
270
+ const result = await watchOnce(dataDir, { channel, fromSeq, mirrorPath }, phrase);
271
+ const after = await readFile(mirrorPath).catch(() => Buffer.alloc(0));
272
+ const fresh = after.subarray(before).toString("utf8");
273
+ return text({ channel, mirrorPath, applied: result.applied, nextSeq: result.nextSeq, gaps: result.gaps, needsReset: result.needsReset, newText: fresh });
274
+ });
275
+ registerTool("sealkeep_bridge_publish_pass", {
276
+ title: "Publish the next bridge delta (driver)",
277
+ description: "Driver side: seals the bytes a session file has grown by since a given offset and pushes them as the next delta on a bridge channel. Returns the new high-water offset to pass next time. The transcript is sealed on this machine before anything leaves it.",
278
+ inputSchema: { channel: z.string().regex(/^[A-Za-z0-9-]{1,64}$/), sessionPath: z.string().min(1), seq: z.number().int().min(0), fromByte: z.number().int().min(0).default(0) }
279
+ }, async ({ channel, sessionPath, seq, fromByte }) => {
280
+ const phrase = await requirePhrase();
281
+ const outcome = await publishOnce(dataDir, { channel, seq, sessionPath, fromByte }, phrase);
282
+ return text(outcome ? { published: outcome.ref, seq: outcome.seq, newHighWater: outcome.newHighWater } : { published: null, note: "Nothing new since fromByte." });
283
+ });
284
+ registerTool("sealkeep_team", {
285
+ title: "Who is working on what right now",
286
+ description: "The team awareness feed git lacks: who STARTED what (and has not finished), plus recent finished lines. Call this before taking a task that might overlap a teammate's work — if someone is already on it, coordinate instead of doubling the effort. Presence events are one sealed line each, published by launches and by active-mode seals.",
287
+ inputSchema: {
288
+ project: z.string().optional(),
289
+ scope: z.enum(["workspace", "account"]).optional(),
290
+ projectKey: z.string().regex(PROJECT_KEY_RE).optional()
291
+ .describe("Stable project ref; pair with project to distinguish same-named checkouts."),
292
+ },
293
+ annotations: { readOnlyHint: true }
294
+ }, async ({ project, projectKey, scope }) => {
295
+ const phrase = await requirePhrase();
296
+ const workspace = await defaultWorkspace(project, projectKey, scope);
297
+ if ("note" in workspace)
298
+ return text({ activeNow: [], recent: [], ...workspace });
299
+ ({ project, projectKey } = workspace);
300
+ const resolved = await resolveProject(project, projectKey);
301
+ if (resolved.note)
302
+ return text({ activeNow: [], recent: [], note: resolved.note });
303
+ const { listPresence, activeNow } = await import("./presence.js");
304
+ const events = await listPresence(dataDir, phrase, { project, projectKey: resolved.projectKey, limit: 25 });
305
+ return text({ activeNow: activeNow(events), recent: events.slice(0, 12) });
306
+ });
307
+ registerTool("sealkeep_bridge_push", {
308
+ title: "Speak into a shared bridge channel",
309
+ description: "Sends a labeled message to every member of a live bridge channel — the collaboration write-path (anthropics/claude-code#60082): a follower's suggestion reaches the driver's agent on its next turn. Sealed on this machine before it leaves; kinds: suggest, note, handoff.",
310
+ inputSchema: { channel: z.string().regex(/^[A-Za-z0-9-]{1,64}$/), message: z.string().min(1), kind: z.enum(["suggest", "note", "handoff"]).default("suggest") }
311
+ }, async ({ channel, message, kind }) => {
312
+ const phrase = await requirePhrase();
313
+ const outcome = await publishBridgeMessage(dataDir, { channel, kind, text: message }, phrase);
314
+ return text({ pushed: outcome.ref, from: outcome.message.from, at: outcome.message.at });
315
+ });
316
+ registerTool("sealkeep_bridge_inbox", {
317
+ title: "Read teammates' bridge messages",
318
+ description: "Pulls new teammate messages on a bridge channel and returns the ones not yet delivered to this agent, oldest first, with a ready-to-use context block. This is the pull half of the collaboration lane — call it between tasks when following or driving a shared session (Codex drivers use this; Claude drivers usually get the same content injected by the UserPromptSubmit hook).",
319
+ inputSchema: { channel: z.string().regex(/^[A-Za-z0-9-]{1,64}$/) }
320
+ }, async ({ channel }) => {
321
+ const phrase = await requirePhrase();
322
+ await pullBridgeMessages(dataDir, channel, phrase);
323
+ const pending = await drainPending(dataDir, channel);
324
+ const { pendingCount } = await import("./bridge.js");
325
+ const waiting = await pendingCount(dataDir, channel);
326
+ return text({ channel, messages: pending, stillWaiting: waiting, context: promptInjection(pending, waiting) || null });
327
+ });
59
328
  const transport = new StdioServerTransport();
60
329
  server.connect(transport).catch((error) => { console.error(error); process.exit(1); });
@@ -1,6 +1,6 @@
1
1
  import { readFile, rename, rm, writeFile } from "node:fs/promises";
2
2
  import { randomUUID } from "node:crypto";
3
- import { decryptArchive as openEnvelope, encryptArchive as sealEnvelope, rewrapArchive } from "../packages/vaultline-crypto/src/index.js";
3
+ import { decryptArchive as openEnvelope, encryptArchive as sealEnvelope, rewrapArchive } from "../packages/sealkeep-crypto/src/index.js";
4
4
  import { equalHex, matchesPhraseCheck, sha256 } from "./crypto.js";
5
5
  import { canonicalPhrase } from "./mnemonic.js";
6
6
  import { fail } from "./errors.js";
@@ -70,41 +70,47 @@ export async function rewrapVault(dataDir, rawPhrase, options = {}) {
70
70
  }
71
71
  if (!matchesPhraseCheck(config.recovery.phraseCheck, phrase))
72
72
  fail("recovery_phrase_mismatch", "Recovery phrase does not match this vault");
73
- const recipients = configuredRecipients(config, phrase, { group: options.group, project: options.project });
74
73
  const records = await listArchives(dataDir);
75
74
  const rewrapped = [];
76
75
  const skipped = [];
77
76
  // Extending a project's membership must not touch archives of other
78
77
  // projects: rewrapping those would either add the member (leaking work they
79
78
  // were never given) or drop them (silently revoking elsewhere).
80
- const projectOf = options.project
81
- ? await (async () => {
82
- const { claudeProjectFromPath, codexProjectFromRollout } = await import("./adapters.js");
83
- return async (record) => {
84
- try {
85
- if (record.source.agent === "claude")
86
- return claudeProjectFromPath(record.source.path).project;
87
- if (record.source.agent === "codex")
88
- return (await codexProjectFromRollout(record.source.path, record.createdAt)).project;
89
- }
90
- catch { /* unreadable project is not a match */ }
91
- return null;
92
- };
93
- })()
94
- : null;
79
+ const { projectOfRecord } = await import("./adapters.js");
80
+ /**
81
+ * Recipients are resolved PER RECORD, from that record's own project.
82
+ *
83
+ * One list computed for the whole vault dropped every project-scoped
84
+ * recipient the moment a rewrap ran without --project — which is the normal
85
+ * case, revoking an unrelated device or retiring a rotated phrase. Every
86
+ * project member then silently lost access to their project's existing
87
+ * archives, with a success message. The scoped-shard writer already resolved
88
+ * membership per project; this now does the same.
89
+ */
90
+ const recipientsFor = new Map();
91
+ const listFor = (project) => {
92
+ const key = project ?? "";
93
+ let list = recipientsFor.get(key);
94
+ if (!list) {
95
+ list = configuredRecipients(config, phrase, { group: options.group, ...(project ? { project } : {}) });
96
+ recipientsFor.set(key, list);
97
+ }
98
+ return list;
99
+ };
95
100
  for (const record of records) {
96
101
  if (!isV2(record)) {
97
102
  skipped.push({ id: record.id, reason: "v1 archive; run `sealkeep migrate` first" });
98
103
  continue;
99
104
  }
100
- if (projectOf && await projectOf(record) !== options.project) {
105
+ const recordProject = await projectOfRecord(record).catch(() => null);
106
+ if (options.project && recordProject !== options.project) {
101
107
  skipped.push({ id: record.id, reason: `belongs to another project` });
102
108
  continue;
103
109
  }
104
- const envelope = rewrapArchive(record.envelope, { phrase }, recipients);
110
+ const envelope = rewrapArchive(record.envelope, { phrase }, listFor(recordProject));
105
111
  await writeRecord(config.storage.root, { ...record, envelope });
106
112
  rewrapped.push(record.id);
107
113
  }
108
- await recordAudit(dataDir, "archive.rewrap", "allowed", { rewrapped: rewrapped.length, skipped: skipped.length, recipients: recipients.length, group: options.group ?? null });
109
- return { rewrapped: rewrapped.length, skipped, recipients: recipients.length };
114
+ await recordAudit(dataDir, "archive.rewrap", "allowed", { rewrapped: rewrapped.length, skipped: skipped.length, recipients: listFor(null).length, group: options.group ?? null });
115
+ return { rewrapped: rewrapped.length, skipped, recipients: listFor(null).length };
110
116
  }
@@ -33,7 +33,7 @@ export type TickSummary = {
33
33
  * Decides whether a round of work is worth interrupting someone for.
34
34
  *
35
35
  * Routine sealing is not: it happens constantly and saying so would train people to
36
- * ignore the product. Reclaimed disk is, because a file moved. A failure is, because
36
+ * ignore the product. Reclaimed disk is, because local blocks were actually released. A failure is, because
37
37
  * it needs a decision.
38
38
  */
39
39
  export declare function summarize(tick: TickSummary): Notification | null;
@@ -53,7 +53,7 @@ function bytes(value) {
53
53
  * Decides whether a round of work is worth interrupting someone for.
54
54
  *
55
55
  * Routine sealing is not: it happens constantly and saying so would train people to
56
- * ignore the product. Reclaimed disk is, because a file moved. A failure is, because
56
+ * ignore the product. Reclaimed disk is, because local blocks were actually released. A failure is, because
57
57
  * it needs a decision.
58
58
  */
59
59
  export function summarize(tick) {
@@ -62,11 +62,11 @@ export function summarize(tick) {
62
62
  }
63
63
  if (tick.reclaimed > 0) {
64
64
  const freed = tick.freedBytes ? ` and freed ${bytes(tick.freedBytes)}` : "";
65
- return { title: "Disk reclaimed", body: `Moved ${tick.reclaimed} archived session${tick.reclaimed === 1 ? "" : "s"} to the trash${freed}. The archives are safe.` };
66
- }
67
- if (tick.archived >= 5) {
68
- return { title: "Sessions preserved", body: `Sealed ${tick.archived} agent sessions${tick.uploaded ? `, ${tick.uploaded} copied off this machine` : ""}.` };
65
+ return { title: "Disk reclaimed", body: `Freed the local originals for ${tick.reclaimed} archived session${tick.reclaimed === 1 ? "" : "s"}${freed}. Their verified encrypted archives remain stored.` };
69
66
  }
67
+ // Routine sealing never notifies. The docstring above says exactly that —
68
+ // "never for routine sealing, which would just train you to ignore it" — and
69
+ // a branch three lines below it fired on five ordinary seals anyway.
70
70
  return null;
71
71
  }
72
72
  /** Keeps a quiet period between notifications so a busy machine cannot spam. */