sealkeep 0.11.2 → 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.
@@ -297,6 +297,29 @@ export async function runDoctor(dataDir, env = process.env, home = env.HOME ?? e
297
297
  checks.push(detected.length > 0
298
298
  ? { name: "agents", status: "pass", detail: `Detected: ${detected.join(", ")}` }
299
299
  : { name: "agents", status: "warn", detail: "No supported agent directory found on this machine" });
300
+ // Recall is only useful if the agent believes it, and what an agent believes
301
+ // is its own memory directory rather than something pushed into its prompt.
302
+ // So report whether Sealkeep can write there, and whether it has.
303
+ {
304
+ const { hasMemorySurface, listWrittenNotes } = await import("./agent-memory.js");
305
+ const here = process.cwd();
306
+ const claude = agents.find((agent) => agent.agent === "claude" && agent.detected);
307
+ if (claude) {
308
+ const writable = await hasMemorySurface("claude", here, home);
309
+ const notes = writable ? await listWrittenNotes("claude", here, home) : [];
310
+ checks.push(writable
311
+ ? {
312
+ name: "agent-memory-notes", status: "pass",
313
+ detail: notes.length
314
+ ? `${notes.length} preserved session${notes.length === 1 ? "" : "s"} written where Claude reads its own notes for this project`
315
+ : "Claude's memory directory for this project is reachable; notes appear once a session here has been preserved",
316
+ }
317
+ : {
318
+ name: "agent-memory-notes", status: "warn",
319
+ detail: `Claude keeps no memory directory for ${here} yet, so preserved sessions can only reach it as injected context, which agents discount`,
320
+ });
321
+ }
322
+ }
300
323
  // A command in hooks.json is not proof that Codex will dispatch it. Codex
301
324
  // keeps enabled/trust state separately in config.toml and deliberately lets
302
325
  // the user disable a reviewed hook. Report that state without changing it;
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Fork a session instead of resuming it.
3
+ *
4
+ * Resuming a long session is the expensive mistake nobody warns you about: the
5
+ * whole transcript goes into every request, so a few questions on a months-old
6
+ * session can cost tens of millions of tokens without producing anything.
7
+ *
8
+ * The answer is not to delete the history. It is to stop carrying it in the
9
+ * prompt. This builds a small pack — what the session decided, what it was in
10
+ * the middle of, where to look for the rest — and hands you the command to
11
+ * start fresh with it. Everything else stays in Sealkeep, one search away.
12
+ *
13
+ * The promise only holds if the session really is recoverable, so this checks
14
+ * that before it suggests you walk away from one, and says plainly when it is
15
+ * not yet safe to.
16
+ */
17
+ import { type SessionCost } from "./meter.js";
18
+ export type ForkPlan = {
19
+ session: SessionCost;
20
+ /** Sealed into the vault, so the bytes survive. */
21
+ archiveId: string | null;
22
+ /** Searchable by content, so an agent can actually pull a detail back. */
23
+ searchable: boolean;
24
+ packPath: string;
25
+ markdown: string;
26
+ packTokens: number;
27
+ /** What one more step costs if you carry on in this session. */
28
+ resumeCostPerStep: number;
29
+ /** What one more step costs in a fresh session carrying the pack. */
30
+ freshCostPerStep: number;
31
+ savingPerStep: number;
32
+ freshCommand: string;
33
+ warnings: string[];
34
+ };
35
+ /**
36
+ * What did this session decide, and what was it in the middle of?
37
+ *
38
+ * Deliberately shallow: the last things a person asked, the last thing the
39
+ * agent said back, and the files it was touching. Anything deeper belongs in
40
+ * a search, not in every future prompt.
41
+ */
42
+ export declare function distil(lines: string[]): {
43
+ asks: string[];
44
+ handoff: string;
45
+ files: string[];
46
+ };
47
+ /** The pack itself: small, plain, and honest about where the rest lives. */
48
+ export declare function renderPack(input: {
49
+ session: SessionCost;
50
+ archiveId: string | null;
51
+ searchable: boolean;
52
+ asks: string[];
53
+ handoff: string;
54
+ files: string[];
55
+ }): string;
56
+ export type ForkOptions = {
57
+ /** A specific transcript. Defaults to the most expensive live session. */
58
+ file?: string;
59
+ hours?: number;
60
+ /** Where to write the pack. Defaults beside the project. */
61
+ out?: string;
62
+ /** Injected in tests. */
63
+ archives?: Array<{
64
+ id: string;
65
+ source: {
66
+ path?: string;
67
+ };
68
+ }>;
69
+ indexedPaths?: Set<string>;
70
+ roots?: {
71
+ claude?: string;
72
+ codex?: string;
73
+ };
74
+ };
75
+ /** Works out what forking this session would save, and whether it is safe to. */
76
+ export declare function planFork(dataDir: string, options?: ForkOptions): Promise<ForkPlan | null>;
77
+ export declare function writePack(plan: ForkPlan): Promise<string>;
78
+ export declare function renderFork(plan: ForkPlan, written: boolean): string;
@@ -0,0 +1,197 @@
1
+ /**
2
+ * Fork a session instead of resuming it.
3
+ *
4
+ * Resuming a long session is the expensive mistake nobody warns you about: the
5
+ * whole transcript goes into every request, so a few questions on a months-old
6
+ * session can cost tens of millions of tokens without producing anything.
7
+ *
8
+ * The answer is not to delete the history. It is to stop carrying it in the
9
+ * prompt. This builds a small pack — what the session decided, what it was in
10
+ * the middle of, where to look for the rest — and hands you the command to
11
+ * start fresh with it. Everything else stays in Sealkeep, one search away.
12
+ *
13
+ * The promise only holds if the session really is recoverable, so this checks
14
+ * that before it suggests you walk away from one, and says plainly when it is
15
+ * not yet safe to.
16
+ */
17
+ import { open, stat, writeFile, mkdir } from "node:fs/promises";
18
+ import { basename, dirname, join } from "node:path";
19
+ import { meterSessions } from "./meter.js";
20
+ const roughTokens = (text) => Math.ceil(text.length / 4);
21
+ /** Reads the last slice of a file without loading gigabytes of transcript. */
22
+ async function tail(file, bytes) {
23
+ const info = await stat(file);
24
+ const length = Math.min(bytes, info.size);
25
+ const handle = await open(file, "r");
26
+ try {
27
+ const buffer = Buffer.alloc(length);
28
+ await handle.read(buffer, 0, length, info.size - length);
29
+ return buffer.toString("utf8");
30
+ }
31
+ finally {
32
+ await handle.close();
33
+ }
34
+ }
35
+ const clean = (text) => text.replace(/\s+/g, " ").trim().slice(0, 600);
36
+ /**
37
+ * What did this session decide, and what was it in the middle of?
38
+ *
39
+ * Deliberately shallow: the last things a person asked, the last thing the
40
+ * agent said back, and the files it was touching. Anything deeper belongs in
41
+ * a search, not in every future prompt.
42
+ */
43
+ export function distil(lines) {
44
+ const asks = [];
45
+ const files = new Set();
46
+ let handoff = "";
47
+ for (const line of lines) {
48
+ if (!line.trim())
49
+ continue;
50
+ let row;
51
+ try {
52
+ row = JSON.parse(line);
53
+ }
54
+ catch {
55
+ continue;
56
+ }
57
+ const payload = row.payload ?? row;
58
+ const role = row.type ?? payload.type ?? row.message?.role;
59
+ const content = row.message?.content ?? payload.content ?? payload.text;
60
+ const textOf = (value) => {
61
+ if (typeof value === "string")
62
+ return value;
63
+ if (Array.isArray(value)) {
64
+ return value.map((part) => (typeof part === "string" ? part : part?.text ?? "")).join(" ");
65
+ }
66
+ return "";
67
+ };
68
+ const text = textOf(content);
69
+ if (!text.trim())
70
+ continue;
71
+ if (role === "user" || row.message?.role === "user") {
72
+ // Plenty of things arrive wearing the user's role without a person
73
+ // having typed them: hook output, tool results, slash-command bodies,
74
+ // whole skill documents. A pack built from those is worse than useless,
75
+ // because it reads like instructions the person never gave.
76
+ if (/hook additional context|tool_result|<system-reminder>|<command-name>|<command-message>|<local-command-stdout>/i.test(text))
77
+ continue;
78
+ if (/^\s*#{1,3}\s|\n\s*#{2,3}\s/.test(text))
79
+ continue; // a pasted document, not a request
80
+ if (text.length > 1_500)
81
+ continue; // same, by weight
82
+ const said = clean(text);
83
+ if (said.length < 3)
84
+ continue;
85
+ asks.push(said);
86
+ }
87
+ else if (role === "assistant" || row.message?.role === "assistant") {
88
+ handoff = clean(text) || handoff;
89
+ }
90
+ for (const match of text.matchAll(/(?:^|[\s"'`(])((?:\/|\.\/|src\/|test\/)[\w./-]{3,80}\.\w{1,6})/g)) {
91
+ files.add(match[1]);
92
+ }
93
+ }
94
+ return { asks: asks.slice(-4), handoff, files: [...files].slice(-12) };
95
+ }
96
+ /** The pack itself: small, plain, and honest about where the rest lives. */
97
+ export function renderPack(input) {
98
+ const blocks = [
99
+ `# Where this work is up to`,
100
+ `Carried over from a ${input.session.tool} session in ${basename(input.session.project) || input.session.project}, `
101
+ + `which had grown to ${Math.round(input.session.perStep / 1000)}k tokens a step. `
102
+ + `This is a summary, not the transcript. Treat it as background, and check anything important against the working tree.`,
103
+ ];
104
+ if (input.handoff)
105
+ blocks.push(`## Where it left off\n${input.handoff}`);
106
+ if (input.asks.length)
107
+ blocks.push(`## What was being asked for\n${input.asks.map((a) => `- ${a}`).join("\n")}`);
108
+ if (input.files.length)
109
+ blocks.push(`## Files it was working in\n${input.files.map((f) => `- ${f}`).join("\n")}`);
110
+ blocks.push(input.searchable
111
+ ? `## The rest of the history\nThe full session is preserved and searchable in Sealkeep${input.archiveId ? ` as archive ${input.archiveId}` : ""}. `
112
+ + `Do not ask for it to be pasted in. When you need a detail — why something was decided, what an earlier attempt did — use the Sealkeep MCP search, `
113
+ + `or \`sealkeep search "<terms>" --content\`, and pull back only that. That is the whole point of starting fresh: the history is one question away instead of in every prompt.`
114
+ : `## The rest of the history\nThis session is NOT yet preserved in Sealkeep, so nothing here can be searched back. `
115
+ + `Seal it first; until then the transcript on disk is the only copy.`);
116
+ return blocks.join("\n\n");
117
+ }
118
+ /** Works out what forking this session would save, and whether it is safe to. */
119
+ export async function planFork(dataDir, options = {}) {
120
+ const summary = await meterSessions({ hours: options.hours ?? 24, roots: options.roots });
121
+ const session = options.file
122
+ ? summary.sessions.find((candidate) => candidate.file === options.file) ?? null
123
+ : summary.worst ?? null;
124
+ if (!session)
125
+ return null;
126
+ const archives = options.archives ?? await (async () => {
127
+ try {
128
+ const { listArchives } = await import("./vault.js");
129
+ return await listArchives(dataDir);
130
+ }
131
+ catch {
132
+ return [];
133
+ }
134
+ })();
135
+ const archive = archives.find((record) => record.source?.path === session.file) ?? null;
136
+ let searchable = options.indexedPaths ? options.indexedPaths.has(session.file) : false;
137
+ if (!options.indexedPaths && archive) {
138
+ try {
139
+ const { indexCoverage } = await import("./search.js");
140
+ const coverage = await indexCoverage(dataDir);
141
+ searchable = Boolean(coverage?.indexed);
142
+ }
143
+ catch {
144
+ searchable = false;
145
+ }
146
+ }
147
+ const text = await tail(session.file, 400_000).catch(() => "");
148
+ const { asks, handoff, files } = distil(text.split("\n"));
149
+ const markdown = renderPack({ session, archiveId: archive?.id ?? null, searchable, asks, handoff, files });
150
+ const warnings = [];
151
+ if (!archive)
152
+ warnings.push("This session is not sealed yet, so nothing could be searched back. Seal it before you abandon it.");
153
+ else if (!searchable)
154
+ warnings.push("This session is sealed but its contents are not indexed yet, so only metadata can be found. Run `sealkeep index build`.");
155
+ const packTokens = roughTokens(markdown);
156
+ const freshPerStep = packTokens + Math.round(session.fresh / Math.max(session.steps, 1));
157
+ const packPath = options.out ?? join(dirname(session.file), `sealkeep-pack-${basename(session.file, ".jsonl")}.md`);
158
+ const agent = session.tool === "codex" ? "codex" : "claude";
159
+ return {
160
+ session, archiveId: archive?.id ?? null, searchable,
161
+ packPath, markdown, packTokens,
162
+ resumeCostPerStep: session.perStep,
163
+ freshCostPerStep: freshPerStep,
164
+ savingPerStep: Math.max(0, session.perStep - freshPerStep),
165
+ freshCommand: `${agent} "$(cat ${packPath})"`,
166
+ warnings,
167
+ };
168
+ }
169
+ export async function writePack(plan) {
170
+ await mkdir(dirname(plan.packPath), { recursive: true }).catch(() => undefined);
171
+ await writeFile(plan.packPath, `${plan.markdown}\n`, "utf8");
172
+ return plan.packPath;
173
+ }
174
+ const tokens = (n) => n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${Math.round(n / 1e3)}k` : String(Math.round(n));
175
+ export function renderFork(plan, written) {
176
+ const out = [
177
+ `The most expensive session you have open is the ${plan.session.tool} one in ${basename(plan.session.project) || plan.session.project}.`,
178
+ "",
179
+ ` carrying on there ${tokens(plan.resumeCostPerStep).padStart(7)} tokens per step, ${Math.round(plan.session.historyShare * 100)}% of it re-read history`,
180
+ ` starting fresh ${tokens(plan.freshCostPerStep).padStart(7)} tokens per step, carrying a ${tokens(plan.packTokens)}-token pack instead`,
181
+ ` you would save ${tokens(plan.savingPerStep).padStart(7)} tokens on every step from here on`,
182
+ "",
183
+ ];
184
+ if (plan.warnings.length) {
185
+ out.push("Before you walk away from it:");
186
+ for (const warning of plan.warnings)
187
+ out.push(` ! ${warning}`);
188
+ out.push("");
189
+ }
190
+ else {
191
+ out.push(`The full session is sealed${plan.archiveId ? ` (${plan.archiveId})` : ""} and searchable, so nothing is lost by leaving it.`, "");
192
+ }
193
+ out.push(written ? `Pack written to ${plan.packPath}` : `Pack (not written; pass --write):`);
194
+ out.push("", plan.markdown, "");
195
+ out.push("Start fresh with it:", ` ${plan.freshCommand}`);
196
+ return out.join("\n");
197
+ }
@@ -262,6 +262,10 @@ async function chooseCommonTarget(dataDir, chain, config, options = {}) {
262
262
  let additional = 0;
263
263
  let eligible = true;
264
264
  for (const item of chain.records) {
265
+ // A long chain can outlast the caller's idle deadline on its own, and
266
+ // every link here is a real step. Report it the same way the loop above
267
+ // does, so proving a destination never looks like a stall.
268
+ options.onProgress?.(0);
265
269
  if (!isV2(item)) {
266
270
  eligible = false;
267
271
  break;
@@ -389,10 +393,24 @@ export async function uploadAndOffloadPending(dataDir, options = {}) {
389
393
  continue;
390
394
  if (attempted >= limit)
391
395
  break;
396
+ // Choosing a destination is real work that moves no bytes: it resolves
397
+ // targets, walks every link of a chain and may ask the account whether a
398
+ // copy already matches. On a vault with thousands of archives that costs
399
+ // minutes before the first byte, and the daemon's idle deadline — which
400
+ // only ever sees byte progress — killed the pass at two minutes, every
401
+ // pass, forever. Measured on a real 5,062-archive vault: first byte at
402
+ // 168s against a 120s deadline, so automatic uploads could never run
403
+ // again. Tell the deadline that preparation is progress; a genuinely
404
+ // stalled transfer still trips it, because that reports no bytes either.
405
+ uploadOptions.onProgress?.(0);
392
406
  let targetId;
393
407
  if (chain) {
394
408
  if (!commonTargets.has(chain.rootId)) {
395
- commonTargets.set(chain.rootId, await chooseCommonTarget(dataDir, chain, config, { clientProvided: Boolean(uploadOptions.client) }));
409
+ commonTargets.set(chain.rootId, await chooseCommonTarget(dataDir, chain, config, {
410
+ clientProvided: Boolean(uploadOptions.client),
411
+ onProgress: uploadOptions.onProgress,
412
+ }));
413
+ uploadOptions.onProgress?.(0);
396
414
  }
397
415
  const chosen = commonTargets.get(chain.rootId);
398
416
  if (!chosen) {
@@ -212,6 +212,16 @@ const localSettingsSchema = z.object({
212
212
  trashStrategy: z.enum(TRASH_STRATEGIES).transform(canonicalStrategy).optional(),
213
213
  schedule: z.object({ intervalMinutes: z.number().min(1).max(1440) }).strict().optional(),
214
214
  bandwidth: z.object({ uploadLimitMbps: z.number().min(0).max(100_000).nullable() }).strict().optional(),
215
+ // What reaches the agent. Injection is convenient and it is re-read on every
216
+ // later step of a conversation, so it is a switch rather than a given.
217
+ recall: z.object({ autoInject: z.boolean() }).strict().optional(),
218
+ mcp: z.object({ enabled: z.boolean() }).strict().optional(),
219
+ // Ceilings for background work. A floor on each, because a limit below it
220
+ // would stall the work rather than pace it. null returns to automatic.
221
+ limits: z.object({
222
+ cpuPercent: z.number().min(5).max(100).nullable(),
223
+ memoryMb: z.number().int().min(128).max(65_536).nullable(),
224
+ }).strict().optional(),
215
225
  // 200 MB is the hard floor: below that, operating systems fail in ways no
216
226
  // archive tool should be the cause of. null returns to automatic.
217
227
  diskReserveMb: z.number().min(200).max(102_400).nullable().optional(),
@@ -24,6 +24,27 @@ export type LocalSettings = {
24
24
  * every sealed copy locally; 0 lets a verified duplicate leave immediately.
25
25
  */
26
26
  localArchiveCacheDays: number | null;
27
+ /**
28
+ * Whether preserved history is volunteered into a session, or only written
29
+ * as notes the agent reads for itself. Injection is convenient and it is not
30
+ * free: everything already in a conversation is re-read on every later
31
+ * request, so a block added early is paid for again on every step after it.
32
+ */
33
+ recall: {
34
+ autoInject: boolean;
35
+ };
36
+ /** The local MCP server that lets an agent search this vault by itself. */
37
+ mcp: {
38
+ enabled: boolean;
39
+ };
40
+ /**
41
+ * Ceilings for background work on this machine. null means Sealkeep picks a
42
+ * safe value; the network limit lives in `bandwidth` beside them.
43
+ */
44
+ limits: {
45
+ cpuPercent: number | null;
46
+ memoryMb: number | null;
47
+ };
27
48
  };
28
49
  /**
29
50
  * True when an automatic watcher must leave this transcript alone.
@@ -44,8 +65,16 @@ export declare function defaultLocalSettings(): LocalSettings;
44
65
  export declare const DEFAULT_BACKGROUND_UPLOAD_LIMIT_MBPS = 8;
45
66
  export declare function backgroundUploadBytesPerSecond(settings: Pick<LocalSettings, "bandwidth">): number;
46
67
  export declare const localSettingsPath: (dataDir: string) => string;
47
- /** Missing/older fields inherit safe defaults; this file is preferences, not cryptographic state. */
48
68
  export declare function readLocalSettings(dataDir: string): Promise<LocalSettings>;
69
+ /**
70
+ * The ceilings this machine should actually use for background work.
71
+ *
72
+ * A person who sets one means "be gentle on this machine", so a setting only
73
+ * ever tightens what Sealkeep already chose — it never raises a limit past the
74
+ * safe automatic value, and null means "you decide".
75
+ */
76
+ export declare function effectiveCpuTarget(settings: LocalSettings, automatic: number): number;
77
+ export declare function effectiveMaxRssBytes(settings: LocalSettings, automatic: number): number;
49
78
  /** Atomic and owner-only: per-machine preferences are never synced. */
50
79
  export declare function writeLocalSettings(dataDir: string, settings: LocalSettings): Promise<void>;
51
80
  export declare function resolvedTrashStrategy(settings: LocalSettings): TrashResult["strategy"];
@@ -110,6 +110,12 @@ export function defaultLocalSettings() {
110
110
  // A week keeps recent memory restorable while offline without allowing a
111
111
  // second, encrypted copy of the whole history to grow forever on disk.
112
112
  localArchiveCacheDays: 7,
113
+ // On by default: an agent that is handed its history at the start of a
114
+ // session is the product. It is deduplicated and never repeats itself, so
115
+ // the standing cost is small — but it is a cost, and it is switchable.
116
+ recall: { autoInject: true },
117
+ mcp: { enabled: true },
118
+ limits: { cpuPercent: null, memoryMb: null },
113
119
  };
114
120
  }
115
121
  /** Automatic work must not infer "unlimited" from an older settings file.
@@ -125,6 +131,12 @@ export function backgroundUploadBytesPerSecond(settings) {
125
131
  }
126
132
  export const localSettingsPath = (dataDir) => join(dataDir, "local-settings.json");
127
133
  /** Missing/older fields inherit safe defaults; this file is preferences, not cryptographic state. */
134
+ /** A bounded number, or null for "let Sealkeep choose". */
135
+ function boundedOrNull(value, low, high, fallback) {
136
+ if (value === null)
137
+ return null;
138
+ return typeof value === "number" && Number.isFinite(value) && value >= low && value <= high ? value : fallback;
139
+ }
128
140
  export async function readLocalSettings(dataDir) {
129
141
  const fallback = defaultLocalSettings();
130
142
  try {
@@ -139,6 +151,15 @@ export async function readLocalSettings(dataDir) {
139
151
  exclusions: Array.isArray(saved?.exclusions) ? saved.exclusions.filter((path) => typeof path === "string") : [],
140
152
  schedule: { ...fallback.schedule, ...saved?.schedule },
141
153
  bandwidth: { ...fallback.bandwidth, ...saved?.bandwidth },
154
+ recall: { autoInject: typeof saved?.recall?.autoInject === "boolean" ? saved.recall.autoInject : fallback.recall.autoInject },
155
+ mcp: { enabled: typeof saved?.mcp?.enabled === "boolean" ? saved.mcp.enabled : fallback.mcp.enabled },
156
+ limits: {
157
+ // A floor of 5% and 128 MB, because a ceiling below those would stall
158
+ // rather than pace, and a person setting one means "be gentle", not
159
+ // "never finish".
160
+ cpuPercent: boundedOrNull(saved?.limits?.cpuPercent, 5, 100, fallback.limits.cpuPercent),
161
+ memoryMb: boundedOrNull(saved?.limits?.memoryMb, 128, 65_536, fallback.limits.memoryMb),
162
+ },
142
163
  localArchiveCacheDays: saved?.localArchiveCacheDays === null
143
164
  ? null
144
165
  : typeof saved?.localArchiveCacheDays === "number"
@@ -153,6 +174,25 @@ export async function readLocalSettings(dataDir) {
153
174
  return fallback;
154
175
  }
155
176
  }
177
+ /**
178
+ * The ceilings this machine should actually use for background work.
179
+ *
180
+ * A person who sets one means "be gentle on this machine", so a setting only
181
+ * ever tightens what Sealkeep already chose — it never raises a limit past the
182
+ * safe automatic value, and null means "you decide".
183
+ */
184
+ export function effectiveCpuTarget(settings, automatic) {
185
+ const asked = settings.limits?.cpuPercent;
186
+ if (typeof asked !== "number")
187
+ return automatic;
188
+ return Math.min(automatic, Math.max(0.05, asked / 100));
189
+ }
190
+ export function effectiveMaxRssBytes(settings, automatic) {
191
+ const asked = settings.limits?.memoryMb;
192
+ if (typeof asked !== "number")
193
+ return automatic;
194
+ return Math.min(automatic, Math.max(128, asked) * 1024 * 1024);
195
+ }
156
196
  /** Atomic and owner-only: per-machine preferences are never synced. */
157
197
  export async function writeLocalSettings(dataDir, settings) {
158
198
  await mkdir(dataDir, { recursive: true, mode: 0o700 });
package/dist/src/mcp.js CHANGED
@@ -325,5 +325,14 @@ registerTool("sealkeep_bridge_inbox", {
325
325
  const waiting = await pendingCount(dataDir, channel);
326
326
  return text({ channel, messages: pending, stillWaiting: waiting, context: promptInjection(pending, waiting) || null });
327
327
  });
328
+ // The Settings page can turn this server off for a machine. Exiting cleanly is
329
+ // the honest behaviour: the agent sees a server that is simply not there,
330
+ // rather than one that answers every question with a refusal.
331
+ const { readLocalSettings } = await import("./machine-settings.js");
332
+ const mcpEnabled = await readLocalSettings(dataDir).then((settings) => settings.mcp.enabled).catch(() => true);
333
+ if (!mcpEnabled) {
334
+ console.error("Sealkeep's local MCP server is switched off for this machine (Settings → Agent access).");
335
+ process.exit(0);
336
+ }
328
337
  const transport = new StdioServerTransport();
329
338
  server.connect(transport).catch((error) => { console.error(error); process.exit(1); });
@@ -0,0 +1,75 @@
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
+ /** A fork carries rules, a statement of what is true now, and the last handoff. */
19
+ export declare const CONTEXT_PACK_TOKENS = 3000;
20
+ /** Each of these fired on a real machine the night the limits died. */
21
+ export type Signal = "BLOATED" | "STALE" | "POLLING";
22
+ export type SessionCost = {
23
+ tool: "claude" | "codex";
24
+ file: string;
25
+ project: string;
26
+ sizeMB: number;
27
+ ageDays: number;
28
+ steps: number;
29
+ tools: number;
30
+ sleeps: number;
31
+ /** Everything sent into the model: re-read history plus genuinely new text. */
32
+ input: number;
33
+ /** The re-read part. Discounted by providers, still counted by limits. */
34
+ cached: number;
35
+ fresh: number;
36
+ output: number;
37
+ perStep: number;
38
+ historyShare: number;
39
+ /** The same steps, in a fresh session carrying a context pack. */
40
+ freshCost: number;
41
+ saving: number;
42
+ signals: Signal[];
43
+ };
44
+ export type MeterSummary = {
45
+ hours: number;
46
+ sessions: SessionCost[];
47
+ totalInput: number;
48
+ totalFreshCost: number;
49
+ totalSaving: number;
50
+ savingShare: number;
51
+ worst?: SessionCost;
52
+ polling: SessionCost[];
53
+ };
54
+ /**
55
+ * Which sessions are worth saying something about. Thresholds are deliberately
56
+ * boring: each one was true of a real session that ended a weekly allowance.
57
+ */
58
+ export declare function signalsFor(input: Pick<SessionCost, "historyShare" | "perStep" | "ageDays" | "sizeMB" | "sleeps">): Signal[];
59
+ /** Turns raw counters into the numbers a person can act on. */
60
+ export declare function scoreSession(raw: Omit<SessionCost, "perStep" | "historyShare" | "freshCost" | "saving" | "signals">): SessionCost;
61
+ export type MeterOptions = {
62
+ hours?: number;
63
+ /** Override for tests; defaults to this machine's agent session directories. */
64
+ roots?: {
65
+ claude?: string;
66
+ codex?: string;
67
+ };
68
+ now?: number;
69
+ };
70
+ /** Reads local session files and scores every session touched in the window. */
71
+ export declare function meterSessions(options?: MeterOptions): Promise<MeterSummary>;
72
+ /** The receipt for one session, in the words a person would use. */
73
+ export declare function renderReceipt(session: SessionCost): string;
74
+ /** The whole picture, for `sealkeep meter`. */
75
+ export declare function renderMeter(summary: MeterSummary): string;