sealkeep 0.11.0 → 0.11.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,50 @@
3
3
  Notable changes, by published version. Sealkeep is pre-1.0: minor versions
4
4
  may change behavior, and say so here when they do.
5
5
 
6
+ ## 0.11.2 — 2026-09-18 — a release tells you when a newer one exists
7
+
8
+ - Sealkeep says, once a day at most, when a newer release is on npm and what
9
+ to run. A person finds out from the tool they already have open, or not at
10
+ all: 0.11.0 sat on the registry for two days unable to open a 0.11.1 vault's
11
+ predecessor, and nobody running it could learn the fix existed. The note
12
+ prints after the command's own output, never before it and never instead of
13
+ it, including when the command failed. The answer is cached per machine, so
14
+ a scripted loop makes no traffic; a failed check counts as the day's check,
15
+ so an offline machine stops asking. It is silent on every failure, skipped
16
+ for `--json`, hooks, the daemon, the MCP server and any non-terminal output,
17
+ and turned off for good with `SEALKEEP_NO_UPDATE_CHECK=1` (or the
18
+ conventional `NO_UPDATE_NOTIFIER=1`). Nothing is sent: it reads the same
19
+ public package metadata `npm view` reads.
20
+
21
+ ## 0.11.1 — 2026-09-18 — an upgrade keeps what you already sealed
22
+
23
+ Found by installing the published 0.9.0 from npm, building a vault with it,
24
+ and upgrading in place to the published 0.11.0.
25
+
26
+ - **Archives sealed before the rename open again.** A recipient is located in
27
+ an envelope by hashing a label with its salt, and renaming the product from
28
+ Vaultline to Sealkeep changed that label — so every archive written by 0.9.0
29
+ or earlier answered "no recipient could be opened with the supplied secret"
30
+ under 0.11.0, with the right phrase and intact bytes. Nothing was lost and
31
+ nothing was corrupt; the key could not be found to try. Both names are now
32
+ accepted when opening, forever, and only the current one is written. Device
33
+ keys were affected the same way, through their identifier and their key
34
+ exchange, and are fixed with them.
35
+ - **Content search survives the upgrade.** The sealed index was named
36
+ `content-index.vlindex` before the rename and is read as
37
+ `content-index.skindex` now, so search answered "no local content index"
38
+ while `index status` and `sealkeep doctor` — which read the coverage file,
39
+ whose name never changed — both insisted every archive was searchable. The
40
+ index is adopted under its current name on first use, so a search, a status
41
+ and a build all see the work the previous release did instead of silently
42
+ discarding it.
43
+ - `sealkeep doctor` fails when an agent's memory hooks are installed and the
44
+ service that prepares memory ran and then stopped. The hooks fail open by
45
+ design, so that combination gives every session no memory and no reason; it
46
+ happened on a test machine whose daemon had died, and nothing said so. A
47
+ vault that never started the service is left alone: archiving by hand is a
48
+ complete way to use this.
49
+
6
50
  ## 0.11.0 — 2026-09-16 — automatic memory that works, and an append-only index
7
51
 
8
52
  **Automatic recall now works on a real, long-lived vault.** The hook lane
@@ -4,6 +4,7 @@ export declare class CryptoError extends Error {
4
4
  readonly code: string;
5
5
  constructor(code: string, message: string);
6
6
  }
7
+ export declare function legacyKeyRecipientId(publicKeyRaw: Buffer): string;
7
8
  /** Overwrites key material in place. Best effort: it cannot reach copies the runtime made. */
8
9
  export declare function zeroize(...buffers: Buffer[]): void;
9
10
  export declare function derivePhraseKey(phrase: string, salt: Buffer, params?: ScryptParams): Buffer;
@@ -11,6 +11,29 @@ export class CryptoError extends Error {
11
11
  }
12
12
  const b64 = (value) => value.toString("base64");
13
13
  const unb64 = (value) => Buffer.from(value, "base64");
14
+ /**
15
+ * Recipient identifiers, and the names this product used to publish under.
16
+ *
17
+ * A recipient is found in an envelope by hashing a label with its salt or its
18
+ * public key, so the label is part of the on-disk format: renaming the product
19
+ * from Vaultline to Sealkeep changed every identifier, and an archive sealed
20
+ * by the older release stopped matching any recipient at all. Its bytes are
21
+ * fine and its phrase is right; nothing could find the key to try. Both names
22
+ * are therefore accepted when opening, forever, and only the current one is
23
+ * ever written.
24
+ */
25
+ const PHRASE_RECIPIENT_LABEL = "sealkeep-phrase-recipient:v2";
26
+ const LEGACY_PHRASE_RECIPIENT_LABEL = "vaultline-phrase-recipient:v2";
27
+ const KEY_RECIPIENT_LABEL = "sealkeep-key-recipient:v2";
28
+ const LEGACY_KEY_RECIPIENT_LABEL = "vaultline-key-recipient:v2";
29
+ const KEY_EXCHANGE_LABEL = "sealkeep-recipient:v2";
30
+ const LEGACY_KEY_EXCHANGE_LABEL = "vaultline-recipient:v2";
31
+ function legacyPhraseRecipientId(salt) {
32
+ return createHash("sha256").update(Buffer.concat([Buffer.from(LEGACY_PHRASE_RECIPIENT_LABEL), salt])).digest("hex").slice(0, 32);
33
+ }
34
+ export function legacyKeyRecipientId(publicKeyRaw) {
35
+ return createHash("sha256").update(Buffer.concat([Buffer.from(LEGACY_KEY_RECIPIENT_LABEL), publicKeyRaw])).digest("hex").slice(0, 32);
36
+ }
14
37
  /** Overwrites key material in place. Best effort: it cannot reach copies the runtime made. */
15
38
  export function zeroize(...buffers) {
16
39
  for (const buffer of buffers)
@@ -38,10 +61,10 @@ function openKey(suite, kek, nonce, sealed, aad) {
38
61
  }
39
62
  }
40
63
  export function phraseRecipientId(salt) {
41
- return createHash("sha256").update(Buffer.concat([Buffer.from("sealkeep-phrase-recipient:v2"), salt])).digest("hex").slice(0, 32);
64
+ return createHash("sha256").update(Buffer.concat([Buffer.from(PHRASE_RECIPIENT_LABEL), salt])).digest("hex").slice(0, 32);
42
65
  }
43
66
  export function keyRecipientId(publicKeyRaw) {
44
- return createHash("sha256").update(Buffer.concat([Buffer.from("sealkeep-key-recipient:v2"), publicKeyRaw])).digest("hex").slice(0, 32);
67
+ return createHash("sha256").update(Buffer.concat([Buffer.from(KEY_RECIPIENT_LABEL), publicKeyRaw])).digest("hex").slice(0, 32);
45
68
  }
46
69
  export function rawPublicKey(key) {
47
70
  // The last 32 bytes of an X25519 SPKI DER encoding are the raw public key.
@@ -94,7 +117,12 @@ export function unwrapArchiveKey(wrappedKeys, suite, archiveId, unlock) {
94
117
  for (const wrapped of wrappedKeys) {
95
118
  if ("phrase" in unlock && wrapped.type === "phrase") {
96
119
  const salt = unb64(wrapped.kdf.salt);
97
- if (!timingSafeEqual(Buffer.from(phraseRecipientId(salt)), Buffer.from(wrapped.id))) {
120
+ // Either name: an archive sealed before the rename carries the legacy
121
+ // identifier, and refusing it there loses the archive to its own owner.
122
+ const stored = Buffer.from(wrapped.id);
123
+ const matches = timingSafeEqual(Buffer.from(phraseRecipientId(salt)), stored)
124
+ || timingSafeEqual(Buffer.from(legacyPhraseRecipientId(salt)), stored);
125
+ if (!matches) {
98
126
  failures.push("phrase recipient id mismatch");
99
127
  continue;
100
128
  }
@@ -112,16 +140,28 @@ export function unwrapArchiveKey(wrappedKeys, suite, archiveId, unlock) {
112
140
  if ("privateKey" in unlock && wrapped.type === "x25519") {
113
141
  const privateKey = typeof unlock.privateKey === "string" ? createPrivateKey(unlock.privateKey) : unlock.privateKey;
114
142
  const shared = diffieHellman({ privateKey, publicKey: x25519PublicKeyFromRaw(unb64(wrapped.ephemeralPublicKey)) });
115
- const kek = Buffer.from(hkdfSync("sha256", shared, Buffer.concat([unb64(wrapped.recipientPublicKey), unb64(wrapped.ephemeralPublicKey)]), Buffer.from("sealkeep-recipient:v2"), KEY_BYTES));
116
- try {
117
- return openKey(suite, kek, unb64(wrapped.nonce), unb64(wrapped.ciphertext), wrapAad(archiveId, wrapped.id));
118
- }
119
- catch (error) {
120
- failures.push(error.code);
121
- }
122
- finally {
123
- zeroize(kek, shared);
143
+ const info = Buffer.concat([unb64(wrapped.recipientPublicKey), unb64(wrapped.ephemeralPublicKey)]);
144
+ // The key-exchange label moved with the rename too, so a device key
145
+ // wrapped by the older release derives a different key-encryption key.
146
+ // Try today's, then the one that was published before it.
147
+ let opened;
148
+ for (const label of [KEY_EXCHANGE_LABEL, LEGACY_KEY_EXCHANGE_LABEL]) {
149
+ const kek = Buffer.from(hkdfSync("sha256", shared, info, Buffer.from(label), KEY_BYTES));
150
+ try {
151
+ opened = openKey(suite, kek, unb64(wrapped.nonce), unb64(wrapped.ciphertext), wrapAad(archiveId, wrapped.id));
152
+ }
153
+ catch (error) {
154
+ failures.push(error.code);
155
+ }
156
+ finally {
157
+ zeroize(kek);
158
+ }
159
+ if (opened)
160
+ break;
124
161
  }
162
+ zeroize(shared);
163
+ if (opened)
164
+ return opened;
125
165
  }
126
166
  }
127
167
  throw new CryptoError("no_recipient", `No recipient could be opened with the supplied secret (${failures.length} attempted)`);
package/dist/src/cli.js CHANGED
@@ -274,6 +274,27 @@ function usage() {
274
274
  `${dim("Docs:")} README.md ${dim("·")} ${dim("Security:")} THREAT_MODEL.md\n`
275
275
  ].join("\n");
276
276
  }
277
+ /**
278
+ * This install's own package manifest. The file sits one level up from
279
+ * dist/src but two from src/, so a fixed relative path is right in exactly one
280
+ * of development and the published package: walk up until our own is found.
281
+ */
282
+ async function ownManifest() {
283
+ const { readFile } = await import("node:fs/promises");
284
+ const { dirname, join } = await import("node:path");
285
+ const { fileURLToPath } = await import("node:url");
286
+ let dir = dirname(fileURLToPath(import.meta.url));
287
+ for (let up = 0; up < 5; up += 1) {
288
+ try {
289
+ const found = JSON.parse(await readFile(join(dir, "package.json"), "utf8"));
290
+ if (found?.name === "sealkeep" || found?.name === "vaultline")
291
+ return found;
292
+ }
293
+ catch { /* keep walking */ }
294
+ dir = dirname(dir);
295
+ }
296
+ return null;
297
+ }
277
298
  async function main() {
278
299
  const [command, ...args] = process.argv.slice(2);
279
300
  const json = args.includes("--json");
@@ -284,20 +305,7 @@ async function main() {
284
305
  // The manifest sits one level up from dist/src but two from src/, so a fixed
285
306
  // relative path is right in exactly one of dev and the published package.
286
307
  // Walk up until we find our own manifest instead.
287
- const { readFile } = await import("node:fs/promises");
288
- const { dirname, join } = await import("node:path");
289
- const { fileURLToPath } = await import("node:url");
290
- let dir = dirname(fileURLToPath(import.meta.url));
291
- let manifest = null;
292
- for (let up = 0; up < 5 && !manifest; up += 1) {
293
- try {
294
- const found = JSON.parse(await readFile(join(dir, "package.json"), "utf8"));
295
- if (found?.name === "sealkeep" || found?.name === "vaultline")
296
- manifest = found;
297
- }
298
- catch { /* keep walking */ }
299
- dir = dirname(dir);
300
- }
308
+ const manifest = await ownManifest();
301
309
  if (!manifest)
302
310
  fail("internal", "Could not read the Sealkeep package manifest");
303
311
  print(json ? JSON.stringify({ name: manifest.name, version: manifest.version }, null, 2) : `${manifest.name} ${manifest.version}`);
@@ -3682,7 +3690,38 @@ const HINTS = {
3682
3690
  // copy, if one exists, is unaffected and `sealkeep verify` proves it.
3683
3691
  ciphertext_integrity_failed: "the local sealed file is damaged; nothing was written. If this archive has a stored copy, `sealkeep verify` checks it and `sealkeep open <ref> <destination>` reads it back"
3684
3692
  };
3685
- main().catch((error) => {
3693
+ /**
3694
+ * One line, after the command's own output, when a newer release exists.
3695
+ *
3696
+ * It runs last on purpose: a person came here to do something, and an
3697
+ * announcement that delays or replaces their answer is worse than no
3698
+ * announcement. Anything that goes wrong here — offline, blocked registry,
3699
+ * unreadable manifest — is silence, never an error and never an exit code.
3700
+ * Machine-readable runs, hooks and the daemon are excluded: nothing may
3701
+ * appear in output another program parses or in a lane nobody watches.
3702
+ */
3703
+ async function noteNewerRelease() {
3704
+ const command = process.argv[2];
3705
+ const args = process.argv.slice(3);
3706
+ if (process.argv.includes("--json") || !process.stdout.isTTY)
3707
+ return;
3708
+ if (command === undefined || ["hook", "daemon", "mcp", "api", "--version", "-v", "version"].includes(command))
3709
+ return;
3710
+ try {
3711
+ const manifest = await ownManifest();
3712
+ const current = manifest?.version;
3713
+ if (!current)
3714
+ return;
3715
+ const dataDirFlag = args.indexOf("--data-dir");
3716
+ const dataDir = (dataDirFlag >= 0 ? args[dataDirFlag + 1] : undefined) ?? envVar("DATA_DIR") ?? defaultDataDir();
3717
+ const { newerReleaseThan, updateNotice } = await import("./release-check.js");
3718
+ const latest = await newerReleaseThan(current, dataDir);
3719
+ if (latest)
3720
+ console.error(`\n ${hint(updateNotice(latest, current))}\n`);
3721
+ }
3722
+ catch { /* a version check must never be the thing that fails */ }
3723
+ }
3724
+ main().then(noteNewerRelease, (error) => {
3686
3725
  const { error: payload } = errorPayload(error);
3687
3726
  console.error(`\n ${mark.fail()} ${payload.message}`);
3688
3727
  const suggestion = HINTS[payload.code];
@@ -3690,4 +3729,5 @@ main().catch((error) => {
3690
3729
  console.error(` ${hint(suggestion)}`);
3691
3730
  console.error(` ${dim(payload.code)}\n`);
3692
3731
  process.exitCode = 1;
3732
+ return noteNewerRelease();
3693
3733
  });
@@ -331,10 +331,26 @@ export async function runDoctor(dataDir, env = process.env, home = env.HOME ?? e
331
331
  // Hooks fail open, so a recall lane whose passes keep dying is invisible at
332
332
  // the agent: memory is simply absent. The service's heartbeat is the one
333
333
  // place that records it.
334
- const recallLane = (await readHeartbeat(dataDir))?.context;
334
+ const recallBeat = await readHeartbeat(dataDir);
335
+ const recallLane = recallBeat?.context;
336
+ const recallService = liveness(recallBeat);
335
337
  const { readHookUnlockFailure } = await import("./agent-context.js");
336
338
  const unlockFailure = await readHookUnlockFailure(dataDir);
337
- if (unlockFailure && Date.now() - Date.parse(unlockFailure.at) < 24 * 60 * 60_000) {
339
+ // A stopped service is the quietest failure of all. The hooks still run and
340
+ // still fail open, so every session simply gets no memory and no reason —
341
+ // exactly what happened on a rehearsal machine whose daemon had died: the
342
+ // requests queued and nobody drained them. Only worth saying when the hooks
343
+ // are installed, because a vault archived by hand has nothing to prepare.
344
+ // Only a service that RAN and stopped: a vault that never started one is
345
+ // being used by hand, which this product supports and says so elsewhere.
346
+ const hooksExpectRecall = supported.some((agent) => agent.hooksInstalled && !agent.hooksAimedElsewhere);
347
+ if (hooksExpectRecall && recallService.state === "stopped") {
348
+ checks.push({
349
+ name: "automatic-recall", status: "fail",
350
+ detail: `Your agents' memory hooks are installed, but the service that prepares memory is not running (${recallService.detail.replace(/^Not running\. /, "").replace(/^It /, "it ")}). Until it runs, every session starts with no memory and no error. Start it from Settings › Automatic archiving, or run \`sealkeep daemon\`.`,
351
+ });
352
+ }
353
+ else if (unlockFailure && Date.now() - Date.parse(unlockFailure.at) < 24 * 60 * 60_000) {
338
354
  checks.push({
339
355
  name: "automatic-recall", status: "fail",
340
356
  detail: `The ${unlockFailure.agent === "codex" ? "Codex" : "Claude Code"} memory hook could not unlock this vault at ${unlockFailure.at.slice(0, 16).replace("T", " ")}: automatic recall and hook capture were silently off for that session, while archiving may have kept working. The hook ran with SEALKEEP_SECRET_BACKEND ${unlockFailure.backend ? `= ${unlockFailure.backend}` : "unset"}; the hooks and the background service must find the recovery phrase in the same secret backend (Settings › Unlocking, or \`sealkeep autopilot\`).`,
@@ -0,0 +1,11 @@
1
+ /**
2
+ * The version to suggest, or null. Never throws, never blocks longer than its
3
+ * own timeout, and answers from cache when today's check already happened.
4
+ */
5
+ export declare function newerReleaseThan(currentVersion: string, dataDir: string, options?: {
6
+ now?: () => number;
7
+ env?: NodeJS.ProcessEnv;
8
+ fetchLatest?: (signal: AbortSignal) => Promise<string | null>;
9
+ }): Promise<string | null>;
10
+ /** The one line a person sees. Kept to a fact and the command that acts on it. */
11
+ export declare function updateNotice(latest: string, current: string): string;
@@ -0,0 +1,117 @@
1
+ /**
2
+ * "A newer Sealkeep is out."
3
+ *
4
+ * Shipping 0.11.0 taught us why this is worth having: a release that could not
5
+ * open archives written by its predecessor sat on the registry for two days,
6
+ * and nobody running it had any way to learn that the fix existed. A person
7
+ * finds out from the tool they already have open, or not at all.
8
+ *
9
+ * The rules this follows, because a version check is a network call the user
10
+ * did not ask for:
11
+ * - never before the command runs, and never in its way: the check is a
12
+ * background read of a cached answer, and the note prints after the work;
13
+ * - at most one request a day, per machine, recorded in the vault's runtime
14
+ * directory, so a scripted loop cannot turn into a traffic generator;
15
+ * - never for a machine-readable run (`--json`), a hook, or a daemon;
16
+ * - silent on every failure. An offline laptop, a blocked registry and a
17
+ * proxy that answers with HTML all mean "no note", never an error;
18
+ * - nothing is sent but the request itself: no vault id, no identifiers, no
19
+ * telemetry. It reads the same public metadata `npm view` reads, and can be
20
+ * turned off for good with SEALKEEP_NO_UPDATE_CHECK=1.
21
+ */
22
+ import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
23
+ import { randomUUID } from "node:crypto";
24
+ import { join } from "node:path";
25
+ const REGISTRY_URL = "https://registry.npmjs.org/sealkeep/latest";
26
+ const CHECK_INTERVAL_MS = 24 * 60 * 60_000;
27
+ const REQUEST_TIMEOUT_MS = 2_000;
28
+ const statePath = (dataDir) => join(dataDir, "runtime", "latest-release.json");
29
+ /** A dotted release, compared numerically; anything unparseable sorts lowest. */
30
+ function isNewer(candidate, current) {
31
+ const parts = (value) => {
32
+ const core = value.split(/[-+]/, 1)[0] ?? "";
33
+ const numbers = core.split(".").map((piece) => Number.parseInt(piece, 10));
34
+ return numbers.length === 3 && numbers.every((piece) => Number.isInteger(piece) && piece >= 0) ? numbers : null;
35
+ };
36
+ const left = parts(candidate);
37
+ const right = parts(current);
38
+ if (!left || !right)
39
+ return false;
40
+ for (let index = 0; index < 3; index += 1) {
41
+ if (left[index] !== right[index])
42
+ return left[index] > right[index];
43
+ }
44
+ // A pre-release of the same numbers is not an upgrade to offer.
45
+ return false;
46
+ }
47
+ async function readCached(dataDir) {
48
+ try {
49
+ const raw = JSON.parse(await readFile(statePath(dataDir), "utf8"));
50
+ if (typeof raw.version !== "string" || typeof raw.at !== "string")
51
+ return null;
52
+ return { version: raw.version, at: raw.at };
53
+ }
54
+ catch {
55
+ return null;
56
+ }
57
+ }
58
+ async function writeCached(dataDir, value) {
59
+ const target = statePath(dataDir);
60
+ const temp = `${target}.${randomUUID()}.tmp`;
61
+ try {
62
+ await mkdir(join(dataDir, "runtime"), { recursive: true, mode: 0o700 });
63
+ await writeFile(temp, JSON.stringify(value, null, 2) + "\n", { mode: 0o600 });
64
+ await rename(temp, target);
65
+ }
66
+ catch {
67
+ // A read-only or full disk must never cost a command its result.
68
+ }
69
+ finally {
70
+ await rm(temp, { force: true }).catch(() => undefined);
71
+ }
72
+ }
73
+ async function fetchLatest(signal) {
74
+ try {
75
+ const response = await fetch(REGISTRY_URL, { signal, headers: { accept: "application/vnd.npm.install-v1+json, application/json" } });
76
+ if (!response.ok)
77
+ return null;
78
+ const body = await response.json();
79
+ return typeof body.version === "string" && /^\d+\.\d+\.\d+/.test(body.version) ? body.version : null;
80
+ }
81
+ catch {
82
+ return null;
83
+ }
84
+ }
85
+ /**
86
+ * The version to suggest, or null. Never throws, never blocks longer than its
87
+ * own timeout, and answers from cache when today's check already happened.
88
+ */
89
+ export async function newerReleaseThan(currentVersion, dataDir, options = {}) {
90
+ const env = options.env ?? process.env;
91
+ if (env.SEALKEEP_NO_UPDATE_CHECK === "1" || env.NO_UPDATE_NOTIFIER === "1" || env.CI === "true")
92
+ return null;
93
+ const now = options.now ?? Date.now;
94
+ const cached = await readCached(dataDir);
95
+ const fresh = cached && now() - Date.parse(cached.at) < CHECK_INTERVAL_MS;
96
+ if (fresh)
97
+ return isNewer(cached.version, currentVersion) ? cached.version : null;
98
+ const controller = new AbortController();
99
+ const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
100
+ let latest;
101
+ try {
102
+ latest = await (options.fetchLatest ?? fetchLatest)(controller.signal);
103
+ }
104
+ finally {
105
+ clearTimeout(timer);
106
+ }
107
+ // A failed check is recorded as "asked today" too: an offline machine must
108
+ // not retry on every single command.
109
+ await writeCached(dataDir, { version: latest ?? cached?.version ?? currentVersion, at: new Date(now()).toISOString() });
110
+ if (!latest)
111
+ return null;
112
+ return isNewer(latest, currentVersion) ? latest : null;
113
+ }
114
+ /** The one line a person sees. Kept to a fact and the command that acts on it. */
115
+ export function updateNotice(latest, current) {
116
+ return `Sealkeep ${latest} is available (you have ${current}). Update with: npm i -g sealkeep@latest`;
117
+ }
@@ -75,6 +75,33 @@ function supersededArchiveIds(records) {
75
75
  export const INDEX_EXTRACTION_POLICY = 3;
76
76
  const indexPath = (dataDir) => join(dataDir, "index", "content-index.skindex");
77
77
  const envelopePath = (dataDir) => join(dataDir, "index", "content-index.json");
78
+ /** What releases up to 0.9.0 named the sealed index, before the rename. */
79
+ const legacyIndexPath = (dataDir) => join(dataDir, "index", "content-index.vlindex");
80
+ /**
81
+ * Adopts an index file left by a pre-rename release.
82
+ *
83
+ * Only the NAME changed: the envelope beside it, the coverage file and the
84
+ * ciphertext are all still current. Upgrading therefore made content search
85
+ * answer "no local content index" while `index status` and doctor, which read
86
+ * coverage.json, kept insisting every archive was searchable — and because
87
+ * coverage looked complete, a rebuild believed it had nothing to do. The file
88
+ * is renamed on first use, which is why this is safe to call from a read.
89
+ */
90
+ async function adoptLegacyIndexFile(dataDir) {
91
+ const current = indexPath(dataDir);
92
+ if (await stat(current).then(() => true, () => false))
93
+ return false;
94
+ const legacy = legacyIndexPath(dataDir);
95
+ if (!(await stat(legacy).then((info) => info.isFile(), () => false)))
96
+ return false;
97
+ try {
98
+ await rename(legacy, current);
99
+ return true;
100
+ }
101
+ catch {
102
+ return false;
103
+ }
104
+ }
78
105
  const coveragePath = (dataDir) => join(dataDir, "index", "coverage.json");
79
106
  const INDEX_TEMP_STALE_MS = 24 * 60 * 60_000;
80
107
  const OWNED_INDEX_TEMP = /^(content-index\.(?:skindex|json)|coverage\.json|(?:account|team)-publication\.pending\.json)\.[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.tmp$/;
@@ -799,6 +826,7 @@ export async function buildContentIndex(dataDir, rawPhrase, options = {}) {
799
826
  };
800
827
  await readConfig(dataDir);
801
828
  await cleanupStaleContentIndexTemps(dataDir);
829
+ await adoptLegacyIndexFile(dataDir);
802
830
  const records = await listArchives(dataDir);
803
831
  // Segments mode is decided once, up front: a vault either has a manifest
804
832
  // with at least one segment (sealkeep index migrate created it) or it
@@ -3885,6 +3913,7 @@ export async function loadContentIndex(dataDir, rawPhrase, options = {}) {
3885
3913
  options.signal?.throwIfAborted();
3886
3914
  const phrase = canonicalPhrase(rawPhrase);
3887
3915
  const identity = await localIndexFileIdentity(dataDir);
3916
+ await adoptLegacyIndexFile(dataDir);
3888
3917
  const envelope = await readFile(envelopePath(dataDir), "utf8").then((raw) => JSON.parse(raw)).catch(() => null);
3889
3918
  const ciphertextPath = indexPath(dataDir);
3890
3919
  const exists = await stat(ciphertextPath).then((entry) => entry.isFile()).catch(() => false);
@@ -4955,6 +4984,9 @@ async function rangedQueryIndex(dataDir, phrase, terms, options) {
4955
4984
  }
4956
4985
  export async function loadContentIndexForQuery(dataDir, rawPhrase, terms, options = {}) {
4957
4986
  const phrase = canonicalPhrase(rawPhrase);
4987
+ // A vault upgraded from a pre-rename release still has its index under the
4988
+ // old file name; adopt it before anything asks whether an index exists.
4989
+ await adoptLegacyIndexFile(dataDir);
4958
4990
  // Segments mode: every segment is opened and merged directly, never through
4959
4991
  // the legacy blob's ranged directory or full-decrypt paths below. The
4960
4992
  // personal-source-facts cache is skipped here — a term-less lookup (as
@@ -6289,6 +6321,10 @@ export async function search(dataDir, query, options = {}) {
6289
6321
  */
6290
6322
  export async function indexCoverage(dataDir, options = {}) {
6291
6323
  const records = await listArchives(dataDir);
6324
+ // Status must not report a searchable vault whose index file this release
6325
+ // cannot see (see adoptLegacyIndexFile): coverage.json survives a rename
6326
+ // and would otherwise claim full coverage while every search failed.
6327
+ await adoptLegacyIndexFile(dataDir);
6292
6328
  const coverage = await readFile(coveragePath(dataDir), "utf8")
6293
6329
  .then((raw) => JSON.parse(raw))
6294
6330
  .catch(() => null);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sealkeep",
3
- "version": "0.11.0",
3
+ "version": "0.11.2",
4
4
  "type": "module",
5
5
  "description": "Sealkeep by SPALA AI \u2014 your AI coding-agent history, sealed, searchable, and shared across your machines.",
6
6
  "repository": {