sealkeep 0.11.4 → 0.11.6

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,41 @@
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.6 — 2026-09-25 — reconnect and Recovery Kit limits are separate
7
+
8
+ - After a machine reconnects successfully, the Recovery Kit page says so even
9
+ if a later cloud check is paused by a service limit. It shows when that
10
+ check can be retried and no longer asks for another reconnect that cannot
11
+ lift the limit. The local recovery phrase and existing vault are unchanged.
12
+
13
+ ## 0.11.5 — 2026-09-23 — compacting is safe, and a lost destination says so
14
+
15
+ - **An agent that has just been compacted is told where the work stands.**
16
+ Compacting is the only thing that makes a running session cheaper: the whole
17
+ conversation is re-sent on every request, so cost is roughly its size times
18
+ the number of steps. Measured on one real machine, a session was sending
19
+ 577,000 tokens per step, 98% of it re-read history. What stops people
20
+ compacting early is that the summary loses detail and the agent then
21
+ re-reads files to work out where it was, which costs far more than being
22
+ told. Sealkeep now hears the compaction — Claude names that source too, and
23
+ only Codex was listening — and treats the session as a new reader, because
24
+ it is: everything it had been handed is gone from the window. It says the
25
+ short version once, with your next message. Nothing is lost from the archive
26
+ either way; the transcript on disk keeps every message, compacted or not.
27
+ Settings → "What reaches your agent" has the switch. Codex reviews changed
28
+ hooks once, so it asks you to review Sealkeep's again after this upgrade.
29
+
30
+ - **When a destination stops accepting this machine, you are told.** Sealkeep
31
+ worked out reachability every minute and published it to the account panel,
32
+ so a Drive whose authorization had lapsed was visible to anyone who opened
33
+ Settings or ran doctor, and to nobody else — while archives piled up locally
34
+ and the upload ledger grew. It now says so once when that changes, as a
35
+ desktop notification on macOS, Linux and Windows, and as a line after your
36
+ next command. The first sight of a destination is never announced, still
37
+ broken is not repeated, and the last state is kept across restarts so an
38
+ upgrade neither repeats a message nor swallows one. It names what it means
39
+ for the work first: new archives stay here and nothing is lost.
40
+
6
41
  ## 0.11.4 — 2026-09-23 — history stays findable
7
42
 
8
43
  - **An archive can no longer be dropped from search because one sidecar could
@@ -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;
@@ -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
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sealkeep",
3
- "version": "0.11.4",
3
+ "version": "0.11.6",
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
@@ -435,7 +435,7 @@ async function api(path, options = {}, preparationAttempt = 0) {
435
435
  error.status = response.status;
436
436
  // Structured refusals (lastCopy, forever) ride to the caller so a button
437
437
  // can change its own words instead of string-matching prose.
438
- if (body?.error && typeof body.error === "object") Object.assign(error, { code: body.error.code, status: response.status, forever: body.error.forever, lastCopy: body.error.lastCopy });
438
+ if (body?.error && typeof body.error === "object") Object.assign(error, { code: body.error.code, status: response.status, forever: body.error.forever, lastCopy: body.error.lastCopy, retryAt: body.error.retryAt });
439
439
  throw error;
440
440
  }
441
441
  if (path === "/v1/archives" && Array.isArray(body)) {
@@ -3683,7 +3683,14 @@ async function renderAccountCard(status, options) {
3683
3683
  }
3684
3684
  }
3685
3685
 
3686
- let recoveryAccessState = { vaultExists: false, phraseHeld: null, kit: null };
3686
+ let recoveryAccessState = { vaultExists: false, phraseHeld: null, kit: null, kitCheckPausedUntil: null, accountReconnected: false };
3687
+
3688
+ function recoveryKitPauseMessage() {
3689
+ const retryAt = recoveryAccessState.kitCheckPausedUntil;
3690
+ const when = retryAt && Number.isFinite(Date.parse(retryAt))
3691
+ ? ` until ${new Date(retryAt).toLocaleString()}` : " until the service limit resets";
3692
+ return `${recoveryAccessState.accountReconnected ? 'This computer reconnected successfully. ' : ''}Cloud Recovery Kit check is paused${when}. Reconnecting again will not lift the service limit.`;
3693
+ }
3687
3694
 
3688
3695
  function paintRecoveryAccess() {
3689
3696
  const panel = $("recovery-access-panel");
@@ -3697,7 +3704,9 @@ function paintRecoveryAccess() {
3697
3704
  ? "Keychain: this machine does not hold its recovery phrase."
3698
3705
  : "Keychain: recovery phrase status is not available yet.";
3699
3706
  const kit = recoveryAccessState.kit;
3700
- $("recovery-access-cloud").textContent = kit?.eligible === true
3707
+ $("recovery-access-cloud").textContent = recoveryAccessState.kitCheckPausedUntil
3708
+ ? recoveryKitPauseMessage()
3709
+ : kit?.eligible === true
3701
3710
  ? kit.available
3702
3711
  ? `Cloud Recovery Kit: ${kit.remaining} unused backup code${kit.remaining === 1 ? " remains" : "s remain"}.`
3703
3712
  : "Cloud Recovery Kit: no unused backup codes are available."
@@ -3764,6 +3773,7 @@ async function pollAccountReconnect() {
3764
3773
  return;
3765
3774
  }
3766
3775
  accountReconnect = null; button.hidden = true; button.href = '#recovery-kit'; button.textContent = 'Reconnect account';
3776
+ recoveryAccessState = { ...recoveryAccessState, accountReconnected: true };
3767
3777
  message.textContent = 'This machine is reconnected. Checking your existing Recovery Kit…';
3768
3778
  overviewDetailCheckedAt = 0;
3769
3779
  try {
@@ -3812,6 +3822,7 @@ $('recovery-kit-account').onclick = async (event) => {
3812
3822
  try {
3813
3823
  const renewed = await api('/v1/account/reconnect', {method:'POST',body:'{}'});
3814
3824
  if (renewed.reconnected) {
3825
+ recoveryAccessState = { ...recoveryAccessState, accountReconnected: true };
3815
3826
  try { popup?.close(); } catch {}
3816
3827
  message.textContent = 'This computer is reconnected using its existing device seat. Checking recovery access…';
3817
3828
  overviewDetailCheckedAt = 0;
@@ -3850,7 +3861,7 @@ async function renderRecoveryKit() {
3850
3861
  try {
3851
3862
  const kit = await api("/v1/recovery-kit");
3852
3863
  if (accountReconnect) return;
3853
- recoveryAccessState = { ...recoveryAccessState, kit };
3864
+ recoveryAccessState = { ...recoveryAccessState, kit, kitCheckPausedUntil: null };
3854
3865
  paintRecoveryAccess();
3855
3866
  account.hidden = true;
3856
3867
  if (kit.eligible == null) {
@@ -3873,9 +3884,14 @@ async function renderRecoveryKit() {
3873
3884
  button.textContent = kit.available ? "Replace backup codes" : "Create Recovery Kit";
3874
3885
  } catch (error) {
3875
3886
  if (accountReconnect) return;
3876
- recoveryAccessState = { ...recoveryAccessState, kit: null };
3887
+ recoveryAccessState = { ...recoveryAccessState, kit: null, kitCheckPausedUntil: error.code === 'rate_limited' ? (error.retryAt || 'pending') : null };
3877
3888
  paintRecoveryAccess();
3878
3889
  button.hidden = true;
3890
+ if (error.code === 'rate_limited') {
3891
+ account.hidden = true;
3892
+ state.textContent = recoveryKitPauseMessage();
3893
+ return;
3894
+ }
3879
3895
  const needsAccount = /JWT|credential|sign|account|unauthorized/i.test(error.message);
3880
3896
  account.hidden = !needsAccount;
3881
3897
  state.textContent = needsAccount
@@ -3923,7 +3939,9 @@ function recoveryAccessSummary() {
3923
3939
  if (kit?.eligible === true) {
3924
3940
  return { message: "This machine can open your memories, but no usable Cloud Recovery Kit exists. Create one below for lost-machine recovery.", tone: "warn" };
3925
3941
  }
3926
- return { message: "This machine can open your memories, but Sealkeep could not confirm off-machine recovery. Reconnect the account and check again.", tone: "warn" };
3942
+ return { message: recoveryAccessState.kitCheckPausedUntil
3943
+ ? `This machine can open your memories, but off-machine recovery cannot be confirmed right now. ${recoveryKitPauseMessage()}`
3944
+ : "This machine can open your memories, but Sealkeep could not confirm off-machine recovery. Reconnect the account and check again.", tone: "warn" };
3927
3945
  }
3928
3946
 
3929
3947
  async function recoverMachine() {
@@ -4491,6 +4509,7 @@ function renderSettingsForm(local, _resolvedTrashStrategy) {
4491
4509
  const check = (id, on) => { const el = $(id); if (el) el.checked = on; };
4492
4510
  const fill = (id, value) => { const el = $(id); if (el) el.value = value; };
4493
4511
  check("f-auto-inject", local.recall?.autoInject !== false);
4512
+ check("f-at-compaction", local.recall?.atCompaction !== false);
4494
4513
  check("f-mcp-enabled", local.mcp?.enabled !== false);
4495
4514
  fill("f-cpu", local.limits?.cpuPercent ?? "");
4496
4515
  fill("f-memory", local.limits?.memoryMb ?? "");
@@ -4521,7 +4540,7 @@ function collectSettingsPatch() {
4521
4540
  const raw = $("f-reserve").value.trim();
4522
4541
  return raw === "" ? null : Math.min(102_400, Math.max(200, Math.round(Number(raw) || 0)));
4523
4542
  })(),
4524
- recall: { autoInject: $("f-auto-inject")?.checked !== false },
4543
+ recall: { autoInject: $("f-auto-inject")?.checked !== false, atCompaction: $("f-at-compaction")?.checked !== false },
4525
4544
  mcp: { enabled: $("f-mcp-enabled")?.checked !== false },
4526
4545
  limits: {
4527
4546
  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>