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.
- package/README.md +135 -443
- package/bin/privateer-tui +86 -0
- package/bin/privateer.mjs +17 -6
- package/bin/pv +28 -0
- package/package.json +26 -23
- package/src/auth/privateer.ts +57 -0
- package/src/boot.ts +43 -0
- package/src/bridge/engineAdapter.ts +182 -0
- package/src/cli/chat.ts +243 -0
- package/src/config/paths.ts +24 -46
- package/src/config/permissionMode.ts +5 -0
- package/src/crypto/outboxSeal.ts +61 -0
- package/src/daemon/index.ts +204 -121
- package/src/daemon/ipc.ts +1 -1
- package/src/engine/errors.ts +85 -45
- package/src/engine/router.ts +11 -165
- package/src/ext/permissionGate.ts +216 -0
- package/src/main.ts +32 -0
- package/src/permissions/classify.ts +172 -0
- package/src/permissions/gate.ts +11 -14
- package/src/permissions/mode.ts +6 -2
- package/src/permissions/{uiGate.ts → modeGate.ts} +18 -3
- package/src/providers/account.ts +170 -0
- package/src/providers/catalog.ts +73 -61
- package/src/providers/genModelsJson.ts +97 -0
- package/src/remote/relayClient.ts +15 -0
- package/src/remote/remoteBridge.ts +152 -0
- package/src/routines/delivery.ts +118 -9
- package/src/routines/schema.ts +24 -5
- package/src/routines/store.ts +44 -1
- package/src/routines/toolSelect.ts +3 -1
- package/src/session.ts +81 -273
- package/src/tools/routine.ts +120 -101
- package/src/tools/saveAttachment.ts +39 -42
- package/src/tools/sendFile.ts +75 -0
- package/src/util/attachmentStore.ts +18 -35
- package/src/util/redact.ts +33 -3
- package/LICENSE +0 -21
- package/src/agents/loader.ts +0 -49
- package/src/commands/custom.ts +0 -123
- package/src/commands/registry.ts +0 -618
- package/src/components/AgentGroupView.tsx +0 -104
- package/src/components/App.tsx +0 -1626
- package/src/components/ApprovalPrompt.tsx +0 -49
- package/src/components/Banner.tsx +0 -78
- package/src/components/Markdown.tsx +0 -183
- package/src/components/ModeHint.tsx +0 -40
- package/src/components/ModelPicker.tsx +0 -302
- package/src/components/Onboarding.tsx +0 -203
- package/src/components/OptionPicker.tsx +0 -134
- package/src/components/PlanConfirm.tsx +0 -37
- package/src/components/PrivateerLogin.tsx +0 -109
- package/src/components/PromptInput.tsx +0 -602
- package/src/components/RewindPicker.tsx +0 -69
- package/src/components/Root.tsx +0 -116
- package/src/components/SessionPicker.tsx +0 -64
- package/src/components/StatusBar.tsx +0 -131
- package/src/components/TodoPanel.tsx +0 -36
- package/src/components/ToolCallView.tsx +0 -113
- package/src/components/Transcript.tsx +0 -210
- package/src/components/figures.ts +0 -14
- package/src/components/promptModel.ts +0 -73
- package/src/components/spinnerVerbs.ts +0 -46
- package/src/components/theme.ts +0 -57
- package/src/components/types.ts +0 -34
- package/src/components/useTeeShield.ts +0 -104
- package/src/components/useTerminalWidth.ts +0 -24
- package/src/components/useZdrShield.ts +0 -126
- package/src/config/load.ts +0 -115
- package/src/config/schema.ts +0 -94
- package/src/context/outputStyles.ts +0 -42
- package/src/context/projectInfo.ts +0 -59
- package/src/context/systemPrompt.ts +0 -176
- package/src/engine/QueryEngine.ts +0 -399
- package/src/hooks/engine.ts +0 -155
- package/src/main.tsx +0 -209
- package/src/mcp/client.ts +0 -251
- package/src/mcp/oauth.ts +0 -245
- package/src/memory/auto.ts +0 -146
- package/src/memory/checkpoints.ts +0 -227
- package/src/memory/store.ts +0 -127
- package/src/providers/attestation.ts +0 -149
- package/src/providers/capabilities.ts +0 -104
- package/src/providers/models.ts +0 -183
- package/src/providers/registry.ts +0 -71
- package/src/providers/resolve.ts +0 -78
- package/src/skills/installer.ts +0 -222
- package/src/skills/loader.ts +0 -88
- package/src/tools/askUser.ts +0 -92
- package/src/tools/bash.ts +0 -98
- package/src/tools/context.ts +0 -128
- package/src/tools/edit.ts +0 -67
- package/src/tools/exec.ts +0 -60
- package/src/tools/glob.ts +0 -39
- package/src/tools/grep.ts +0 -86
- package/src/tools/index.ts +0 -83
- package/src/tools/memory.ts +0 -53
- package/src/tools/processRegistry.ts +0 -77
- package/src/tools/read.ts +0 -42
- package/src/tools/sendFileToClient.ts +0 -55
- package/src/tools/skill.ts +0 -44
- package/src/tools/task.ts +0 -52
- package/src/tools/todo.ts +0 -36
- package/src/tools/todoStore.ts +0 -31
- package/src/tools/walk.ts +0 -44
- package/src/tools/web.ts +0 -145
- package/src/tools/worktree.ts +0 -145
- package/src/tools/write.ts +0 -40
- package/src/util/images.ts +0 -378
- package/src/util/limit.ts +0 -32
- 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
|
-
}
|
package/src/memory/auto.ts
DELETED
|
@@ -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
|
-
}
|