privateer-agent 0.2.1 → 0.3.0

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.
Files changed (111) hide show
  1. package/README.md +135 -443
  2. package/bin/privateer-tui +86 -0
  3. package/bin/privateer.mjs +17 -6
  4. package/bin/pv +28 -0
  5. package/package.json +26 -23
  6. package/src/auth/privateer.ts +57 -0
  7. package/src/boot.ts +43 -0
  8. package/src/bridge/engineAdapter.ts +182 -0
  9. package/src/cli/chat.ts +243 -0
  10. package/src/config/paths.ts +24 -46
  11. package/src/config/permissionMode.ts +5 -0
  12. package/src/crypto/outboxSeal.ts +61 -0
  13. package/src/daemon/index.ts +204 -121
  14. package/src/daemon/ipc.ts +1 -1
  15. package/src/engine/errors.ts +85 -45
  16. package/src/engine/router.ts +11 -165
  17. package/src/ext/permissionGate.ts +216 -0
  18. package/src/main.ts +32 -0
  19. package/src/permissions/classify.ts +172 -0
  20. package/src/permissions/gate.ts +11 -14
  21. package/src/permissions/mode.ts +6 -2
  22. package/src/permissions/{uiGate.ts → modeGate.ts} +18 -3
  23. package/src/providers/account.ts +170 -0
  24. package/src/providers/catalog.ts +73 -61
  25. package/src/providers/genModelsJson.ts +97 -0
  26. package/src/remote/relayClient.ts +15 -0
  27. package/src/remote/remoteBridge.ts +152 -0
  28. package/src/routines/delivery.ts +118 -9
  29. package/src/routines/schema.ts +24 -5
  30. package/src/routines/store.ts +44 -1
  31. package/src/routines/toolSelect.ts +3 -1
  32. package/src/session.ts +81 -273
  33. package/src/tools/routine.ts +120 -101
  34. package/src/tools/saveAttachment.ts +39 -42
  35. package/src/tools/sendFile.ts +75 -0
  36. package/src/util/attachmentStore.ts +18 -35
  37. package/src/util/redact.ts +33 -3
  38. package/LICENSE +0 -21
  39. package/src/agents/loader.ts +0 -49
  40. package/src/commands/custom.ts +0 -123
  41. package/src/commands/registry.ts +0 -618
  42. package/src/components/AgentGroupView.tsx +0 -104
  43. package/src/components/App.tsx +0 -1626
  44. package/src/components/ApprovalPrompt.tsx +0 -49
  45. package/src/components/Banner.tsx +0 -78
  46. package/src/components/Markdown.tsx +0 -183
  47. package/src/components/ModeHint.tsx +0 -40
  48. package/src/components/ModelPicker.tsx +0 -302
  49. package/src/components/Onboarding.tsx +0 -203
  50. package/src/components/OptionPicker.tsx +0 -134
  51. package/src/components/PlanConfirm.tsx +0 -37
  52. package/src/components/PrivateerLogin.tsx +0 -109
  53. package/src/components/PromptInput.tsx +0 -602
  54. package/src/components/RewindPicker.tsx +0 -69
  55. package/src/components/Root.tsx +0 -116
  56. package/src/components/SessionPicker.tsx +0 -64
  57. package/src/components/StatusBar.tsx +0 -131
  58. package/src/components/TodoPanel.tsx +0 -36
  59. package/src/components/ToolCallView.tsx +0 -113
  60. package/src/components/Transcript.tsx +0 -210
  61. package/src/components/figures.ts +0 -14
  62. package/src/components/promptModel.ts +0 -73
  63. package/src/components/spinnerVerbs.ts +0 -46
  64. package/src/components/theme.ts +0 -57
  65. package/src/components/types.ts +0 -34
  66. package/src/components/useTeeShield.ts +0 -104
  67. package/src/components/useTerminalWidth.ts +0 -24
  68. package/src/components/useZdrShield.ts +0 -126
  69. package/src/config/load.ts +0 -115
  70. package/src/config/schema.ts +0 -94
  71. package/src/context/outputStyles.ts +0 -42
  72. package/src/context/projectInfo.ts +0 -59
  73. package/src/context/systemPrompt.ts +0 -176
  74. package/src/engine/QueryEngine.ts +0 -399
  75. package/src/hooks/engine.ts +0 -155
  76. package/src/main.tsx +0 -209
  77. package/src/mcp/client.ts +0 -251
  78. package/src/mcp/oauth.ts +0 -245
  79. package/src/memory/auto.ts +0 -146
  80. package/src/memory/checkpoints.ts +0 -227
  81. package/src/memory/store.ts +0 -127
  82. package/src/providers/attestation.ts +0 -149
  83. package/src/providers/capabilities.ts +0 -104
  84. package/src/providers/models.ts +0 -183
  85. package/src/providers/registry.ts +0 -71
  86. package/src/providers/resolve.ts +0 -78
  87. package/src/skills/installer.ts +0 -222
  88. package/src/skills/loader.ts +0 -88
  89. package/src/tools/askUser.ts +0 -92
  90. package/src/tools/bash.ts +0 -98
  91. package/src/tools/context.ts +0 -128
  92. package/src/tools/edit.ts +0 -67
  93. package/src/tools/exec.ts +0 -60
  94. package/src/tools/glob.ts +0 -39
  95. package/src/tools/grep.ts +0 -86
  96. package/src/tools/index.ts +0 -83
  97. package/src/tools/memory.ts +0 -53
  98. package/src/tools/processRegistry.ts +0 -77
  99. package/src/tools/read.ts +0 -42
  100. package/src/tools/sendFileToClient.ts +0 -55
  101. package/src/tools/skill.ts +0 -44
  102. package/src/tools/task.ts +0 -52
  103. package/src/tools/todo.ts +0 -36
  104. package/src/tools/todoStore.ts +0 -31
  105. package/src/tools/walk.ts +0 -44
  106. package/src/tools/web.ts +0 -145
  107. package/src/tools/worktree.ts +0 -145
  108. package/src/tools/write.ts +0 -40
  109. package/src/util/images.ts +0 -378
  110. package/src/util/limit.ts +0 -32
  111. package/src/version.ts +0 -13
package/src/mcp/oauth.ts DELETED
@@ -1,245 +0,0 @@
1
- import { createServer, type Server as HttpServer } from "node:http";
2
- import { spawn } from "node:child_process";
3
- import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync, rmSync } from "node:fs";
4
- import { join } from "node:path";
5
- import { createHash, randomBytes } from "node:crypto";
6
- import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js";
7
- import type {
8
- OAuthClientInformation,
9
- OAuthClientInformationFull,
10
- OAuthClientMetadata,
11
- OAuthTokens,
12
- } from "@modelcontextprotocol/sdk/shared/auth.js";
13
- import { globalPaths } from "../config/paths.ts";
14
-
15
- // Called when interactive consent is needed, so the host (e.g. the TUI) can show
16
- // the URL in case the browser doesn't open on its own.
17
- export type AuthorizePrompt = (info: { server: string; url: string }) => void;
18
-
19
- // Everything we persist between runs for one remote server. Written owner-only
20
- // (0600) since it holds OAuth tokens. `port` is pinned so the loopback redirect
21
- // URI stays stable across runs (and thus matches the registered client).
22
- interface AuthStore {
23
- port?: number;
24
- state?: string;
25
- codeVerifier?: string;
26
- clientInformation?: OAuthClientInformationFull;
27
- tokens?: OAuthTokens;
28
- }
29
-
30
- const DEFAULT_PORT = 7777;
31
- const CALLBACK_PATH = "/oauth/callback";
32
-
33
- function authDir(): string {
34
- return join(globalPaths().dir, "mcp-auth");
35
- }
36
-
37
- // One file per server, keyed by a short hash of its URL.
38
- function storePath(serverUrl: string): string {
39
- const hash = createHash("sha256").update(serverUrl).digest("hex").slice(0, 16);
40
- return join(authDir(), `${hash}.json`);
41
- }
42
-
43
- // An interactive OAuth 2.1 (PKCE + dynamic client registration) provider that
44
- // persists credentials to disk and catches the redirect on a loopback server.
45
- // One instance per remote server connection.
46
- export class FileOAuthProvider implements OAuthClientProvider {
47
- private store: AuthStore;
48
- private readonly path: string;
49
- private port: number;
50
- private server?: HttpServer;
51
- private redirect?: { resolve: (code: string) => void; reject: (e: Error) => void };
52
-
53
- constructor(
54
- private readonly serverName: string,
55
- private readonly serverUrl: string,
56
- private readonly onAuthorize?: AuthorizePrompt,
57
- ) {
58
- this.path = storePath(serverUrl);
59
- this.store = readStore(this.path);
60
- const envPort = Number(process.env.PRIVATEER_OAUTH_PORT);
61
- this.port = this.store.port ?? (Number.isInteger(envPort) ? envPort : DEFAULT_PORT);
62
- }
63
-
64
- get redirectUrl(): string {
65
- return `http://127.0.0.1:${this.port}${CALLBACK_PATH}`;
66
- }
67
-
68
- get clientMetadata(): OAuthClientMetadata {
69
- return {
70
- client_name: "Privateer",
71
- redirect_uris: [this.redirectUrl],
72
- grant_types: ["authorization_code", "refresh_token"],
73
- response_types: ["code"],
74
- token_endpoint_auth_method: "none", // public client (PKCE), no secret
75
- };
76
- }
77
-
78
- clientInformation(): OAuthClientInformation | undefined {
79
- return this.store.clientInformation;
80
- }
81
-
82
- saveClientInformation(info: OAuthClientInformationFull): void {
83
- this.store.clientInformation = info;
84
- this.persist();
85
- }
86
-
87
- tokens(): OAuthTokens | undefined {
88
- return this.store.tokens;
89
- }
90
-
91
- saveTokens(tokens: OAuthTokens): void {
92
- this.store.tokens = tokens;
93
- this.persist();
94
- }
95
-
96
- saveCodeVerifier(verifier: string): void {
97
- this.store.codeVerifier = verifier;
98
- this.persist();
99
- }
100
-
101
- codeVerifier(): string {
102
- if (!this.store.codeVerifier) throw new Error("no PKCE code verifier saved");
103
- return this.store.codeVerifier;
104
- }
105
-
106
- state(): string {
107
- this.store.state = randomBytes(16).toString("hex");
108
- this.persist();
109
- return this.store.state;
110
- }
111
-
112
- // Drop persisted credentials when the server says they're stale, so the next
113
- // connect re-runs discovery / re-authorizes instead of looping on 401s.
114
- invalidateCredentials(scope: "all" | "client" | "tokens" | "verifier" | "discovery"): void {
115
- if (scope === "all") this.store = { port: this.store.port };
116
- else if (scope === "client") delete this.store.clientInformation;
117
- else if (scope === "tokens") delete this.store.tokens;
118
- else if (scope === "verifier") delete this.store.codeVerifier;
119
- this.persist();
120
- }
121
-
122
- // Bind the loopback listener, open the browser, and arm the wait. The SDK calls
123
- // this during auth() right before it throws UnauthorizedError.
124
- async redirectToAuthorization(authorizationUrl: URL): Promise<void> {
125
- await this.listen();
126
- this.onAuthorize?.({ server: this.serverName, url: authorizationUrl.toString() });
127
- openBrowser(authorizationUrl.toString());
128
- }
129
-
130
- // Resolves with the authorization code once the user is redirected back. Caller
131
- // passes it to transport.finishAuth(). Always tears the listener down after.
132
- waitForCode(timeoutMs = 300_000): Promise<string> {
133
- return new Promise<string>((resolve, reject) => {
134
- const done = (fn: () => void) => {
135
- clearTimeout(timer);
136
- this.closeServer();
137
- fn();
138
- };
139
- const timer = setTimeout(
140
- () => done(() => reject(new Error(`OAuth for "${this.serverName}" timed out`))),
141
- timeoutMs,
142
- );
143
- this.redirect = {
144
- resolve: (code) => done(() => resolve(code)),
145
- reject: (e) => done(() => reject(e)),
146
- };
147
- });
148
- }
149
-
150
- private listen(): Promise<void> {
151
- if (this.server) return Promise.resolve();
152
- return new Promise<void>((resolve, reject) => {
153
- const server = createServer((req, res) => this.handleCallback(req.url ?? "", res));
154
- server.once("error", (err) => reject(new Error(`OAuth loopback on port ${this.port}: ${err.message}`)));
155
- server.listen(this.port, "127.0.0.1", () => {
156
- this.server = server;
157
- // Capture the actually-bound port (matters when port 0 = ephemeral) and pin
158
- // it, so future runs reuse the same redirect URI the client registered with.
159
- const addr = server.address();
160
- if (addr && typeof addr === "object") this.port = addr.port;
161
- this.store.port = this.port;
162
- this.persist();
163
- resolve();
164
- });
165
- });
166
- }
167
-
168
- private handleCallback(rawUrl: string, res: import("node:http").ServerResponse): void {
169
- const url = new URL(rawUrl, this.redirectUrl);
170
- if (url.pathname !== CALLBACK_PATH) {
171
- res.writeHead(404).end();
172
- return;
173
- }
174
- const code = url.searchParams.get("code");
175
- const err = url.searchParams.get("error");
176
- const state = url.searchParams.get("state");
177
- res.writeHead(200, { "content-type": "text/html" });
178
- res.end(
179
- `<!doctype html><body style="font-family:system-ui;padding:2rem">` +
180
- `<h2>${code ? "Authorized — you can close this tab." : "Authorization failed."}</h2>` +
181
- `</body>`,
182
- );
183
- if (err) return this.redirect?.reject(new Error(`authorization error: ${err}`));
184
- if (state && this.store.state && state !== this.store.state) {
185
- return this.redirect?.reject(new Error("OAuth state mismatch (possible CSRF)"));
186
- }
187
- if (code) this.redirect?.resolve(code);
188
- else this.redirect?.reject(new Error("authorization callback missing code"));
189
- }
190
-
191
- private closeServer(): void {
192
- this.server?.close();
193
- this.server = undefined;
194
- }
195
-
196
- private persist(): void {
197
- mkdirSync(authDir(), { recursive: true });
198
- tryChmod(authDir(), 0o700);
199
- writeFileSync(this.path, JSON.stringify(this.store, null, 2) + "\n", { encoding: "utf8", mode: 0o600 });
200
- tryChmod(this.path, 0o600);
201
- }
202
- }
203
-
204
- function readStore(path: string): AuthStore {
205
- if (!existsSync(path)) return {};
206
- try {
207
- return JSON.parse(readFileSync(path, "utf8")) as AuthStore;
208
- } catch {
209
- return {};
210
- }
211
- }
212
-
213
- function tryChmod(path: string, mode: number): void {
214
- try {
215
- chmodSync(path, mode);
216
- } catch {
217
- /* non-POSIX filesystem — best effort */
218
- }
219
- }
220
-
221
- // Open a URL in the user's default browser. Best-effort and non-blocking; if it
222
- // fails the URL was already surfaced via the AuthorizePrompt callback.
223
- function openBrowser(url: string): void {
224
- // Headless / CI / tests: skip the launch; the URL is surfaced via AuthorizePrompt.
225
- if (process.env.PRIVATEER_NO_BROWSER) return;
226
- const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
227
- try {
228
- const child = spawn(cmd, [url], { stdio: "ignore", detached: true, shell: process.platform === "win32" });
229
- child.on("error", () => {});
230
- child.unref();
231
- } catch {
232
- /* headless / no browser — user uses the printed URL */
233
- }
234
- }
235
-
236
- // Wipe stored OAuth state for one server (used by `/mcp logout`).
237
- export function clearStoredAuth(serverUrl: string): void {
238
- const path = storePath(serverUrl);
239
- if (existsSync(path)) rmSync(path, { force: true });
240
- }
241
-
242
- // Whether we hold an OAuth access token for this server (for `/mcp` status).
243
- export function hasStoredAuth(serverUrl: string): boolean {
244
- return Boolean(readStore(storePath(serverUrl)).tokens?.access_token);
245
- }
@@ -1,146 +0,0 @@
1
- import { mkdirSync, writeFileSync, readFileSync, existsSync, readdirSync, rmSync } from "node:fs";
2
- import { join } from "node:path";
3
- import { globalDir } from "../config/load.ts";
4
- import { parseFrontmatter } from "../commands/custom.ts";
5
- import { projectKey } from "./store.ts";
6
-
7
- // Agent-authored "auto-memory": durable facts the agent records across runs, modeled on
8
- // Claude Code's memory files. Each memory is one markdown file with flat frontmatter
9
- // (so it round-trips through parseFrontmatter, which only reads `key: value` lines) plus
10
- // a body. A per-scope MEMORY.md index lists them and is recalled into the system prompt.
11
- //
12
- // Two scopes: "project" memories live under the per-project dir (keyed by cwd) and only
13
- // recall in that project; "global" memories live under the global dir and recall
14
- // everywhere. Project memories win on a name clash.
15
-
16
- export type MemoryType = "user" | "feedback" | "project" | "reference";
17
- export type MemoryScope = "project" | "global";
18
-
19
- export interface MemoryRecord {
20
- name: string;
21
- description: string;
22
- type: MemoryType;
23
- scope: MemoryScope;
24
- body: string;
25
- path: string;
26
- }
27
-
28
- const INDEX_FILE = "MEMORY.md";
29
- const VALID_TYPES: MemoryType[] = ["user", "feedback", "project", "reference"];
30
-
31
- function memoryDir(scope: MemoryScope, cwd: string): string {
32
- return scope === "global"
33
- ? join(globalDir(), "memory")
34
- : join(globalDir(), "projects", projectKey(cwd), "memory");
35
- }
36
-
37
- // Constrain a proposed name to a safe, kebab-ish file stem (no path traversal, no spaces).
38
- export function sanitizeName(name: string): string {
39
- return name
40
- .toLowerCase()
41
- .replace(/[^a-z0-9._-]+/g, "-")
42
- .replace(/^[-.]+|[-.]+$/g, "")
43
- .slice(0, 80);
44
- }
45
-
46
- function coerceType(raw: string | undefined): MemoryType {
47
- return VALID_TYPES.includes(raw as MemoryType) ? (raw as MemoryType) : "project";
48
- }
49
-
50
- function readDir(scope: MemoryScope, cwd: string): MemoryRecord[] {
51
- const dir = memoryDir(scope, cwd);
52
- if (!existsSync(dir)) return [];
53
- const out: MemoryRecord[] = [];
54
- for (const file of readdirSync(dir)) {
55
- if (!file.endsWith(".md") || file === INDEX_FILE) continue;
56
- const path = join(dir, file);
57
- const { meta, body } = parseFrontmatter(readFileSync(path, "utf8"));
58
- const name = meta.name || file.replace(/\.md$/, "");
59
- out.push({
60
- name,
61
- description: meta.description ?? "",
62
- type: coerceType(meta.type),
63
- scope,
64
- body: body.trim(),
65
- path,
66
- });
67
- }
68
- return out;
69
- }
70
-
71
- // Regenerate a scope's MEMORY.md from the memory files it contains. Regenerating (vs.
72
- // editing in place) keeps the index free of stale or duplicate lines.
73
- function rebuildIndex(scope: MemoryScope, cwd: string): void {
74
- const dir = memoryDir(scope, cwd);
75
- const records = readDir(scope, cwd).sort((a, b) => a.name.localeCompare(b.name));
76
- const indexPath = join(dir, INDEX_FILE);
77
- if (records.length === 0) {
78
- if (existsSync(indexPath)) rmSync(indexPath, { force: true });
79
- return;
80
- }
81
- const lines = [
82
- "# Memory Index",
83
- "",
84
- ...records.map((r) => `- [${r.name}](${r.name}.md) — ${r.description}`),
85
- "",
86
- ];
87
- mkdirSync(dir, { recursive: true });
88
- writeFileSync(indexPath, lines.join("\n"), "utf8");
89
- }
90
-
91
- // All memories visible from this cwd: project entries override global on a name clash.
92
- export function listMemories(cwd: string): MemoryRecord[] {
93
- const byName = new Map<string, MemoryRecord>();
94
- for (const r of readDir("global", cwd)) byName.set(r.name, r);
95
- for (const r of readDir("project", cwd)) byName.set(r.name, r);
96
- return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
97
- }
98
-
99
- export function readMemory(cwd: string, name: string): MemoryRecord | null {
100
- const key = sanitizeName(name);
101
- return listMemories(cwd).find((r) => r.name === key) ?? null;
102
- }
103
-
104
- export function saveMemory(
105
- cwd: string,
106
- input: { name: string; description: string; type?: MemoryType; scope?: MemoryScope; body: string },
107
- ): MemoryRecord {
108
- const scope: MemoryScope = input.scope === "global" ? "global" : "project";
109
- const name = sanitizeName(input.name);
110
- if (!name) throw new Error("memory name is empty after sanitizing");
111
- const type = coerceType(input.type);
112
- const dir = memoryDir(scope, cwd);
113
- mkdirSync(dir, { recursive: true });
114
- const path = join(dir, `${name}.md`);
115
- const frontmatter = [
116
- "---",
117
- `name: ${name}`,
118
- `description: ${input.description.replace(/\n/g, " ").trim()}`,
119
- `type: ${type}`,
120
- `scope: ${scope}`,
121
- "---",
122
- ].join("\n");
123
- writeFileSync(path, `${frontmatter}\n${input.body.trim()}\n`, "utf8");
124
- rebuildIndex(scope, cwd);
125
- return { name, description: input.description, type, scope, body: input.body.trim(), path };
126
- }
127
-
128
- export function deleteMemory(cwd: string, name: string): MemoryRecord | null {
129
- const existing = readMemory(cwd, name);
130
- if (!existing) return null;
131
- rmSync(existing.path, { force: true });
132
- rebuildIndex(existing.scope, cwd);
133
- return existing;
134
- }
135
-
136
- // The memory index(es) to recall into the system prompt, or null when there are none.
137
- export function loadMemoryContext(cwd: string): string | null {
138
- const sections: string[] = [];
139
- for (const scope of ["project", "global"] as const) {
140
- const indexPath = join(memoryDir(scope, cwd), INDEX_FILE);
141
- if (!existsSync(indexPath)) continue;
142
- const body = readFileSync(indexPath, "utf8").trim();
143
- if (body) sections.push(`(${scope})\n${body}`);
144
- }
145
- return sections.length ? sections.join("\n\n") : null;
146
- }
@@ -1,227 +0,0 @@
1
- import { createHash } from "node:crypto";
2
- import { existsSync, readFileSync, writeFileSync, mkdirSync, rmSync, readdirSync } from "node:fs";
3
- import { dirname, join } from "node:path";
4
-
5
- // How many of the most recent checkpoints to keep. Older ones are dropped and any blobs
6
- // they alone referenced are garbage-collected, so a long-lived session stays bounded.
7
- const DEFAULT_MAX_CHECKPOINTS = 100;
8
-
9
- // The on-disk state of a single file at a moment in time. `existed: false` means the
10
- // file was absent (so restoring deletes it). When it existed, `hash` keys its content
11
- // in the blob store rather than inlining it, so the index stays small and repeated
12
- // content across turns is stored once.
13
- export interface FileState {
14
- existed: boolean;
15
- hash?: string;
16
- }
17
-
18
- export interface Checkpoint {
19
- id: string;
20
- label: string;
21
- ts: number;
22
- messagesLength: number; // engine.messages length at checkpoint time
23
- committedLength: number; // UI transcript length at checkpoint time
24
- files: Record<string, FileState>; // absolute path → state captured at checkpoint time
25
- }
26
-
27
- export type RewindScope = "conversation" | "files" | "both";
28
-
29
- // The serialized index for one session's checkpoints. Blob bodies live separately
30
- // (content-addressed under `blobs/`), so this stays compact regardless of file size.
31
- interface PersistedIndex {
32
- seq: number;
33
- original: Record<string, FileState>;
34
- touched: string[];
35
- checkpoints: Checkpoint[];
36
- }
37
-
38
- // Content-addressed file-content store. Backed by disk when a directory is given (so
39
- // snapshots survive a restart) or an in-memory map otherwise (live-session only, used
40
- // by tests and any non-persisted store). Identical content is stored once under its
41
- // sha256, which keeps growth bounded since most turns touch few files and content
42
- // repeats heavily across checkpoints.
43
- class BlobStore {
44
- private mem = new Map<string, string>();
45
-
46
- constructor(private dir?: string) {}
47
-
48
- put(content: string): string {
49
- const hash = createHash("sha256").update(content).digest("hex");
50
- if (this.dir) {
51
- const p = join(this.dir, hash);
52
- if (!existsSync(p)) {
53
- mkdirSync(this.dir, { recursive: true });
54
- writeFileSync(p, content, "utf8");
55
- }
56
- } else {
57
- this.mem.set(hash, content);
58
- }
59
- return hash;
60
- }
61
-
62
- get(hash: string): string | undefined {
63
- if (this.dir) {
64
- const p = join(this.dir, hash);
65
- return existsSync(p) ? readFileSync(p, "utf8") : undefined;
66
- }
67
- return this.mem.get(hash);
68
- }
69
-
70
- // Drop every stored blob whose hash isn't in `live`.
71
- keep(live: Set<string>): void {
72
- if (this.dir) {
73
- if (!existsSync(this.dir)) return;
74
- for (const name of readdirSync(this.dir)) {
75
- if (!live.has(name)) rmSync(join(this.dir, name), { force: true });
76
- }
77
- } else {
78
- for (const h of this.mem.keys()) if (!live.has(h)) this.mem.delete(h);
79
- }
80
- }
81
- }
82
-
83
- // Undo for the agent's edits, durable when bound to a session directory. A checkpoint
84
- // is taken before each turn, capturing the conversation length and the current content
85
- // of every file the session has modified so far. The first time any file is mutated we
86
- // also record its original (pre-modification) state, so a rewind can restore files
87
- // first touched after a checkpoint back to their baseline — or delete files the session
88
- // created. When constructed with a directory the index and blobs are persisted there,
89
- // so `/rewind` keeps working after the process restarts and the session is resumed.
90
- export class CheckpointStore {
91
- private original = new Map<string, FileState>();
92
- private touched = new Set<string>();
93
- private checkpoints: Checkpoint[] = [];
94
- private seq = 0;
95
- private blobs: BlobStore;
96
-
97
- constructor(
98
- private dir?: string,
99
- private maxCheckpoints = DEFAULT_MAX_CHECKPOINTS,
100
- ) {
101
- this.blobs = new BlobStore(dir ? join(dir, "blobs") : undefined);
102
- }
103
-
104
- // Rehydrate a store from a session's checkpoint directory (or a fresh, empty store
105
- // bound to that directory when nothing has been persisted there yet).
106
- static load(dir: string): CheckpointStore {
107
- const store = new CheckpointStore(dir);
108
- store.loadFrom(dir);
109
- return store;
110
- }
111
-
112
- private loadFrom(dir: string): void {
113
- try {
114
- const p = join(dir, "index.json");
115
- if (!existsSync(p)) return;
116
- const data = JSON.parse(readFileSync(p, "utf8")) as PersistedIndex;
117
- this.seq = data.seq ?? 0;
118
- this.original = new Map(Object.entries(data.original ?? {}));
119
- this.touched = new Set(data.touched ?? []);
120
- this.checkpoints = data.checkpoints ?? [];
121
- } catch {
122
- /* corrupt index: start fresh rather than crash */
123
- }
124
- }
125
-
126
- // Re-point this store at a different session's persisted checkpoints, replacing all
127
- // in-memory state. Used when `/resume` swaps the live conversation for a stored one:
128
- // the engine's existing recordMutation closure keeps pointing at this instance, so we
129
- // mutate it in place rather than constructing a new store.
130
- adopt(dir: string): void {
131
- this.dir = dir;
132
- this.blobs = new BlobStore(join(dir, "blobs"));
133
- this.original = new Map();
134
- this.touched = new Set();
135
- this.checkpoints = [];
136
- this.seq = 0;
137
- this.loadFrom(dir);
138
- }
139
-
140
- private captureFileState(abs: string): FileState {
141
- if (!existsSync(abs)) return { existed: false };
142
- try {
143
- return { existed: true, hash: this.blobs.put(readFileSync(abs, "utf8")) };
144
- } catch {
145
- return { existed: false };
146
- }
147
- }
148
-
149
- private applyFileState(abs: string, state: FileState): void {
150
- if (state.existed && state.hash != null) {
151
- mkdirSync(dirname(abs), { recursive: true });
152
- writeFileSync(abs, this.blobs.get(state.hash) ?? "", "utf8");
153
- } else if (existsSync(abs)) {
154
- rmSync(abs, { force: true });
155
- }
156
- }
157
-
158
- private persist(): void {
159
- if (!this.dir) return;
160
- try {
161
- mkdirSync(this.dir, { recursive: true });
162
- const data: PersistedIndex = {
163
- seq: this.seq,
164
- original: Object.fromEntries(this.original),
165
- touched: [...this.touched],
166
- checkpoints: this.checkpoints,
167
- };
168
- writeFileSync(join(this.dir, "index.json"), JSON.stringify(data), "utf8");
169
- } catch {
170
- /* persistence is best-effort; the in-memory store stays usable */
171
- }
172
- }
173
-
174
- // Called by write/edit immediately before they mutate `abs`.
175
- recordMutation(abs: string): void {
176
- if (!this.original.has(abs)) this.original.set(abs, this.captureFileState(abs));
177
- this.touched.add(abs);
178
- this.persist();
179
- }
180
-
181
- create(opts: { messagesLength: number; committedLength: number; label: string }): Checkpoint {
182
- const files: Record<string, FileState> = {};
183
- for (const p of this.touched) files[p] = this.captureFileState(p);
184
- const cp: Checkpoint = {
185
- id: `cp${++this.seq}`,
186
- label: opts.label.replace(/\s+/g, " ").trim().slice(0, 60) || "(turn)",
187
- ts: Date.now(),
188
- messagesLength: opts.messagesLength,
189
- committedLength: opts.committedLength,
190
- files,
191
- };
192
- this.checkpoints.push(cp);
193
- this.trim();
194
- this.persist();
195
- return cp;
196
- }
197
-
198
- // Enforce the retention cap, dropping the oldest checkpoints and reclaiming any blobs
199
- // they alone referenced. Files first touched before the surviving window stay
200
- // rewindable: the oldest retained checkpoint falls back to the `original` baseline,
201
- // whose blobs are never collected while the file remains in `touched`.
202
- private trim(): void {
203
- if (this.checkpoints.length <= this.maxCheckpoints) return;
204
- this.checkpoints.splice(0, this.checkpoints.length - this.maxCheckpoints);
205
- const live = new Set<string>();
206
- for (const s of this.original.values()) if (s.hash) live.add(s.hash);
207
- for (const cp of this.checkpoints)
208
- for (const s of Object.values(cp.files)) if (s.hash) live.add(s.hash);
209
- this.blobs.keep(live);
210
- }
211
-
212
- list(): Checkpoint[] {
213
- return [...this.checkpoints];
214
- }
215
-
216
- get(id: string): Checkpoint | undefined {
217
- return this.checkpoints.find((c) => c.id === id);
218
- }
219
-
220
- // Restore every session-touched file to its state as of `cp`: the checkpoint's
221
- // snapshot if present, otherwise the file's original (pre-first-touch) state.
222
- restoreFiles(cp: Checkpoint): void {
223
- for (const abs of this.touched) {
224
- this.applyFileState(abs, cp.files[abs] ?? this.original.get(abs) ?? { existed: false });
225
- }
226
- }
227
- }