sealkeep 0.11.1 → 0.11.3

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.
@@ -0,0 +1,241 @@
1
+ /**
2
+ * The context meter — what this session is costing you, per step.
3
+ *
4
+ * An AI coding session charges you for its own history on every request. A
5
+ * conversation that has been alive for months carries its whole transcript
6
+ * into each step, so a one-line question costs whatever the transcript costs,
7
+ * and an agent loop pays that once per command. Nothing tells you until the
8
+ * limit is gone.
9
+ *
10
+ * Sealkeep already holds the session files, so it can answer the question
11
+ * nobody else can: what is each live session costing per step, how much of
12
+ * that is re-read history, and what would the same work cost in a fresh
13
+ * session carrying a small context pack instead of a transcript.
14
+ *
15
+ * Everything here reads local files only. No key, no network, nothing leaves
16
+ * the machine to produce a number.
17
+ */
18
+ import { createReadStream } from "node:fs";
19
+ import { readdir, stat } from "node:fs/promises";
20
+ import { homedir } from "node:os";
21
+ import { basename, dirname, join } from "node:path";
22
+ import { createInterface } from "node:readline";
23
+ /** A fork carries rules, a statement of what is true now, and the last handoff. */
24
+ export const CONTEXT_PACK_TOKENS = 3_000;
25
+ /**
26
+ * Which sessions are worth saying something about. Thresholds are deliberately
27
+ * boring: each one was true of a real session that ended a weekly allowance.
28
+ */
29
+ export function signalsFor(input) {
30
+ const signals = [];
31
+ if (input.historyShare > 0.9 && input.perStep > 50_000)
32
+ signals.push("BLOATED");
33
+ if (input.ageDays > 7 || input.sizeMB > 200)
34
+ signals.push("STALE");
35
+ if (input.sleeps >= 5)
36
+ signals.push("POLLING");
37
+ return signals;
38
+ }
39
+ /** Turns raw counters into the numbers a person can act on. */
40
+ export function scoreSession(raw) {
41
+ const steps = Math.max(raw.steps, 1);
42
+ const perStep = Math.round(raw.input / steps);
43
+ const historyShare = raw.input > 0 ? raw.cached / raw.input : 0;
44
+ // A fresh session still pays for the new work; what it stops paying for is
45
+ // the transcript. The pack is the small thing carried in its place.
46
+ const freshCost = steps * (CONTEXT_PACK_TOKENS + Math.round(raw.fresh / steps));
47
+ const scored = { ...raw, perStep, historyShare, freshCost, saving: Math.max(0, raw.input - freshCost), signals: [] };
48
+ scored.signals = signalsFor(scored);
49
+ return scored;
50
+ }
51
+ async function* jsonlFiles(root) {
52
+ let entries;
53
+ try {
54
+ entries = await readdir(root, { withFileTypes: true });
55
+ }
56
+ catch {
57
+ return;
58
+ }
59
+ for (const entry of entries) {
60
+ const path = join(root, entry.name);
61
+ if (entry.isDirectory())
62
+ yield* jsonlFiles(path);
63
+ else if (entry.name.endsWith(".jsonl"))
64
+ yield path;
65
+ }
66
+ }
67
+ async function* lines(file) {
68
+ // Streamed, because a session worth metering is often gigabytes.
69
+ const stream = createReadStream(file, { encoding: "utf8" });
70
+ try {
71
+ for await (const line of createInterface({ input: stream, crlfDelay: Infinity }))
72
+ yield line;
73
+ }
74
+ finally {
75
+ stream.destroy();
76
+ }
77
+ }
78
+ /** Claude Code transcripts report usage per message, so a window is a sum. */
79
+ async function claudeSession(file, since) {
80
+ const info = await stat(file).catch(() => null);
81
+ if (!info || info.mtimeMs < since)
82
+ return null;
83
+ let steps = 0, cached = 0, fresh = 0, cacheWrite = 0, output = 0;
84
+ for await (const line of lines(file)) {
85
+ if (!line.includes('"usage"'))
86
+ continue;
87
+ let row;
88
+ try {
89
+ row = JSON.parse(line);
90
+ }
91
+ catch {
92
+ continue;
93
+ }
94
+ if (!(Date.parse(row.timestamp ?? "") >= since))
95
+ continue;
96
+ const usage = row.message?.usage ?? {};
97
+ steps += 1;
98
+ cached += usage.cache_read_input_tokens ?? 0;
99
+ fresh += usage.input_tokens ?? 0;
100
+ cacheWrite += usage.cache_creation_input_tokens ?? 0;
101
+ output += usage.output_tokens ?? 0;
102
+ }
103
+ if (!steps)
104
+ return null;
105
+ return scoreSession({
106
+ tool: "claude", file,
107
+ project: basename(dirname(file)).replace(/^-/, "").replace(/-/g, "/"),
108
+ sizeMB: Math.round(info.size / 1e6),
109
+ ageDays: (Date.now() - info.birthtimeMs) / 864e5,
110
+ steps, tools: 0, sleeps: 0,
111
+ input: cached + fresh + cacheWrite, cached, fresh: fresh + cacheWrite, output,
112
+ });
113
+ }
114
+ /** Codex rollouts report cumulative counters, so a window is a delta. */
115
+ async function codexSession(file, since) {
116
+ const info = await stat(file).catch(() => null);
117
+ if (!info || info.mtimeMs < since)
118
+ return null;
119
+ let cwd = "", before = null, last = null, steps = 0, tools = 0, sleeps = 0, seen = 0;
120
+ for await (const line of lines(file)) {
121
+ if (!line)
122
+ continue;
123
+ let row;
124
+ try {
125
+ row = JSON.parse(line);
126
+ }
127
+ catch {
128
+ continue;
129
+ }
130
+ const payload = row.payload ?? {};
131
+ if (seen < 5) {
132
+ seen += 1;
133
+ const meta = payload.type === "session_meta" ? (payload.payload ?? payload) : (row.type === "session_meta" ? payload : null);
134
+ if (meta?.cwd)
135
+ cwd = meta.cwd;
136
+ }
137
+ const at = Date.parse(row.timestamp ?? "");
138
+ if (payload.type === "token_count") {
139
+ const usage = payload.info?.total_token_usage;
140
+ if (!usage)
141
+ continue;
142
+ if (!(at >= since)) {
143
+ before = usage;
144
+ continue;
145
+ }
146
+ before ??= usage;
147
+ last = usage;
148
+ steps += 1;
149
+ }
150
+ else if (at >= since && (payload.type === "custom_tool_call" || payload.type === "function_call")) {
151
+ tools += 1;
152
+ if (/\bsleep\b|"name"\s*:\s*"sleep"/.test(line))
153
+ sleeps += 1;
154
+ }
155
+ }
156
+ if (!last || !before || !steps)
157
+ return null;
158
+ const input = last.input_tokens - before.input_tokens;
159
+ const cached = last.cached_input_tokens - before.cached_input_tokens;
160
+ return scoreSession({
161
+ tool: "codex", file, project: cwd || "?",
162
+ sizeMB: Math.round(info.size / 1e6),
163
+ ageDays: (Date.now() - info.birthtimeMs) / 864e5,
164
+ steps, tools, sleeps,
165
+ input, cached, fresh: input - cached, output: last.output_tokens - before.output_tokens,
166
+ });
167
+ }
168
+ /** Reads local session files and scores every session touched in the window. */
169
+ export async function meterSessions(options = {}) {
170
+ const hours = options.hours ?? 24;
171
+ const since = (options.now ?? Date.now()) - hours * 36e5;
172
+ const claudeRoot = options.roots?.claude ?? join(homedir(), ".claude", "projects");
173
+ const codexRoot = options.roots?.codex ?? join(homedir(), ".codex", "sessions");
174
+ const sessions = [];
175
+ for await (const file of jsonlFiles(claudeRoot)) {
176
+ const scored = await claudeSession(file, since).catch(() => null);
177
+ if (scored)
178
+ sessions.push(scored);
179
+ }
180
+ for await (const file of jsonlFiles(codexRoot)) {
181
+ const scored = await codexSession(file, since).catch(() => null);
182
+ if (scored)
183
+ sessions.push(scored);
184
+ }
185
+ sessions.sort((a, b) => b.input - a.input);
186
+ const totalInput = sessions.reduce((n, s) => n + s.input, 0);
187
+ const totalSaving = sessions.reduce((n, s) => n + s.saving, 0);
188
+ return {
189
+ hours, sessions, totalInput,
190
+ totalFreshCost: totalInput - totalSaving,
191
+ totalSaving,
192
+ savingShare: totalInput ? totalSaving / totalInput : 0,
193
+ worst: sessions.find((s) => s.signals.includes("BLOATED")) ?? sessions[0],
194
+ polling: sessions.filter((s) => s.signals.includes("POLLING")),
195
+ };
196
+ }
197
+ const tokens = (n) => n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${Math.round(n / 1e3)}k` : String(Math.round(n));
198
+ const percent = (share) => `${Math.round(share * 100)}%`;
199
+ /** The receipt for one session, in the words a person would use. */
200
+ export function renderReceipt(session) {
201
+ const lines = [
202
+ `${session.tool} session in ${basename(session.project) || session.project}`,
203
+ ` ${tokens(session.perStep)} tokens per step, ${percent(session.historyShare)} of it re-read history.`,
204
+ ` ${session.steps} steps cost ${tokens(session.input)}. The same work in a fresh session: about ${tokens(session.freshCost)}.`,
205
+ ];
206
+ if (session.signals.includes("STALE")) {
207
+ lines.push(` ${session.sizeMB} MB on disk, ${session.ageDays.toFixed(0)} days old.`);
208
+ }
209
+ if (session.saving > 0)
210
+ lines.push(` You are paying about ${tokens(session.saving)} for history you did not ask for.`);
211
+ return lines.join("\n");
212
+ }
213
+ /** The whole picture, for `sealkeep meter`. */
214
+ export function renderMeter(summary) {
215
+ if (!summary.sessions.length) {
216
+ return `No agent sessions were touched in the last ${summary.hours}h, so there is nothing to meter.`;
217
+ }
218
+ const out = [
219
+ `Context cost, last ${summary.hours}h, across ${summary.sessions.length} session${summary.sessions.length === 1 ? "" : "s"}.`,
220
+ "Read from local session files. Nothing left this machine.",
221
+ "",
222
+ `${"tool".padEnd(7)}${"where".padEnd(22)}${"steps".padStart(6)}${"per step".padStart(10)}${"history".padStart(9)}${"total in".padStart(10)} signals`,
223
+ ];
224
+ for (const s of summary.sessions.slice(0, 12)) {
225
+ out.push(`${s.tool.padEnd(7)}${(basename(s.project) || "?").slice(0, 21).padEnd(22)}${String(s.steps).padStart(6)}`
226
+ + `${tokens(s.perStep).padStart(10)}${percent(s.historyShare).padStart(9)}${tokens(s.input).padStart(10)} ${s.signals.join(" ")}`);
227
+ }
228
+ out.push("", `Total sent: ${tokens(summary.totalInput)}. The same work from fresh sessions carrying a ${tokens(CONTEXT_PACK_TOKENS)}-token pack: about ${tokens(summary.totalFreshCost)}.`);
229
+ if (summary.totalSaving > 0) {
230
+ out.push(`That is roughly ${tokens(summary.totalSaving)} spent re-reading, ${percent(summary.savingShare)} of everything you sent.`);
231
+ }
232
+ if (summary.worst?.signals.includes("BLOATED")) {
233
+ out.push("", "Worst offender:", renderReceipt(summary.worst));
234
+ out.push(" Fork it: write what is true now into a short state note, start fresh, and search the rest on demand.");
235
+ }
236
+ if (summary.polling.length) {
237
+ out.push("", "Polling: an agent is sleeping and re-checking inside its own turn, paying the full context on every tick.");
238
+ out.push(" Move the wait into a shell command that returns once.");
239
+ }
240
+ return out.join("\n");
241
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * The version to suggest, or null. Never throws, never blocks longer than its
3
+ * own timeout, and answers from cache when today's check already happened.
4
+ */
5
+ export declare function newerReleaseThan(currentVersion: string, dataDir: string, options?: {
6
+ now?: () => number;
7
+ env?: NodeJS.ProcessEnv;
8
+ fetchLatest?: (signal: AbortSignal) => Promise<string | null>;
9
+ }): Promise<string | null>;
10
+ /** The one line a person sees. Kept to a fact and the command that acts on it. */
11
+ export declare function updateNotice(latest: string, current: string): string;
@@ -0,0 +1,117 @@
1
+ /**
2
+ * "A newer Sealkeep is out."
3
+ *
4
+ * Shipping 0.11.0 taught us why this is worth having: a release that could not
5
+ * open archives written by its predecessor sat on the registry for two days,
6
+ * and nobody running it had any way to learn that the fix existed. A person
7
+ * finds out from the tool they already have open, or not at all.
8
+ *
9
+ * The rules this follows, because a version check is a network call the user
10
+ * did not ask for:
11
+ * - never before the command runs, and never in its way: the check is a
12
+ * background read of a cached answer, and the note prints after the work;
13
+ * - at most one request a day, per machine, recorded in the vault's runtime
14
+ * directory, so a scripted loop cannot turn into a traffic generator;
15
+ * - never for a machine-readable run (`--json`), a hook, or a daemon;
16
+ * - silent on every failure. An offline laptop, a blocked registry and a
17
+ * proxy that answers with HTML all mean "no note", never an error;
18
+ * - nothing is sent but the request itself: no vault id, no identifiers, no
19
+ * telemetry. It reads the same public metadata `npm view` reads, and can be
20
+ * turned off for good with SEALKEEP_NO_UPDATE_CHECK=1.
21
+ */
22
+ import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
23
+ import { randomUUID } from "node:crypto";
24
+ import { join } from "node:path";
25
+ const REGISTRY_URL = "https://registry.npmjs.org/sealkeep/latest";
26
+ const CHECK_INTERVAL_MS = 24 * 60 * 60_000;
27
+ const REQUEST_TIMEOUT_MS = 2_000;
28
+ const statePath = (dataDir) => join(dataDir, "runtime", "latest-release.json");
29
+ /** A dotted release, compared numerically; anything unparseable sorts lowest. */
30
+ function isNewer(candidate, current) {
31
+ const parts = (value) => {
32
+ const core = value.split(/[-+]/, 1)[0] ?? "";
33
+ const numbers = core.split(".").map((piece) => Number.parseInt(piece, 10));
34
+ return numbers.length === 3 && numbers.every((piece) => Number.isInteger(piece) && piece >= 0) ? numbers : null;
35
+ };
36
+ const left = parts(candidate);
37
+ const right = parts(current);
38
+ if (!left || !right)
39
+ return false;
40
+ for (let index = 0; index < 3; index += 1) {
41
+ if (left[index] !== right[index])
42
+ return left[index] > right[index];
43
+ }
44
+ // A pre-release of the same numbers is not an upgrade to offer.
45
+ return false;
46
+ }
47
+ async function readCached(dataDir) {
48
+ try {
49
+ const raw = JSON.parse(await readFile(statePath(dataDir), "utf8"));
50
+ if (typeof raw.version !== "string" || typeof raw.at !== "string")
51
+ return null;
52
+ return { version: raw.version, at: raw.at };
53
+ }
54
+ catch {
55
+ return null;
56
+ }
57
+ }
58
+ async function writeCached(dataDir, value) {
59
+ const target = statePath(dataDir);
60
+ const temp = `${target}.${randomUUID()}.tmp`;
61
+ try {
62
+ await mkdir(join(dataDir, "runtime"), { recursive: true, mode: 0o700 });
63
+ await writeFile(temp, JSON.stringify(value, null, 2) + "\n", { mode: 0o600 });
64
+ await rename(temp, target);
65
+ }
66
+ catch {
67
+ // A read-only or full disk must never cost a command its result.
68
+ }
69
+ finally {
70
+ await rm(temp, { force: true }).catch(() => undefined);
71
+ }
72
+ }
73
+ async function fetchLatest(signal) {
74
+ try {
75
+ const response = await fetch(REGISTRY_URL, { signal, headers: { accept: "application/vnd.npm.install-v1+json, application/json" } });
76
+ if (!response.ok)
77
+ return null;
78
+ const body = await response.json();
79
+ return typeof body.version === "string" && /^\d+\.\d+\.\d+/.test(body.version) ? body.version : null;
80
+ }
81
+ catch {
82
+ return null;
83
+ }
84
+ }
85
+ /**
86
+ * The version to suggest, or null. Never throws, never blocks longer than its
87
+ * own timeout, and answers from cache when today's check already happened.
88
+ */
89
+ export async function newerReleaseThan(currentVersion, dataDir, options = {}) {
90
+ const env = options.env ?? process.env;
91
+ if (env.SEALKEEP_NO_UPDATE_CHECK === "1" || env.NO_UPDATE_NOTIFIER === "1" || env.CI === "true")
92
+ return null;
93
+ const now = options.now ?? Date.now;
94
+ const cached = await readCached(dataDir);
95
+ const fresh = cached && now() - Date.parse(cached.at) < CHECK_INTERVAL_MS;
96
+ if (fresh)
97
+ return isNewer(cached.version, currentVersion) ? cached.version : null;
98
+ const controller = new AbortController();
99
+ const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
100
+ let latest;
101
+ try {
102
+ latest = await (options.fetchLatest ?? fetchLatest)(controller.signal);
103
+ }
104
+ finally {
105
+ clearTimeout(timer);
106
+ }
107
+ // A failed check is recorded as "asked today" too: an offline machine must
108
+ // not retry on every single command.
109
+ await writeCached(dataDir, { version: latest ?? cached?.version ?? currentVersion, at: new Date(now()).toISOString() });
110
+ if (!latest)
111
+ return null;
112
+ return isNewer(latest, currentVersion) ? latest : null;
113
+ }
114
+ /** The one line a person sees. Kept to a fact and the command that acts on it. */
115
+ export function updateNotice(latest, current) {
116
+ return `Sealkeep ${latest} is available (you have ${current}). Update with: npm i -g sealkeep@latest`;
117
+ }
@@ -246,7 +246,8 @@ export declare function searchMetadata(dataDir: string, query: string, options?:
246
246
  *
247
247
  * This decrypts every archive locally, so it requires the recovery phrase. The
248
248
  * resulting index is itself encrypted with the same envelope before it touches the
249
- * disk, and it is never uploaded: `sealkeep upload` only ever sends archive objects.
249
+ * disk, and only that ciphertext ever leaves the machine — synced to the account
250
+ * so your other machines can search it (`pushed` in the result says whether it was).
250
251
  */
251
252
  export declare function buildContentIndex(dataDir: string, rawPhrase: string, options?: {
252
253
  sync?: boolean;
@@ -397,12 +397,27 @@ export async function contentIndexNeedsPolicyUpgrade(dataDir) {
397
397
  if (!Array.isArray(raw.indexed) || raw.extractionPolicy !== INDEX_EXTRACTION_POLICY)
398
398
  return true;
399
399
  const indexed = new Set(raw.indexed.filter((id) => typeof id === "string"));
400
- return (await listArchives(dataDir)).some((record) => !indexed.has(record.id));
400
+ return coverageGaps(await listArchives(dataDir), indexed).length > 0;
401
401
  }
402
402
  catch {
403
403
  return stat(indexPath(dataDir)).then(() => true, () => false);
404
404
  }
405
405
  }
406
+ /**
407
+ * The archives the index still owes: sealed here, not superseded, not covered.
408
+ *
409
+ * One definition, because the daemon's "is the index finished" check kept its
410
+ * own and counted superseded snapshots too. Those are never indexed on their
411
+ * own — the snapshot that superseded one carries every byte of it, and the
412
+ * index retires its id — so they can never appear in coverage, and the check
413
+ * could never pass on a vault that had ever superseded one: the daemon retried
414
+ * `index_incomplete` forever, and disk reclaim waits for exactly that check.
415
+ * `sealkeep index status` and doctor read the same list.
416
+ */
417
+ function coverageGaps(records, indexed) {
418
+ const superseded = supersededArchiveIds(records);
419
+ return records.filter((record) => !indexed.has(record.id) && !superseded.has(record.id));
420
+ }
406
421
  const MIN_TOKEN = 3;
407
422
  const MAX_TOKENS_PER_ARCHIVE = 4000;
408
423
  /**
@@ -442,6 +457,12 @@ export function isRecalledMemoryLine(line) {
442
457
  return true;
443
458
  if (line.includes("Sealkeep automatically loaded preserved context for project"))
444
459
  return true;
460
+ if (line.includes("preserved by Sealkeep from previous sessions"))
461
+ return true;
462
+ // Pull mode says only that recall exists; it carries no history, but it is
463
+ // still Sealkeep's own words rather than anything the session produced.
464
+ if (line.includes("Nothing has been loaded into this conversation."))
465
+ return true;
445
466
  try {
446
467
  const row = JSON.parse(line);
447
468
  const payload = row.payload && typeof row.payload === "object" && !Array.isArray(row.payload)
@@ -785,7 +806,8 @@ export async function searchMetadata(dataDir, query, options = {}) {
785
806
  *
786
807
  * This decrypts every archive locally, so it requires the recovery phrase. The
787
808
  * resulting index is itself encrypted with the same envelope before it touches the
788
- * disk, and it is never uploaded: `sealkeep upload` only ever sends archive objects.
809
+ * disk, and only that ciphertext ever leaves the machine — synced to the account
810
+ * so your other machines can search it (`pushed` in the result says whether it was).
789
811
  */
790
812
  export async function buildContentIndex(dataDir, rawPhrase, options = {}) {
791
813
  const phrase = canonicalPhrase(rawPhrase);
@@ -991,10 +1013,6 @@ export async function buildContentIndex(dataDir, rawPhrase, options = {}) {
991
1013
  }
992
1014
  }
993
1015
  const recordById = new Map(records.map((record) => [record.id, record]));
994
- const deltaBases = new Set(records.flatMap((record) => {
995
- const delta = deltaOf(record);
996
- return delta ? [delta.baseArchiveId] : [];
997
- }));
998
1016
  // Once a verified child is present, its base is a dependency rather than a
999
1017
  // separate stale search result. Purge those old logical postings in one
1000
1018
  // token-map pass. Physical archive/catalog entries stay covered so a later
@@ -1061,8 +1079,9 @@ export async function buildContentIndex(dataDir, rawPhrase, options = {}) {
1061
1079
  // start with them rather than making today's session wait behind months of
1062
1080
  // history. Posting order no longer depends on completion order; the
1063
1081
  // serializer sorts by sealed session metadata before applying its cap.
1064
- // A linear delta chain is one logical session. Only its latest leaf is
1065
- // scheduled; indexOneRecord authenticates the chain once and records its
1082
+ // A linear delta chain is one logical session. Only the newest link that
1083
+ // still needs indexing is scheduled — normally the leaf (see `awaitedBases`
1084
+ // below); indexOneRecord authenticates the chain once and records its
1066
1085
  // ancestors as covered dependencies. This turns N watcher snapshots from
1067
1086
  // 1+2+...+N physical reads into N reads.
1068
1087
  // A whale goes after every ordinary session, newest-first within each
@@ -1077,8 +1096,23 @@ export async function buildContentIndex(dataDir, rawPhrase, options = {}) {
1077
1096
  // coverage.json (this machine's own view of the manifest, already read
1078
1097
  // above as `mine`) and the manifest's tombstones answer the same question.
1079
1098
  const segmentsTombstones = segmentsMode ? new Set((await readManifest(dataDir)).tombstones) : null;
1080
- const fresh = records.filter((record) => !deltaBases.has(record.id) && !superseded.has(record.id)
1081
- && (segmentsMode ? !mine.has(record.id) && !segmentsTombstones.has(record.id) : !index.archives[record.id]))
1099
+ const uncovered = (record) => !superseded.has(record.id)
1100
+ && (segmentsMode ? !mine.has(record.id) && !segmentsTombstones.has(record.id) : !index.archives[record.id]);
1101
+ // A delta base is skipped only while one of its own children is still
1102
+ // waiting: the newest waiting link is scheduled instead, and walking its
1103
+ // chain to the root covers this base on the way. Skipping every base
1104
+ // unconditionally assumed the leaf would always be walked — but a leaf
1105
+ // indexed at seal time is never walked, and covers only the bytes it
1106
+ // appended. Every link sealed before it, the whole beginning of the
1107
+ // session, then sat outside every build forever while each run reported
1108
+ // success. Measured on a real vault: 400 such links across 76 sessions;
1109
+ // words found only in those early ranges came back in 0 of 20 searches,
1110
+ // words from the covered tail of the same sessions in 10.
1111
+ const awaitedBases = new Set(records.flatMap((record) => {
1112
+ const delta = deltaOf(record);
1113
+ return delta && uncovered(record) ? [delta.baseArchiveId] : [];
1114
+ }));
1115
+ const fresh = records.filter((record) => uncovered(record) && !awaitedBases.has(record.id))
1082
1116
  .sort((a, b) => Number(whale(a)) - Number(whale(b)) || b.createdAt.localeCompare(a.createdAt) || b.id.localeCompare(a.id));
1083
1117
  let indexedNow = 0;
1084
1118
  let skipped = 0;
@@ -6329,14 +6363,10 @@ export async function indexCoverage(dataDir, options = {}) {
6329
6363
  .then((raw) => JSON.parse(raw))
6330
6364
  .catch(() => null);
6331
6365
  const indexedIds = new Set(coverage?.indexed ?? []);
6332
- // A superseded snapshot is intentionally never indexed on its own — the
6333
- // newer snapshot named by its `supersedes` field already carries every one
6334
- // of its bytes (see buildContentIndex's `fresh` filter) — so it is not a
6335
- // gap. Doctor's `search-index` check reads `missing` and must go green on a
6336
- // vault whose only unindexed records are superseded snapshots.
6337
- const superseded = supersededArchiveIds(records);
6338
- const missing = records
6339
- .filter((record) => !indexedIds.has(record.id) && !superseded.has(record.id))
6366
+ // A superseded snapshot is intentionally never indexed on its own, so it is
6367
+ // not a gap (see coverageGaps). Doctor's `search-index` check reads `missing`
6368
+ // and must go green on a vault whose only unindexed records are superseded.
6369
+ const missing = coverageGaps(records, indexedIds)
6340
6370
  .map((record) => ({ id: record.id, path: record.source.path, agent: record.source.agent }));
6341
6371
  const indexBytes = await import("node:fs/promises").then(({ stat }) => stat(indexPath(dataDir))).then((info) => info.size).catch(() => 0);
6342
6372
  let searchable = null;
@@ -454,7 +454,13 @@ export async function reconcileCloudTeamAccess(dataDir, phrase, preferred) {
454
454
  // seals it, binds the local project, then acknowledges the cloud request.
455
455
  // If the response from createTeamSpace was lost, the next list observes the
456
456
  // already-created owner space and completes the same request idempotently.
457
+ //
458
+ // The space list is read once and re-read only after this pass itself
459
+ // created, bound or re-listed a space. It used to be fetched three times
460
+ // per pass unconditionally; on an idle vault that was half of every pass's
461
+ // requests, and the three answers were always the same.
457
462
  let spaces = await listTeamSpaces(dataDir, preferred);
463
+ let spacesStale = false;
458
464
  const projectRequests = await listTeamProjectRequests(dataDir, preferred);
459
465
  for (const request of projectRequests) {
460
466
  if (request.state !== "requested")
@@ -464,6 +470,7 @@ export async function reconcileCloudTeamAccess(dataDir, phrase, preferred) {
464
470
  continue;
465
471
  let space = spaces.find((item) => item.role === "owner" && item.project_name?.trim() === project);
466
472
  if (!space) {
473
+ spacesStale = true;
467
474
  try {
468
475
  const made = await createProjectTeamSpace(dataDir, project, phrase, preferred);
469
476
  space = { space_key: made.spaceKey, role: "owner", key_version: made.keyVersion, realtime_key: made.realtimeKey, project_name: project };
@@ -486,8 +493,12 @@ export async function reconcileCloudTeamAccess(dataDir, phrase, preferred) {
486
493
  failures.push({ operation: "complete_project", code: accessFailureCode(error) });
487
494
  }
488
495
  }
489
- spaces = await listTeamSpaces(dataDir, preferred);
490
- bound += await reconcileTeamProjectDrafts(dataDir, phrase, spaces, preferred);
496
+ if (spacesStale)
497
+ spaces = await listTeamSpaces(dataDir, preferred);
498
+ const draftsBound = await reconcileTeamProjectDrafts(dataDir, phrase, spaces, preferred);
499
+ bound += draftsBound;
500
+ if (draftsBound > 0)
501
+ spacesStale = true;
491
502
  const config = await readConfig(dataDir);
492
503
  const localBySpace = new Map(Object.entries(config.teamSpaces ?? {}).map(([project, binding]) => [binding.spaceKey, { project, binding }]));
493
504
  const locallyDisconnected = new Set(config.teamSpaceOptOuts ?? []);
@@ -532,7 +543,8 @@ export async function reconcileCloudTeamAccess(dataDir, phrase, preferred) {
532
543
  // An accepted invitation is authoritative cloud membership. On every fresh
533
544
  // machine, recover its wrapped feed key and bind the human project label
534
545
  // without asking for an opaque space id or a local "join" command.
535
- spaces = await listTeamSpaces(dataDir, preferred);
546
+ if (spacesStale)
547
+ spaces = await listTeamSpaces(dataDir, preferred);
536
548
  for (const space of spaces) {
537
549
  if (!SPACE_RE.test(space.space_key) || localBySpace.has(space.space_key) || locallyDisconnected.has(space.space_key))
538
550
  continue;
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "sealkeep",
3
- "version": "0.11.1",
3
+ "version": "0.11.3",
4
4
  "type": "module",
5
- "description": "Sealkeep by SPALA AI \u2014 your AI coding-agent history, sealed, searchable, and shared across your machines.",
5
+ "description": "Sealkeep by SPALA AI — your AI coding-agent history, sealed, searchable, and shared across your machines.",
6
6
  "repository": {
7
7
  "type": "git",
8
8
  "url": "git+https://github.com/spala-ai/sealkeep.git"
package/web/app.js CHANGED
@@ -4485,6 +4485,15 @@ function renderSettingsForm(local, _resolvedTrashStrategy) {
4485
4485
  $("f-interval").value = local.schedule.intervalMinutes;
4486
4486
  $("f-bandwidth").value = local.bandwidth?.uploadLimitMbps ?? "";
4487
4487
  $("f-reserve").value = local.diskReserveMb ?? "";
4488
+ // Undefined means a settings file written before these existed; both default
4489
+ // on. Guarded because a shell that predates these controls must still load
4490
+ // the rest of the form rather than stop at the first missing element.
4491
+ const check = (id, on) => { const el = $(id); if (el) el.checked = on; };
4492
+ const fill = (id, value) => { const el = $(id); if (el) el.value = value; };
4493
+ check("f-auto-inject", local.recall?.autoInject !== false);
4494
+ check("f-mcp-enabled", local.mcp?.enabled !== false);
4495
+ fill("f-cpu", local.limits?.cpuPercent ?? "");
4496
+ fill("f-memory", local.limits?.memoryMb ?? "");
4488
4497
  const cacheDays = local.localArchiveCacheDays === undefined ? 7 : local.localArchiveCacheDays;
4489
4498
  $("f-local-archive-cache-all").checked = cacheDays === null;
4490
4499
  $("f-local-archive-cache-days").value = cacheDays === null ? "7" : String(cacheDays);
@@ -4512,6 +4521,18 @@ function collectSettingsPatch() {
4512
4521
  const raw = $("f-reserve").value.trim();
4513
4522
  return raw === "" ? null : Math.min(102_400, Math.max(200, Math.round(Number(raw) || 0)));
4514
4523
  })(),
4524
+ recall: { autoInject: $("f-auto-inject")?.checked !== false },
4525
+ mcp: { enabled: $("f-mcp-enabled")?.checked !== false },
4526
+ limits: {
4527
+ cpuPercent: (() => {
4528
+ const raw = ($("f-cpu")?.value ?? "").trim();
4529
+ return raw === "" ? null : Math.min(100, Math.max(5, Math.round(Number(raw) || 30)));
4530
+ })(),
4531
+ memoryMb: (() => {
4532
+ const raw = ($("f-memory")?.value ?? "").trim();
4533
+ return raw === "" ? null : Math.min(65_536, Math.max(128, Math.round(Number(raw) || 1536)));
4534
+ })(),
4535
+ },
4515
4536
  localArchiveCacheDays: $("f-local-archive-cache-all").checked
4516
4537
  ? null
4517
4538
  : Math.min(3650, Math.max(0, Math.round(Number($("f-local-archive-cache-days").value) || 0)))
package/web/index.html CHANGED
@@ -323,6 +323,17 @@
323
323
  <p class="note">Off by default. When on, this machine permanently frees only originals whose encrypted archive is verified in storage, searchable, unchanged, idle, and past the grace period. Codex keeps a tiny same-session resume pointer. Policy and timing stay in the <a href="#rules">Free up disk rules</a>.</p>
324
324
  </div>
325
325
 
326
+ <h3 id="machine-agent">What reaches your agent</h3>
327
+ <div class="field">
328
+ <label class="opt"><input id="f-auto-inject" type="checkbox"> Hand preserved history to an agent when a session starts</label>
329
+ <p class="note">On by default. Your agent opens a project and already knows where you left off, without being asked.</p>
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
+ </div>
332
+ <div class="field">
333
+ <label class="opt"><input id="f-mcp-enabled" type="checkbox"> Let agents search this vault themselves</label>
334
+ <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>
335
+ </div>
336
+
326
337
  <h3 id="machine-resources">Speed and resource limits</h3>
327
338
  <div class="row">
328
339
  <div class="field"><label for="f-interval">Check every (minutes)</label><input id="f-interval" type="number" min="1" max="1440"></div>
@@ -332,6 +343,16 @@
332
343
  <p class="note">Automatic shares one 8 Mbps background ceiling across Sealkeep jobs so agent calls, meetings, and normal browsing keep network headroom. This applies to unattended archive and shared-memory transfers on this machine.</p>
333
344
  <p class="note">Background sealing, indexing, and memory preparation share a low-priority CPU budget targeting 30% of one CPU core. Short bursts can exceed this target. Large index workers have a 1.5 GB RSS safety limit. Foreground MCP searches are not throttled.</p>
334
345
  </div>
346
+ <div class="field">
347
+ <label for="f-cpu">Background CPU ceiling, % of one core (blank for automatic)</label>
348
+ <input id="f-cpu" type="number" min="5" max="100" step="5" inputmode="numeric" placeholder="automatic · 30%">
349
+ <p class="note">Automatic targets 30% of one core for sealing, indexing, and memory preparation. A number here only ever makes Sealkeep gentler, never greedier, and below 5% it would stall rather than pace.</p>
350
+ </div>
351
+ <div class="field">
352
+ <label for="f-memory">Background memory ceiling, MB (blank for automatic)</label>
353
+ <input id="f-memory" type="number" min="128" max="65536" step="128" inputmode="numeric" placeholder="automatic · 1536 MB">
354
+ <p class="note">The ceiling a background worker may reach before it stops and retries smaller. Lower it on a small machine; it only ever tightens the automatic value.</p>
355
+ </div>
335
356
  <div class="field">
336
357
  <label for="f-reserve">Always keep free, MB (blank for automatic)</label>
337
358
  <input id="f-reserve" type="number" min="200" placeholder="automatic">