sealkeep 0.11.2 → 0.11.4
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/ARCHITECTURE.md +20 -0
- package/CHANGELOG.md +146 -0
- package/README.md +117 -3
- package/dist/site/index.html +23 -1
- package/dist/site.zip +0 -0
- package/dist/src/adapters.js +12 -5
- package/dist/src/agent-context.js +169 -18
- package/dist/src/agent-memory.d.ts +52 -0
- package/dist/src/agent-memory.js +128 -0
- package/dist/src/cli.js +64 -4
- package/dist/src/daemon.d.ts +3 -0
- package/dist/src/daemon.js +52 -7
- package/dist/src/doctor.js +23 -0
- package/dist/src/fork.d.ts +78 -0
- package/dist/src/fork.js +197 -0
- package/dist/src/heartbeat.js +19 -1
- package/dist/src/local-api.js +10 -0
- package/dist/src/machine-settings.d.ts +30 -1
- package/dist/src/machine-settings.js +40 -0
- package/dist/src/mcp.js +9 -0
- package/dist/src/meter.d.ts +75 -0
- package/dist/src/meter.js +241 -0
- package/dist/src/search.d.ts +2 -1
- package/dist/src/search.js +60 -21
- package/dist/src/team-presence.js +15 -3
- package/dist/src/vault.d.ts +14 -0
- package/dist/src/vault.js +16 -0
- package/package.json +2 -2
- package/web/app.js +21 -0
- package/web/index.html +21 -0
package/dist/src/doctor.js
CHANGED
|
@@ -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;
|
package/dist/src/fork.js
ADDED
|
@@ -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
|
+
}
|
package/dist/src/heartbeat.js
CHANGED
|
@@ -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, {
|
|
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) {
|
package/dist/src/local-api.js
CHANGED
|
@@ -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;
|