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 +35 -0
- package/dist/src/adapters.d.ts +1 -1
- package/dist/src/adapters.js +15 -16
- package/dist/src/agent-context.d.ts +2 -0
- package/dist/src/agent-context.js +18 -1
- package/dist/src/cli.js +9 -0
- package/dist/src/daemon.js +51 -1
- package/dist/src/local-api.js +5 -2
- package/dist/src/machine-settings.d.ts +13 -0
- package/dist/src/machine-settings.js +5 -2
- package/dist/src/storage-reachability.d.ts +9 -0
- package/dist/src/storage-reachability.js +38 -0
- package/package.json +1 -1
- package/web/app.js +26 -7
- package/web/index.html +5 -0
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
|
package/dist/src/adapters.d.ts
CHANGED
|
@@ -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 =
|
|
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. */
|
package/dist/src/adapters.js
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
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
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
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
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
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
|
-
: !(
|
|
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
|
}
|
package/dist/src/daemon.js
CHANGED
|
@@ -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")
|
|
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;
|
package/dist/src/local-api.js
CHANGED
|
@@ -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: {
|
|
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
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 =
|
|
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:
|
|
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>
|