sealkeep 0.11.3 → 0.11.4

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,35 @@
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.4 — 2026-09-23 — history stays findable
7
+
8
+ - **An archive can no longer be dropped from search because one sidecar could
9
+ not be read.** The build treats an id missing from the archive ledger as
10
+ deleted and retires it, which in segments mode is permanent: the id vanishes
11
+ from every later lookup and no build indexes it again. But the ledger scan
12
+ skips any sidecar it cannot parse — a partial write being replaced, one
13
+ transient read error — and that scan is cached per directory, so the damage
14
+ only surfaces when something else changes the directory: a seal arriving
15
+ while a build runs, which is what a busy machine does all day. On one real
16
+ vault ten archives had been retired with nothing superseding them, and two
17
+ sessions were genuinely unfindable — words in one returned no hits from it
18
+ while matching hundreds of other sessions. Absence now counts as deletion
19
+ only when the scan read every sidecar.
20
+
21
+ A vault that already lost archives this way recovers with `sealkeep index
22
+ drop` followed by `sealkeep index build`, which clears every retirement and
23
+ indexes the archives again. Repairing it automatically was tried and dropped:
24
+ a retirement also travels between your machines, so a build that lifted one
25
+ could undo a removal another machine meant, and the suite caught that.
26
+
27
+ - **The team check no longer runs on every seal.** 0.11.3 gave the lane its own
28
+ backing-off cadence, but a tick still asked it for a pass, and a tick is
29
+ scheduled whenever the queue has work — so cloud traffic still followed local
30
+ sealing. Measured on a busy machine: passes 4-6 seconds apart while a backlog
31
+ drained, against the 6-to-120-second schedule the lane had chosen. Nothing is
32
+ lost by waiting for that schedule; a pass this machine's own sealing triggers
33
+ cannot learn anything sooner than one the clock triggers.
34
+
6
35
  ## 0.11.3 — 2026-09-23 — the context tax, and a vault that catches up
7
36
 
8
37
  - **The team check stops asking the cloud every three seconds.** The daemon
package/dist/site.zip CHANGED
Binary file
@@ -780,9 +780,19 @@ export async function startDaemon(dataDir, options) {
780
780
  notes.push(`rescanned transcript roots (${found} seen)`);
781
781
  }
782
782
  // Human access decisions live in SealKeep Cloud; private-key work stays on
783
- // this machine. Polling here closes both loops automatically: owners wrap
784
- // new invitations and accepted teammates/fresh machines bind the project.
785
- requestAccessReconciliation();
783
+ // this machine, and the access lane above closes both loops on its own
784
+ // cadence: owners wrap new invitations and accepted teammates/fresh
785
+ // machines bind the project.
786
+ //
787
+ // A tick deliberately does NOT ask for a pass. A tick is scheduled
788
+ // whenever the queue has claimable work, so asking here tied cloud
789
+ // traffic to local sealing: on a machine sealing a session every couple
790
+ // of minutes the lane ran that often no matter how quiet the team was.
791
+ // Measured on a busy host, passes came 4-6 seconds apart while a backlog
792
+ // drained, against the 6-to-120-second schedule the lane had chosen.
793
+ // Nothing is lost by waiting for that schedule — a pass triggered by this
794
+ // machine's own sealing cannot learn anything sooner than one triggered
795
+ // by the clock.
786
796
  // This used to read `options.diskPressureFreeBytes` with no default, and
787
797
  // nothing anywhere set it — so the guard was dead code and the installed
788
798
  // service ran with no floor at all, on machines chosen for being short of
@@ -8,7 +8,7 @@ import { createGunzip, gunzipSync } from "node:zlib";
8
8
  import { decryptArchive as openEnvelope, decryptArchiveBodyChunks, encryptArchive as sealEnvelope, openArchiveToFile, sealArchiveToFile, unsqueezeStream, unsqueezeSync, validateEnvelope, rawPublicKey, x25519PrivateKeyFromRaw, x25519PublicKeyFromRaw } from "../packages/sealkeep-crypto/src/index.js";
9
9
  import { fail, isSealkeepError } from "./errors.js";
10
10
  import { canonicalPhrase } from "./mnemonic.js";
11
- import { configuredRecipients, decryptRecord, deltaOf, listArchives, readConfig, resolveDeltaChain, restoreRecordToFile, teamSpaceOf } from "./vault.js";
11
+ import { configuredRecipients, decryptRecord, deltaOf, listArchives, listArchivesWithIntegrity, readConfig, resolveDeltaChain, restoreRecordToFile, teamSpaceOf } from "./vault.js";
12
12
  import { chunkWindowForRange, openChunkWindow, supportsChunkAccess } from "../packages/sealkeep-crypto/src/chunk-access.js";
13
13
  import { isV2, teamCustodyRecordIsValid } from "./types.js";
14
14
  import { archiveWrapsEveryMember } from "./upload.js";
@@ -849,7 +849,9 @@ export async function buildContentIndex(dataDir, rawPhrase, options = {}) {
849
849
  await readConfig(dataDir);
850
850
  await cleanupStaleContentIndexTemps(dataDir);
851
851
  await adoptLegacyIndexFile(dataDir);
852
- const records = await listArchives(dataDir);
852
+ // Absence is only evidence of deletion when every sidecar could be read.
853
+ const { records, unreadable } = await listArchivesWithIntegrity(dataDir);
854
+ const absenceMeansGone = unreadable.length === 0;
853
855
  // Segments mode is decided once, up front: a vault either has a manifest
854
856
  // with at least one segment (sealkeep index migrate created it) or it
855
857
  // stays on the legacy blob path below, unchanged.
@@ -970,12 +972,19 @@ export async function buildContentIndex(dataDir, rawPhrase, options = {}) {
970
972
  // file's index entry, the newer snapshot named by its `supersedes` field
971
973
  // is — and every superseded id is named here even one this machine never
972
974
  // got around to covering, so the manifest need not remember it later.
973
- const stale = [...new Set([...mine, ...superseded])].filter((id) => superseded.has(id) || !liveIds.has(id));
975
+ // Retiring an id from a segment is permanent: it disappears from every
976
+ // later lookup and compaction, and no build ever indexes it again. So it
977
+ // may only follow proof. A superseded snapshot is proof. Absence is not,
978
+ // unless the scan read every sidecar — see listArchivesWithIntegrity.
979
+ const stale = [...new Set([...mine, ...superseded])]
980
+ .filter((id) => superseded.has(id) || (absenceMeansGone && !liveIds.has(id)));
974
981
  if (stale.length)
975
982
  await retireArchiveIds(dataDir, stale);
976
983
  }
977
984
  else {
978
985
  for (const id of Object.keys(index.archives)) {
986
+ if (!absenceMeansGone)
987
+ break; // a short scan is not a deletion
979
988
  if (liveIds.has(id))
980
989
  continue;
981
990
  if (!mine.has(id))
@@ -181,6 +181,20 @@ export declare function teamRecipientsForProject(dataDir: string, project: strin
181
181
  * problem into a missing one. The archives that do parse still answer.
182
182
  */
183
183
  export declare function listArchives(dataDir: string): Promise<ArchiveRecord[]>;
184
+ /**
185
+ * The same list, plus whether the scan could read every sidecar.
186
+ *
187
+ * A caller that concludes "this archive no longer exists" from absence needs
188
+ * this: one sidecar that could not be read — a partial write being replaced, a
189
+ * transient EIO — silently shortens the list. The index build drew exactly that
190
+ * conclusion and retired the archive from search permanently. Two of a real
191
+ * vault's sessions became unfindable that way; words in one returned no hits
192
+ * from it while matching hundreds of other sessions.
193
+ */
194
+ export declare function listArchivesWithIntegrity(dataDir: string): Promise<{
195
+ records: ArchiveRecord[];
196
+ unreadable: string[];
197
+ }>;
184
198
  /**
185
199
  * Registers an X25519 public key that may open future archives. Only the public
186
200
  * key is stored; the matching private key stays on its own device.
package/dist/src/vault.js CHANGED
@@ -1373,6 +1373,22 @@ export async function listArchives(dataDir) {
1373
1373
  // dashboard requests shared the underlying filesystem scan.
1374
1374
  return scan.records.slice();
1375
1375
  }
1376
+ /**
1377
+ * The same list, plus whether the scan could read every sidecar.
1378
+ *
1379
+ * A caller that concludes "this archive no longer exists" from absence needs
1380
+ * this: one sidecar that could not be read — a partial write being replaced, a
1381
+ * transient EIO — silently shortens the list. The index build drew exactly that
1382
+ * conclusion and retired the archive from search permanently. Two of a real
1383
+ * vault's sessions became unfindable that way; words in one returned no hits
1384
+ * from it while matching hundreds of other sessions.
1385
+ */
1386
+ export async function listArchivesWithIntegrity(dataDir) {
1387
+ const config = await readConfig(dataDir);
1388
+ const scan = await currentArchiveLedgerScan(config.storage.root);
1389
+ reportUnreadableArchiveRecords(config.storage.root, scan.unreadable);
1390
+ return { records: scan.records.slice(), unreadable: scan.unreadable.slice() };
1391
+ }
1376
1392
  // The dashboard loads several archive-derived endpoints together. A per-call
1377
1393
  // batch bound alone still multiplied 32 opens by every endpoint. Share both an
1378
1394
  // in-flight scan and a completed, generation-validated scan. Archive record
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sealkeep",
3
- "version": "0.11.3",
3
+ "version": "0.11.4",
4
4
  "type": "module",
5
5
  "description": "Sealkeep by SPALA AI — your AI coding-agent history, sealed, searchable, and shared across your machines.",
6
6
  "repository": {