sealkeep 0.11.3 → 0.11.5

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,63 @@
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.5 — 2026-09-23 — compacting is safe, and a lost destination says so
7
+
8
+ - **An agent that has just been compacted is told where the work stands.**
9
+ Compacting is the only thing that makes a running session cheaper: the whole
10
+ conversation is re-sent on every request, so cost is roughly its size times
11
+ the number of steps. Measured on one real machine, a session was sending
12
+ 577,000 tokens per step, 98% of it re-read history. What stops people
13
+ compacting early is that the summary loses detail and the agent then
14
+ re-reads files to work out where it was, which costs far more than being
15
+ told. Sealkeep now hears the compaction — Claude names that source too, and
16
+ only Codex was listening — and treats the session as a new reader, because
17
+ it is: everything it had been handed is gone from the window. It says the
18
+ short version once, with your next message. Nothing is lost from the archive
19
+ either way; the transcript on disk keeps every message, compacted or not.
20
+ Settings → "What reaches your agent" has the switch. Codex reviews changed
21
+ hooks once, so it asks you to review Sealkeep's again after this upgrade.
22
+
23
+ - **When a destination stops accepting this machine, you are told.** Sealkeep
24
+ worked out reachability every minute and published it to the account panel,
25
+ so a Drive whose authorization had lapsed was visible to anyone who opened
26
+ Settings or ran doctor, and to nobody else — while archives piled up locally
27
+ and the upload ledger grew. It now says so once when that changes, as a
28
+ desktop notification on macOS, Linux and Windows, and as a line after your
29
+ next command. The first sight of a destination is never announced, still
30
+ broken is not repeated, and the last state is kept across restarts so an
31
+ upgrade neither repeats a message nor swallows one. It names what it means
32
+ for the work first: new archives stay here and nothing is lost.
33
+
34
+ ## 0.11.4 — 2026-09-23 — history stays findable
35
+
36
+ - **An archive can no longer be dropped from search because one sidecar could
37
+ not be read.** The build treats an id missing from the archive ledger as
38
+ deleted and retires it, which in segments mode is permanent: the id vanishes
39
+ from every later lookup and no build indexes it again. But the ledger scan
40
+ skips any sidecar it cannot parse — a partial write being replaced, one
41
+ transient read error — and that scan is cached per directory, so the damage
42
+ only surfaces when something else changes the directory: a seal arriving
43
+ while a build runs, which is what a busy machine does all day. On one real
44
+ vault ten archives had been retired with nothing superseding them, and two
45
+ sessions were genuinely unfindable — words in one returned no hits from it
46
+ while matching hundreds of other sessions. Absence now counts as deletion
47
+ only when the scan read every sidecar.
48
+
49
+ A vault that already lost archives this way recovers with `sealkeep index
50
+ drop` followed by `sealkeep index build`, which clears every retirement and
51
+ indexes the archives again. Repairing it automatically was tried and dropped:
52
+ a retirement also travels between your machines, so a build that lifted one
53
+ could undo a removal another machine meant, and the suite caught that.
54
+
55
+ - **The team check no longer runs on every seal.** 0.11.3 gave the lane its own
56
+ backing-off cadence, but a tick still asked it for a pass, and a tick is
57
+ scheduled whenever the queue has work — so cloud traffic still followed local
58
+ sealing. Measured on a busy machine: passes 4-6 seconds apart while a backlog
59
+ drained, against the 6-to-120-second schedule the lane had chosen. Nothing is
60
+ lost by waiting for that schedule; a pass this machine's own sealing triggers
61
+ cannot learn anything sooner than one the clock triggers.
62
+
6
63
  ## 0.11.3 — 2026-09-23 — the context tax, and a vault that catches up
7
64
 
8
65
  - **The team check stops asking the cloud every three seconds.** The daemon
package/dist/site.zip CHANGED
Binary file
@@ -26,7 +26,7 @@ export type AgentInstall = {
26
26
  export declare const SETUP_AGENT_IDS: SetupAgentId[];
27
27
  /** The subset with a session adapter. Mirrors `AgentId` at runtime for schema use. */
28
28
  export declare const ARCHIVING_AGENT_IDS: readonly ["codex", "claude"];
29
- export declare const HOOK_LIFECYCLE_VERSION = 3;
29
+ export declare const HOOK_LIFECYCLE_VERSION = 4;
30
30
  /** Narrows a setup id to an archiving one, so callers prove the subset rather than casting to it. */
31
31
  export declare function isArchivingAgent(agent: SetupAgentId): agent is AgentId;
32
32
  /** Detect only well-known local roots; it never reads transcript contents. */
@@ -69,7 +69,7 @@ const AGENTS = {
69
69
  export const SETUP_AGENT_IDS = Object.keys(AGENTS);
70
70
  /** The subset with a session adapter. Mirrors `AgentId` at runtime for schema use. */
71
71
  export const ARCHIVING_AGENT_IDS = ["codex", "claude"];
72
- export const HOOK_LIFECYCLE_VERSION = 3;
72
+ export const HOOK_LIFECYCLE_VERSION = 4;
73
73
  /** Narrows a setup id to an archiving one, so callers prove the subset rather than casting to it. */
74
74
  export function isArchivingAgent(agent) {
75
75
  return ARCHIVING_AGENT_IDS.includes(agent);
@@ -589,7 +589,10 @@ export function hookConfig(agent, executable = "sealkeep", dataDir = "~/.sealkee
589
589
  ...(agent === "codex" ? { additionalContextLimit: 2000 } : {})
590
590
  };
591
591
  const contextStart = [{
592
- matcher: agent === "codex" ? "startup|resume|clear|compact" : "startup|resume|clear",
592
+ // "compact" matters most of all: the window has just been replaced by a
593
+ // summary, which is exactly when an agent starts re-reading files to work
594
+ // out where it was. Claude names that source too; only Codex was listening.
595
+ matcher: "startup|resume|clear|compact",
593
596
  hooks: [{ type: "command", command: contextSync, timeout: 30, async: true }]
594
597
  }];
595
598
  const contextPrompt = [{ hooks: [
@@ -835,21 +838,17 @@ function lifecycleInstalled(agent, contents) {
835
838
  const owned = commandsIn(document.hooks).filter((command) => lifecycleIdentity(command)?.endsWith(`:${agent}`));
836
839
  if (owned.some((command) => !command.includes(`--lifecycle ${HOOK_LIFECYCLE_VERSION}`)))
837
840
  return false;
838
- const actionCount = (action) => owned.filter((command) => lifecycleIdentity(command) === `${action}:${agent}`).length;
839
- if (actionCount("enqueue") !== (agent === "codex" ? 2 : 1)
840
- || actionCount("rehydrate") !== 0
841
- || actionCount("context") !== 2
842
- || actionCount("context-sync") !== 3)
841
+ // The expected lifecycle is read from the generator, not restated here. A
842
+ // restated count ("context-sync appears three times") declared every file
843
+ // the generator had just written incomplete the moment it gained a hook.
844
+ const expected = hookConfig(agent).hooks;
845
+ const identities = (commands) => commands.map(lifecycleIdentity).filter((identity) => identity !== null).sort();
846
+ if (identities(owned).join() !== identities(commandsIn(expected)).join())
843
847
  return false;
844
- const eventHas = (event, action) => commandsIn(document.hooks?.[event]).some((command) => command.includes(`hook ${action} --agent ${agent}`)
845
- && command.includes(`--lifecycle ${HOOK_LIFECYCLE_VERSION}`));
846
- return eventHas("SessionEnd", "enqueue")
847
- && eventHas("UserPromptSubmit", "context")
848
- && eventHas("PostToolUse", "context")
849
- && eventHas("SessionStart", "context-sync")
850
- && eventHas("UserPromptSubmit", "context-sync")
851
- && eventHas("PostToolUse", "context-sync")
852
- && (agent !== "codex" || eventHas("PostCompact", "enqueue"));
848
+ return Object.entries(expected).every(([event, groups]) => {
849
+ const present = new Set(identities(commandsIn(document.hooks?.[event])));
850
+ return identities(commandsIn(groups)).every((identity) => present.has(identity));
851
+ });
853
852
  }
854
853
  catch {
855
854
  return false;
@@ -23,6 +23,8 @@ export type AgentHookPayload = {
23
23
  transcript_path?: string;
24
24
  rollout_path?: string;
25
25
  tool_name?: string;
26
+ /** Why a session started: "startup" | "resume" | "clear" | "compact". */
27
+ source?: string;
26
28
  };
27
29
  export type AutomaticContextOutcome = {
28
30
  project: string;
@@ -88,6 +88,9 @@ export function parseAgentHookPayload(raw) {
88
88
  hook_event_name: text("hook_event_name"), session_id: text("session_id"), thread_id: text("thread_id"),
89
89
  cwd: text("cwd"), prompt: text("prompt"), transcript_path: text("transcript_path"),
90
90
  rollout_path: text("rollout_path"), tool_name: text("tool_name"),
91
+ // Without this a compaction is indistinguishable from any other start,
92
+ // and the session is never told what the summary dropped.
93
+ source: text("source"),
91
94
  };
92
95
  }
93
96
  catch {
@@ -1122,6 +1125,20 @@ export async function automaticAgentContext(dataDir, phrase, agent, payload, opt
1122
1125
  //
1123
1126
  // SEALKEEP_RECALL_PUSH=0 turns later boundaries off entirely, leaving only
1124
1127
  // the memories handed over at session start.
1128
+ // A compaction replaces the conversation with a summary, so everything this
1129
+ // session was already handed is gone from the window — and the record of
1130
+ // what it has been told is now wrong. Clearing that record re-establishes
1131
+ // the framing and the load-bearing lines once, at the one moment an agent
1132
+ // would otherwise start re-reading files to work out where it was. That
1133
+ // re-read costs far more than the few hundred tokens this spends, and
1134
+ // compaction is the only thing that makes a running session cheaper: the
1135
+ // whole window is re-sent on every request, so cost is roughly its size
1136
+ // times the number of steps. Nothing is lost from the archive either way —
1137
+ // the transcript on disk keeps every message, compacted or not.
1138
+ const settings = await readLocalSettings(dataDir).catch(() => null);
1139
+ if (event === "SessionStart" && payload.source === "compact" && (settings?.recall.atCompaction ?? true)) {
1140
+ state.injected = [];
1141
+ }
1125
1142
  const seen = new Set(state.injected ?? []);
1126
1143
  const greeted = seen.size > 0;
1127
1144
  // The switch lives in Settings; the environment variable stays as an override
@@ -1131,7 +1148,7 @@ export async function automaticAgentContext(dataDir, phrase, agent, payload, opt
1131
1148
  ? true
1132
1149
  : quiet === "1" || quiet === "true" || quiet === "on"
1133
1150
  ? false
1134
- : !(await readLocalSettings(dataDir).then((settings) => settings.recall.autoInject).catch(() => true));
1151
+ : !(settings?.recall.autoInject ?? true);
1135
1152
  // History the agent will actually believe goes in its own memory directory,
1136
1153
  // not only into the prompt. See src/agent-memory.ts for why: injected recall
1137
1154
  // was delivered perfectly and then refused, on two different models.
package/dist/src/cli.js CHANGED
@@ -3778,6 +3778,15 @@ async function noteNewerRelease() {
3778
3778
  const latest = await newerReleaseThan(current, dataDir);
3779
3779
  if (latest)
3780
3780
  console.error(`\n ${hint(updateNotice(latest, current))}\n`);
3781
+ // A destination that stopped accepting this machine is worth the same
3782
+ // sentence, and for the same reason: a person finds out from the tool they
3783
+ // already have open, or not at all. The background service notices within
3784
+ // a minute and records it; this only reads that record, so it costs no
3785
+ // network and no credentials.
3786
+ const { unreachableDestinations, unreachableNotice } = await import("./storage-reachability.js");
3787
+ const unreachable = await unreachableDestinations(dataDir);
3788
+ if (unreachable.length)
3789
+ console.error(`\n ${hint(unreachableNotice(unreachable))}\n`);
3781
3790
  }
3782
3791
  catch { /* a version check must never be the thing that fails */ }
3783
3792
  }
@@ -1,3 +1,5 @@
1
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import { dirname } from "node:path";
1
3
  import { freeBytes, resolveReserveBytes } from "./disk.js";
2
4
  import { ArchiveQueue } from "./queue.js";
3
5
  import { applyRetention, readApprovals, reconcileCodexReclaims, retentionSettings } from "./retention.js";
@@ -670,10 +672,58 @@ export async function startDaemon(dataDir, options) {
670
672
  // stay on each machine. Report only reachability (never credentials) on an
671
673
  // independent advisory lane so a disconnected Drive is visible in the
672
674
  // account panel before the next archive tries and fails to upload.
675
+ /**
676
+ * Say it out loud when a destination stops accepting this machine.
677
+ *
678
+ * Reachability was computed every minute and published to the account panel,
679
+ * and that was all: a Drive whose access had lapsed was visible only if you
680
+ * opened Settings or ran doctor. Meanwhile archives pile up locally and the
681
+ * upload ledger grows — the silent stall this product exists to prevent.
682
+ * Only a change is announced, and the last state is kept on disk so a
683
+ * restart (an upgrade restarts this service) neither repeats the message nor
684
+ * swallows one that happened while it was down.
685
+ */
686
+ const { reachabilityPath } = await import("./storage-reachability.js");
687
+ const reachabilityFile = reachabilityPath(dataDir);
688
+ const readReachability = async () => {
689
+ try {
690
+ const raw = JSON.parse(await readFile(reachabilityFile, "utf8"));
691
+ return Object.fromEntries(Object.entries(raw).filter(([, value]) => typeof value === "boolean"));
692
+ }
693
+ catch {
694
+ return {};
695
+ }
696
+ };
697
+ const announceStorageReachability = async () => {
698
+ const { resolveTargets, targetConnectionEvidence } = await import("./storage-targets.js");
699
+ const previous = await readReachability();
700
+ const current = {};
701
+ const changes = [];
702
+ for (const target of await resolveTargets(dataDir)) {
703
+ if (target.enabled === false)
704
+ continue;
705
+ const { connected } = await targetConnectionEvidence(dataDir, target).catch(() => ({ connected: previous[target.id] ?? true }));
706
+ current[target.id] = connected;
707
+ const was = previous[target.id];
708
+ // An unseen destination is recorded, never announced: the first sight of
709
+ // one is not news, and a new machine would otherwise open with a warning.
710
+ if (was !== undefined && was !== connected)
711
+ changes.push({ target: target.provider ?? target.id, connected });
712
+ }
713
+ await mkdir(dirname(reachabilityFile), { recursive: true, mode: 0o700 }).catch(() => undefined);
714
+ await writeFile(reachabilityFile, JSON.stringify(current, null, 2) + "\n", { mode: 0o600 }).catch(() => undefined);
715
+ for (const change of changes) {
716
+ await notify(change.connected
717
+ ? { title: "Storage reconnected", body: `${change.target} is accepting this machine again. Waiting archives upload on the next pass.` }
718
+ : { title: "Sealkeep cannot reach your storage", body: `${change.target} is not accepting this machine. New archives stay here, nothing is lost, and Sealkeep keeps trying. Reconnect it in Settings.` }, { ...options.notify, enabled: options.notifications ?? options.notify?.enabled }).catch(() => undefined);
719
+ }
720
+ };
673
721
  const reportStorageHealth = async () => {
674
722
  if (storageHealthInFlight)
675
723
  return storageHealthInFlight;
676
- const task = import("./storage-targets.js").then(({ pushTargetUsage }) => pushTargetUsage(dataDir));
724
+ const task = import("./storage-targets.js")
725
+ .then(({ pushTargetUsage }) => pushTargetUsage(dataDir))
726
+ .then(() => announceStorageReachability().catch(() => undefined));
677
727
  storageHealthInFlight = task;
678
728
  try {
679
729
  await task;
@@ -780,9 +830,19 @@ export async function startDaemon(dataDir, options) {
780
830
  notes.push(`rescanned transcript roots (${found} seen)`);
781
831
  }
782
832
  // 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();
833
+ // this machine, and the access lane above closes both loops on its own
834
+ // cadence: owners wrap new invitations and accepted teammates/fresh
835
+ // machines bind the project.
836
+ //
837
+ // A tick deliberately does NOT ask for a pass. A tick is scheduled
838
+ // whenever the queue has claimable work, so asking here tied cloud
839
+ // traffic to local sealing: on a machine sealing a session every couple
840
+ // of minutes the lane ran that often no matter how quiet the team was.
841
+ // Measured on a busy host, passes came 4-6 seconds apart while a backlog
842
+ // drained, against the 6-to-120-second schedule the lane had chosen.
843
+ // Nothing is lost by waiting for that schedule — a pass triggered by this
844
+ // machine's own sealing cannot learn anything sooner than one triggered
845
+ // by the clock.
786
846
  // This used to read `options.diskPressureFreeBytes` with no default, and
787
847
  // nothing anywhere set it — so the guard was dead code and the installed
788
848
  // service ran with no floor at all, on machines chosen for being short of
@@ -214,7 +214,7 @@ const localSettingsSchema = z.object({
214
214
  bandwidth: z.object({ uploadLimitMbps: z.number().min(0).max(100_000).nullable() }).strict().optional(),
215
215
  // What reaches the agent. Injection is convenient and it is re-read on every
216
216
  // later step of a conversation, so it is a switch rather than a given.
217
- recall: z.object({ autoInject: z.boolean() }).strict().optional(),
217
+ recall: z.object({ autoInject: z.boolean(), atCompaction: z.boolean().optional() }).strict().optional(),
218
218
  mcp: z.object({ enabled: z.boolean() }).strict().optional(),
219
219
  // Ceilings for background work. A floor on each, because a limit below it
220
220
  // would stall the work rather than pace it. null returns to automatic.
@@ -5474,7 +5474,10 @@ export function createLocalApiServer(dataDir, token, options = {}) {
5474
5474
  const next = {
5475
5475
  ...current, ...patch,
5476
5476
  schedule: { ...current.schedule, ...patch.schedule },
5477
- bandwidth: { ...current.bandwidth, ...patch.bandwidth }
5477
+ bandwidth: { ...current.bandwidth, ...patch.bandwidth },
5478
+ // Merged field by field like the others: a panel that sends only the
5479
+ // switch it changed must not drop the rest of the group.
5480
+ recall: { ...current.recall, ...patch.recall },
5478
5481
  };
5479
5482
  // The explicit, confirmed machine opt-in must be operational rather
5480
5483
  // than a green switch in front of a policy that can never select work.
@@ -32,6 +32,19 @@ export type LocalSettings = {
32
32
  */
33
33
  recall: {
34
34
  autoInject: boolean;
35
+ /**
36
+ * Hand the agent its bearings back when its conversation is compacted.
37
+ *
38
+ * Compaction is the only thing that makes a running session cheaper: the
39
+ * whole conversation is re-sent on every request, so cost is roughly the
40
+ * size of the live window times the number of steps. What stops people
41
+ * compacting early is that the summary loses detail and the agent then
42
+ * re-reads files to work out where it was — which costs far more than the
43
+ * few hundred tokens of a note saying what is true now and that the rest
44
+ * is one search away. Nothing is lost from the archive either way: the
45
+ * transcript on disk keeps every message, compacted or not.
46
+ */
47
+ atCompaction: boolean;
35
48
  };
36
49
  /** The local MCP server that lets an agent search this vault by itself. */
37
50
  mcp: {
@@ -113,7 +113,7 @@ export function defaultLocalSettings() {
113
113
  // On by default: an agent that is handed its history at the start of a
114
114
  // session is the product. It is deduplicated and never repeats itself, so
115
115
  // the standing cost is small — but it is a cost, and it is switchable.
116
- recall: { autoInject: true },
116
+ recall: { autoInject: true, atCompaction: true },
117
117
  mcp: { enabled: true },
118
118
  limits: { cpuPercent: null, memoryMb: null },
119
119
  };
@@ -151,7 +151,10 @@ export async function readLocalSettings(dataDir) {
151
151
  exclusions: Array.isArray(saved?.exclusions) ? saved.exclusions.filter((path) => typeof path === "string") : [],
152
152
  schedule: { ...fallback.schedule, ...saved?.schedule },
153
153
  bandwidth: { ...fallback.bandwidth, ...saved?.bandwidth },
154
- recall: { autoInject: typeof saved?.recall?.autoInject === "boolean" ? saved.recall.autoInject : fallback.recall.autoInject },
154
+ recall: {
155
+ autoInject: typeof saved?.recall?.autoInject === "boolean" ? saved.recall.autoInject : fallback.recall.autoInject,
156
+ atCompaction: typeof saved?.recall?.atCompaction === "boolean" ? saved.recall.atCompaction : fallback.recall.atCompaction,
157
+ },
155
158
  mcp: { enabled: typeof saved?.mcp?.enabled === "boolean" ? saved.mcp.enabled : fallback.mcp.enabled },
156
159
  limits: {
157
160
  // A floor of 5% and 128 MB, because a ceiling below those would stall
@@ -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))
@@ -0,0 +1,9 @@
1
+ export declare const reachabilityPath: (dataDir: string) => string;
2
+ /** Destination ids the service last saw refusing this machine, sorted. */
3
+ export declare function unreachableDestinations(dataDir: string): Promise<string[]>;
4
+ /**
5
+ * Says what broke, what it means for the work, and what to do — in that order.
6
+ * "Nothing is lost" is the part a person needs first: the archives are still
7
+ * here and still sealed, they simply have not left this machine yet.
8
+ */
9
+ export declare function unreachableNotice(names: readonly string[]): string;
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Which destinations last refused this machine, and how to say so.
3
+ *
4
+ * Reachability was computed every minute and published to the account panel,
5
+ * and that was the only place it went: a Drive whose authorization had lapsed
6
+ * was visible to someone who opened Settings or ran doctor, and to nobody
7
+ * else. Archives pile up locally meanwhile and the upload ledger grows, which
8
+ * is the silent stall this product exists to prevent.
9
+ *
10
+ * The background service owns the observation and writes it here. Every reader
11
+ * — the CLI notice, a test — only reads this file, so telling a person costs
12
+ * no network call and touches no credential.
13
+ */
14
+ import { readFile } from "node:fs/promises";
15
+ import { join } from "node:path";
16
+ export const reachabilityPath = (dataDir) => join(dataDir, "runtime", "storage-reachability.json");
17
+ /** Destination ids the service last saw refusing this machine, sorted. */
18
+ export async function unreachableDestinations(dataDir) {
19
+ try {
20
+ const raw = JSON.parse(await readFile(reachabilityPath(dataDir), "utf8"));
21
+ return Object.entries(raw).filter(([, reachable]) => reachable === false).map(([id]) => id).sort();
22
+ }
23
+ catch {
24
+ // No observation yet, or an unreadable file. Saying nothing is correct:
25
+ // this is an advisory line after somebody's real answer, and a warning
26
+ // invented from a missing file would be worse than silence.
27
+ return [];
28
+ }
29
+ }
30
+ /**
31
+ * Says what broke, what it means for the work, and what to do — in that order.
32
+ * "Nothing is lost" is the part a person needs first: the archives are still
33
+ * here and still sealed, they simply have not left this machine yet.
34
+ */
35
+ export function unreachableNotice(names) {
36
+ const subject = names.length === 1 ? `${names[0]} is` : `${names.slice(0, -1).join(", ")} and ${names[names.length - 1]} are`;
37
+ return `${subject} not accepting this machine. New archives stay here and nothing is lost; reconnect in Settings (\`sealkeep ui\`).`;
38
+ }
@@ -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.5",
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": {
package/web/app.js CHANGED
@@ -4491,6 +4491,7 @@ function renderSettingsForm(local, _resolvedTrashStrategy) {
4491
4491
  const check = (id, on) => { const el = $(id); if (el) el.checked = on; };
4492
4492
  const fill = (id, value) => { const el = $(id); if (el) el.value = value; };
4493
4493
  check("f-auto-inject", local.recall?.autoInject !== false);
4494
+ check("f-at-compaction", local.recall?.atCompaction !== false);
4494
4495
  check("f-mcp-enabled", local.mcp?.enabled !== false);
4495
4496
  fill("f-cpu", local.limits?.cpuPercent ?? "");
4496
4497
  fill("f-memory", local.limits?.memoryMb ?? "");
@@ -4521,7 +4522,7 @@ function collectSettingsPatch() {
4521
4522
  const raw = $("f-reserve").value.trim();
4522
4523
  return raw === "" ? null : Math.min(102_400, Math.max(200, Math.round(Number(raw) || 0)));
4523
4524
  })(),
4524
- recall: { autoInject: $("f-auto-inject")?.checked !== false },
4525
+ recall: { autoInject: $("f-auto-inject")?.checked !== false, atCompaction: $("f-at-compaction")?.checked !== false },
4525
4526
  mcp: { enabled: $("f-mcp-enabled")?.checked !== false },
4526
4527
  limits: {
4527
4528
  cpuPercent: (() => {
package/web/index.html CHANGED
@@ -329,6 +329,11 @@
329
329
  <p class="note">On by default. Your agent opens a project and already knows where you left off, without being asked.</p>
330
330
  <p class="note"><b>What it costs.</b> Anything placed in a conversation is re-read by the model on every later step, so a block added at the start is paid for again on every request that follows. Sealkeep sends each thing once and never repeats itself, which on a measured session cut this from 86 million tokens to 11 million, under one percent of what that session sent. Turn this off and preserved history is still written as notes your agent reads for itself, and is still searchable — it simply is not volunteered.</p>
331
331
  </div>
332
+ <div class="field">
333
+ <label class="opt"><input id="f-at-compaction" type="checkbox"> Give the agent its bearings back after its conversation is compacted</label>
334
+ <p class="note">On by default. When an agent's conversation is replaced by a summary, Sealkeep hands it the short version of where the work stands, once. Without that, an agent typically re-reads files to work out where it was, which costs far more than the note does.</p>
335
+ <p class="note"><b>Why it saves money.</b> The whole conversation is re-sent to the model on every step, so cost is roughly the size of that conversation times the number of steps. Compacting is the only thing that makes a running session cheaper, and this is what makes compacting early safe: nothing is lost either way, because the full transcript is preserved and searchable.</p>
336
+ </div>
332
337
  <div class="field">
333
338
  <label class="opt"><input id="f-mcp-enabled" type="checkbox"> Let agents search this vault themselves</label>
334
339
  <p class="note">On by default. Runs a local tool server so Claude Code and Codex can search your sealed history on demand. Nothing is exposed to the network. Turn it off and agents keep the notes and the automatic recall, but cannot go looking for more.</p>