@staix/agent-research 0.0.0-stage → 0.2.1
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/CHANGELOG.md +51 -0
- package/LICENSE +21 -0
- package/README.md +666 -2
- package/bin/ares.js +11 -0
- package/package.json +37 -4
- package/src/autonomy.ts +50 -0
- package/src/cli.ts +308 -0
- package/src/commands-table.ts +75 -0
- package/src/commands.ts +561 -0
- package/src/console/registry.ts +97 -0
- package/src/copilot.ts +91 -0
- package/src/exp/ablate.ts +140 -0
- package/src/exp/artifacts.ts +229 -0
- package/src/exp/attempts.ts +483 -0
- package/src/exp/executor.ts +207 -0
- package/src/exp/final.ts +197 -0
- package/src/exp/ideas.ts +117 -0
- package/src/exp/investigate.ts +187 -0
- package/src/exp/logs.ts +63 -0
- package/src/exp/metrics.ts +132 -0
- package/src/exp/protocol.ts +148 -0
- package/src/exp/selection.ts +53 -0
- package/src/exp/snapshot.ts +147 -0
- package/src/http.ts +106 -0
- package/src/index.ts +4 -0
- package/src/lit/bibtex.ts +61 -0
- package/src/lit/candidates.ts +148 -0
- package/src/lit/cards.ts +21 -0
- package/src/lit/cite.ts +26 -0
- package/src/lit/sources.ts +122 -0
- package/src/lit/verify.ts +110 -0
- package/src/lit/watch.ts +122 -0
- package/src/manifest.ts +39 -0
- package/src/mcp-tools.ts +210 -0
- package/src/mcp.ts +113 -0
- package/src/memory/amem.ts +97 -0
- package/src/memory/archive.ts +66 -0
- package/src/memory/lessons.ts +88 -0
- package/src/memory/skills.ts +99 -0
- package/src/paper/build.ts +73 -0
- package/src/paper/check.ts +181 -0
- package/src/paper/placeholders.ts +118 -0
- package/src/paper/review.ts +127 -0
- package/src/plan/controller.ts +222 -0
- package/src/plan/definition.ts +168 -0
- package/src/plan/hub.ts +56 -0
- package/src/plan/state.ts +270 -0
- package/src/playbook.ts +141 -0
- package/src/scope.ts +53 -0
- package/src/setup.ts +296 -0
- package/src/study-export.ts +254 -0
- package/src/study.ts +324 -0
- package/src/ui/panel.css +11 -0
- package/src/ui/panel.js +161 -0
- package/src/ui/plugin.ts +375 -0
package/src/setup.ts
ADDED
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
import { chmodSync, closeSync, existsSync, lstatSync, mkdirSync, openSync, realpathSync, unlinkSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { isDeepStrictEqual } from "node:util";
|
|
6
|
+
import { assertWritable, plain, readBytesBounded, readTextBounded, sha256, writeAtomic } from "./study.ts";
|
|
7
|
+
|
|
8
|
+
export const SETUP_TARGETS = ["claude", "codex", "kimi", "pi", "qwen", "jcode", "agy"] as const;
|
|
9
|
+
type AgentSetupTarget = typeof SETUP_TARGETS[number];
|
|
10
|
+
export type SetupTarget = AgentSetupTarget | "console";
|
|
11
|
+
export type SetupMode = "dry-run" | "apply" | "undo";
|
|
12
|
+
export interface McpEntry { command: string; args: string[] }
|
|
13
|
+
export interface ConsoleManifest { api: 1; tool: "ares"; module: string; sha256: string }
|
|
14
|
+
type SetupEntry = McpEntry | ConsoleManifest;
|
|
15
|
+
interface Host { target: SetupTarget; file: string; key: string; format: "json" | "toml" | "manifest" }
|
|
16
|
+
export interface SetupAction { target: SetupTarget; file: string; entry: string; receipt: string; lock: string; change: "add" | "replace" | "unchanged" | "restore" | "remove" | "none" | "refused"; receiptChange: "add" | "replace" | "remove" | "none"; reason?: string }
|
|
17
|
+
export interface SetupReport { mode: SetupMode; applied: boolean; root: string; entry: McpEntry; actions: SetupAction[]; notes: string[]; console?: ConsoleManifest }
|
|
18
|
+
interface Receipt { v: 1; target: SetupTarget; file: string; existed: boolean; keyExisted: boolean; priorPresent: boolean; prior?: unknown; priorToml?: string; installed: SetupEntry }
|
|
19
|
+
interface Plan { host: Host; action: SetupAction; before: string | null; after: string | null; receiptBefore: string | null; receiptAfter: string | null }
|
|
20
|
+
export const SETUP_CONFIG_BYTES = 4 * 1024 * 1024, SETUP_RECEIPT_BYTES = 8 * 1024 * 1024;
|
|
21
|
+
const OWN_ROOT = fileURLToPath(new URL("..", import.meta.url));
|
|
22
|
+
class SetupError extends Error {}
|
|
23
|
+
function fail(message: string, nextStep = "correct the config or restore its setup receipt, then rerun ares setup"): never { throw new SetupError(`setup: ${message}; ${nextStep}`); }
|
|
24
|
+
function failureReason(error: unknown): string {
|
|
25
|
+
return error instanceof SetupError ? error.message : `setup: ${error instanceof Error ? error.message : "file operation failed"}; check file permissions and the setup receipt, then rerun ares setup`;
|
|
26
|
+
}
|
|
27
|
+
function setupLockRefusal(file: string): never {
|
|
28
|
+
fail(`target is locked by another setup: ${file}`, `if no ares setup is running, remove ${file} and rerun ares setup`);
|
|
29
|
+
}
|
|
30
|
+
function absolute(value: string, label: string): string {
|
|
31
|
+
if (!isAbsolute(value) || value.includes("\0")) fail(`${label} must be an absolute path`, label === "--root" ? "pass --root ABS for an existing agent-research release" : `set ${label} to an absolute path and rerun ares setup`);
|
|
32
|
+
return resolve(value);
|
|
33
|
+
}
|
|
34
|
+
/** Links are always refused. Ownership checks protect mutable config/receipts; release reads may be administrator-owned. */
|
|
35
|
+
function guard(file: string, owned = true): void {
|
|
36
|
+
const parts: string[] = []; for (let current = resolve(file); ; current = dirname(current)) { parts.unshift(current); if (current === dirname(current)) break; }
|
|
37
|
+
for (const part of parts) {
|
|
38
|
+
let info;
|
|
39
|
+
try { info = lstatSync(part); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") continue; throw error; }
|
|
40
|
+
if (info.isSymbolicLink()) fail("links are refused in config and receipt paths");
|
|
41
|
+
if (part === resolve(file)) {
|
|
42
|
+
if (!info.isFile()) fail("config or receipt is not a regular file");
|
|
43
|
+
if (owned && info.uid !== process.getuid!()) fail("config or receipt is owned by another user");
|
|
44
|
+
} else {
|
|
45
|
+
if (!info.isDirectory()) fail("config or receipt parent is not a directory");
|
|
46
|
+
if (owned && info.uid !== 0 && info.uid !== process.getuid!()) fail("config or receipt parent is owned by another user");
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
function read(file: string, max: number, owned = true): string | null {
|
|
51
|
+
guard(file, owned);
|
|
52
|
+
try { return readTextBounded(file, max, { noFollow: true, ...(owned ? { uid: process.getuid!() } : {}) }); }
|
|
53
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; throw error; }
|
|
54
|
+
}
|
|
55
|
+
function write(file: string, value: string | null, max: number, privateFile = false): void {
|
|
56
|
+
guard(file);
|
|
57
|
+
if (value === null) { unlinkSync(file); return; }
|
|
58
|
+
assertWritable(value, max, "setup output");
|
|
59
|
+
let mode = 0o600;
|
|
60
|
+
try { if (!privateFile) mode = lstatSync(file).mode & 0o777; } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
|
|
61
|
+
mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
|
|
62
|
+
if (privateFile) privateDirectory(dirname(file), true);
|
|
63
|
+
guard(file); writeAtomic(file, value, max, mode);
|
|
64
|
+
}
|
|
65
|
+
function record(value: unknown): Record<string, unknown> { if (!plain(value)) fail("config must contain an object"); return value; }
|
|
66
|
+
function document(host: Host, source: string | null): Record<string, unknown> {
|
|
67
|
+
if (source === null) return {};
|
|
68
|
+
try { return record(host.format === "toml" ? Bun.TOML.parse(source) : JSON.parse(source)); }
|
|
69
|
+
catch { fail(`invalid ${host.format.toUpperCase()}; fix ${host.file} before applying setup`); }
|
|
70
|
+
}
|
|
71
|
+
function entry(host: Host, source: string | null): unknown { if (host.format === "manifest") return source === null ? undefined : document(host, source); const doc = document(host, source); return doc[host.key] === undefined ? undefined : record(doc[host.key]).ares; }
|
|
72
|
+
function hosts(env: NodeJS.ProcessEnv, cwd: string): Record<AgentSetupTarget, Host> {
|
|
73
|
+
const home = absolute(env.HOME?.trim() || homedir(), "HOME");
|
|
74
|
+
const dir = (key: string, fallback: string) => absolute(env[key]?.trim() || join(home, fallback), key);
|
|
75
|
+
const json = (target: AgentSetupTarget, file: string, key = "mcpServers"): Host => ({ target, file, format: "json", key });
|
|
76
|
+
return {
|
|
77
|
+
claude: json("claude", env.CLAUDE_CONFIG_DIR?.trim() ? join(dir("CLAUDE_CONFIG_DIR", ".claude"), ".claude.json") : join(home, ".claude.json")),
|
|
78
|
+
codex: { target: "codex", file: join(dir("CODEX_HOME", ".codex"), "config.toml"), format: "toml", key: "mcp_servers" },
|
|
79
|
+
kimi: json("kimi", join(dir("KIMI_CODE_HOME", ".kimi-code"), "mcp.json")),
|
|
80
|
+
pi: json("pi", join(dir("PI_CODING_AGENT_DIR", ".pi/agent"), "mcp.json")),
|
|
81
|
+
qwen: json("qwen", join(dir("QWEN_HOME", ".qwen"), "settings.json")),
|
|
82
|
+
jcode: json("jcode", join(dir("JCODE_HOME", ".jcode"), "mcp.json"), "servers"),
|
|
83
|
+
agy: json("agy", join(absolute(cwd, "cwd"), ".agents", "mcp_config.json")),
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
/** Header/key handling follows vmem setup; Bun decodes quoted keys instead of a second TOML decoder. */
|
|
87
|
+
function tomlKey(raw: string): string[] | undefined {
|
|
88
|
+
const pattern = /\s*([A-Za-z0-9_-]+|"(?:[^"\\]|\\.)*"|'[^']*')\s*(?:\.|$)/y, parts: string[] = [];
|
|
89
|
+
while (pattern.lastIndex < raw.length) {
|
|
90
|
+
const match = pattern.exec(raw); if (!match) return undefined;
|
|
91
|
+
const token = match[1]!;
|
|
92
|
+
try { parts.push(token.startsWith('"') || token.startsWith("'") ? record(Bun.TOML.parse(`key = ${token}`)).key as string : token); } catch { return undefined; }
|
|
93
|
+
}
|
|
94
|
+
return parts;
|
|
95
|
+
}
|
|
96
|
+
function header(line: string): string | undefined {
|
|
97
|
+
line = line.replace(/\r?\n$/, "");
|
|
98
|
+
const match = /^\s*(\[\[?)/.exec(line); if (!match) return undefined;
|
|
99
|
+
let quote = "", index = match[0].length;
|
|
100
|
+
for (; index < line.length; index++) {
|
|
101
|
+
const char = line[index]!;
|
|
102
|
+
if (quote) { if (char === "\\" && quote === '"') index++; else if (char === quote) quote = ""; }
|
|
103
|
+
else if (char === '"' || char === "'") quote = char;
|
|
104
|
+
else if (char === "]") break;
|
|
105
|
+
}
|
|
106
|
+
const close = match[1] === "[[" ? "]]" : "]";
|
|
107
|
+
return !quote && line.startsWith(close, index) && /^\s*(?:#.*)?$/.test(line.slice(index + close.length)) ? line.slice(match[0].length, index).trim() : undefined;
|
|
108
|
+
}
|
|
109
|
+
/** Multiline strings may contain apparent table headers; never treat those lines as config entries. */
|
|
110
|
+
function spans(lines: string[]): [number, number][] {
|
|
111
|
+
const result: [number, number][] = [], comments: boolean[] = []; let start = -1, quote = "";
|
|
112
|
+
for (let i = 0; i <= lines.length; i++) {
|
|
113
|
+
const line = lines[i], name = line !== undefined && !quote ? header(line) : undefined;
|
|
114
|
+
comments[i] = !quote && line?.trim().startsWith("#") === true;
|
|
115
|
+
if (i === lines.length || name !== undefined) {
|
|
116
|
+
if (start >= 0) { let stop = i; while (stop > start && (!lines[stop - 1]!.trim() || comments[stop - 1])) stop--; result.push([start, stop]); start = -1; }
|
|
117
|
+
const key = name === undefined ? undefined : tomlKey(name); if (key?.[0] === "mcp_servers" && key[1] === "ares") start = i;
|
|
118
|
+
}
|
|
119
|
+
for (let pos = 0; line !== undefined && pos < line.length; pos++) {
|
|
120
|
+
const char = line[pos]!;
|
|
121
|
+
if (!quote && char === "#") break;
|
|
122
|
+
if (quote && char === "\\" && quote[0] === '"') { pos++; continue; }
|
|
123
|
+
if (quote.length === 3 && line.startsWith(quote, pos)) { const end = quote[0]!; while (line[pos + 1] === end) pos++; quote = ""; }
|
|
124
|
+
else if (quote.length === 1 && char === quote) quote = "";
|
|
125
|
+
else if (!quote && (char === '"' || char === "'")) { quote = line.startsWith(char.repeat(3), pos) ? char.repeat(3) : char; pos += quote.length - 1; }
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return result;
|
|
129
|
+
}
|
|
130
|
+
const linesOf = (source: string): string[] => source.match(/[^\n]*\n|[^\n]+$/g) ?? [];
|
|
131
|
+
function priorToml(source: string | null): string | undefined {
|
|
132
|
+
const lines = linesOf(source ?? ""), owned = spans(lines);
|
|
133
|
+
return owned.length ? owned.map(([start, stop]) => lines.slice(start, stop).join("")).join("") : undefined;
|
|
134
|
+
}
|
|
135
|
+
function put(host: Host, source: string | null, value: unknown, rawToml?: string, keepKey = false): string {
|
|
136
|
+
if (host.format === "manifest") return JSON.stringify(value ?? {}, null, 2) + "\n";
|
|
137
|
+
if (host.format === "json") {
|
|
138
|
+
const doc = document(host, source), servers = { ...record(doc[host.key] ?? {}) };
|
|
139
|
+
if (value === undefined) delete servers.ares; else servers.ares = value;
|
|
140
|
+
if (Object.keys(servers).length || keepKey) doc[host.key] = servers; else delete doc[host.key];
|
|
141
|
+
return JSON.stringify(doc, null, source && /\n(\t| +)"/.exec(source)?.[1] || " ") + "\n";
|
|
142
|
+
}
|
|
143
|
+
const newline = source?.includes("\r\n") ? "\r\n" : "\n", lines = linesOf(source ?? ""), owned = spans(lines);
|
|
144
|
+
let block = rawToml ?? "";
|
|
145
|
+
if (value !== undefined && rawToml === undefined) {
|
|
146
|
+
const target = record(value);
|
|
147
|
+
block = `[mcp_servers.ares]${newline}command = ${JSON.stringify(target.command)}${newline}args = ${JSON.stringify(target.args)}${newline}`;
|
|
148
|
+
}
|
|
149
|
+
if (owned.length) {
|
|
150
|
+
for (const [start, stop] of [...owned].reverse()) lines.splice(start, stop - start);
|
|
151
|
+
lines.splice(owned[0]![0], 0, block);
|
|
152
|
+
} else if (block) {
|
|
153
|
+
const prefix = (source ?? "").endsWith("\n") || !source ? "" : newline;
|
|
154
|
+
lines.push(prefix + (source ? newline : "") + block);
|
|
155
|
+
}
|
|
156
|
+
const output = lines.join("");
|
|
157
|
+
try { const parsed = document(host, output); if (!isDeepStrictEqual(parsed.mcp_servers === undefined ? undefined : record(parsed.mcp_servers).ares, rawToml === undefined ? value : entry(host, rawToml))) throw new Error(); }
|
|
158
|
+
catch { fail("TOML entry cannot be edited safely; convert inline/dotted ares settings to [mcp_servers.ares] tables first"); }
|
|
159
|
+
return output;
|
|
160
|
+
}
|
|
161
|
+
function install(root: string): string {
|
|
162
|
+
const requested = absolute(root, "--root");
|
|
163
|
+
let location: string;
|
|
164
|
+
try { location = realpathSync(requested); } catch { fail("install root is unreadable or missing", "pass --root ABS for an existing agent-research release"); }
|
|
165
|
+
const source = read(join(location, "package.json"), 65_536, false);
|
|
166
|
+
let pkg: unknown; try { pkg = source === null ? null : JSON.parse(source); } catch { fail("install root package.json is not valid JSON", "select or reinstall an intact agent-research release root"); }
|
|
167
|
+
if (!plain(pkg) || pkg.name !== "@staix/agent-research" || typeof pkg.version !== "string") fail("--root is not an agent-research installation", "select a release root containing package.json and bin/ares.js");
|
|
168
|
+
const bootstrap = join(location, "bin", "ares.js"); guard(bootstrap, false);
|
|
169
|
+
try { if (!lstatSync(bootstrap).isFile()) throw new Error(); } catch { fail("install root has no regular bin/ares.js", "select or reinstall an intact agent-research release root"); }
|
|
170
|
+
return location;
|
|
171
|
+
}
|
|
172
|
+
function validInstalled(host: Host, value: unknown): boolean {
|
|
173
|
+
if (!plain(value)) return false;
|
|
174
|
+
return host.format === "manifest"
|
|
175
|
+
? value.api === 1 && value.tool === "ares" && typeof value.module === "string" && isAbsolute(value.module) && typeof value.sha256 === "string" && /^[a-f0-9]{64}$/.test(value.sha256)
|
|
176
|
+
: typeof value.command === "string" && Array.isArray(value.args) && value.args.every(arg => typeof arg === "string");
|
|
177
|
+
}
|
|
178
|
+
function receipt(source: string | null, host: Host): Receipt | undefined {
|
|
179
|
+
if (source === null) return undefined;
|
|
180
|
+
let raw: unknown; try { raw = JSON.parse(source); } catch { fail("receipt must be valid JSON"); }
|
|
181
|
+
if (!plain(raw) || raw.v !== 1 || raw.target !== host.target || raw.file !== host.file || typeof raw.existed !== "boolean" || typeof raw.keyExisted !== "boolean" || typeof raw.priorPresent !== "boolean"
|
|
182
|
+
|| !validInstalled(host, raw.installed)
|
|
183
|
+
|| (raw.priorPresent && !Object.hasOwn(raw, "prior")) || (raw.priorToml !== undefined && typeof raw.priorToml !== "string")) fail("unsupported, malformed or foreign setup receipt");
|
|
184
|
+
return raw as unknown as Receipt;
|
|
185
|
+
}
|
|
186
|
+
function plan(host: Host, receiptPath: string, mode: SetupMode, desired: SetupEntry, ownsLock = false): Plan {
|
|
187
|
+
const action: SetupAction = { target: host.target, file: host.file, entry: host.format === "manifest" ? "console plugin manifest" : `${host.key}.ares`, receipt: receiptPath, lock: `${host.file}.ares-setup.lock`, change: "none", receiptChange: "none" };
|
|
188
|
+
if (host.format === "manifest") privateDirectory(dirname(host.file));
|
|
189
|
+
guard(action.lock);
|
|
190
|
+
if (!ownsLock && existsSync(action.lock)) setupLockRefusal(action.lock);
|
|
191
|
+
const before = read(host.file, SETUP_CONFIG_BYTES), receiptBefore = read(receiptPath, SETUP_RECEIPT_BYTES), prior = receipt(receiptBefore, host), current = entry(host, before);
|
|
192
|
+
const result: Plan = { host, action, before, after: before, receiptBefore, receiptAfter: receiptBefore };
|
|
193
|
+
if (mode === "undo") {
|
|
194
|
+
if (!prior) return result;
|
|
195
|
+
if (!isDeepStrictEqual(current, prior.installed)) fail("ares entry changed since apply; keep the user edit and reconcile the receipt manually before undo");
|
|
196
|
+
result.after = put(host, before, prior.priorPresent ? prior.prior : undefined, prior.priorToml, prior.keyExisted);
|
|
197
|
+
if (!prior.existed && isDeepStrictEqual(document(host, result.after), {})) result.after = null;
|
|
198
|
+
result.receiptAfter = null; action.change = prior.priorPresent ? "restore" : "remove"; action.receiptChange = "remove"; return result;
|
|
199
|
+
}
|
|
200
|
+
if (isDeepStrictEqual(current, desired) && (host.format !== "manifest" || !privateModesNeeded(host.file))) { action.change = "unchanged"; return result; }
|
|
201
|
+
const original = prior?.priorToml === undefined ? prior?.priorPresent ? prior.prior : undefined : entry(host, prior.priorToml);
|
|
202
|
+
if (prior && !isDeepStrictEqual(current, prior.installed) && !isDeepStrictEqual(current, original)) fail("ares entry changed since apply; reconcile it before replacing the installed root");
|
|
203
|
+
result.after = put(host, before, desired);
|
|
204
|
+
const saved: Receipt = prior ?? { v: 1, target: host.target, file: host.file, existed: before !== null, keyExisted: Object.hasOwn(document(host, before), host.key), priorPresent: current !== undefined,
|
|
205
|
+
...(current === undefined ? {} : { prior: current }), ...(host.format === "toml" && current !== undefined ? { priorToml: priorToml(before) } : {}), installed: desired };
|
|
206
|
+
result.receiptAfter = JSON.stringify({ ...saved, installed: desired }, null, 2) + "\n";
|
|
207
|
+
assertWritable(result.receiptAfter, SETUP_RECEIPT_BYTES, "setup receipt");
|
|
208
|
+
assertWritable(result.after, SETUP_CONFIG_BYTES, "client config");
|
|
209
|
+
action.change = current === undefined ? "add" : "replace"; action.receiptChange = receiptBefore === null ? "add" : "replace"; return result;
|
|
210
|
+
}
|
|
211
|
+
/** Per-config, not per-family, so two family homes cannot race on the same client file. */
|
|
212
|
+
function acquireSetupLock(file: string): () => void {
|
|
213
|
+
guard(file); mkdirSync(dirname(file), { recursive: true, mode: 0o700 }); guard(file);
|
|
214
|
+
let fd: number;
|
|
215
|
+
try { fd = openSync(file, "wx", 0o600); }
|
|
216
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code === "EEXIST") setupLockRefusal(file); throw error; }
|
|
217
|
+
return () => { try { closeSync(fd); } finally { unlinkSync(file); } };
|
|
218
|
+
}
|
|
219
|
+
/** Agent setup only. Console dispatch is deliberately separate for the plugin integration. */
|
|
220
|
+
export function runSetup(target: string, mode: SetupMode, options: { env: NodeJS.ProcessEnv; cwd?: string; root?: string }): SetupReport {
|
|
221
|
+
if (target === "console") return consoleManifest(mode, options.env, options.root ?? OWN_ROOT, options.root !== undefined);
|
|
222
|
+
if (target !== "all" && !(SETUP_TARGETS as readonly string[]).includes(target)) fail("unknown target", `use ${SETUP_TARGETS.join("|")}|all|console`);
|
|
223
|
+
if (!["dry-run", "apply", "undo"].includes(mode)) fail("unsupported setup mode", "choose dry-run, apply or undo and rerun ares setup");
|
|
224
|
+
const env = options.env, root = install(options.root ?? OWN_ROOT), desired: McpEntry = { command: "bun", args: ["--smol", join(root, "bin", "ares.js"), "mcp"] };
|
|
225
|
+
const table = hosts(env, options.cwd ?? process.cwd()), family = absolute(env.AGENTHUB_HOME?.trim() || join(absolute(env.HOME?.trim() || homedir(), "HOME"), ".agenthub"), "AGENTHUB_HOME");
|
|
226
|
+
const targets = target === "all" ? SETUP_TARGETS.filter(name => name !== "agy") : [target as AgentSetupTarget], plans: Plan[] = [];
|
|
227
|
+
const report: SetupReport = { mode, applied: false, root, entry: desired, actions: [], notes: target === "all" ? ["all covers user-level clients; run ares setup agy from its workspace separately"] : [] };
|
|
228
|
+
for (const name of targets) {
|
|
229
|
+
const host = table[name], receiptPath = join(family, "research", "setup", `${name}-${sha256(host.file).slice(0, 16)}.json`);
|
|
230
|
+
try { const item = plan(host, receiptPath, mode, desired); plans.push(item); report.actions.push(item.action); }
|
|
231
|
+
catch (error) { report.actions.push({ target: name, file: host.file, entry: host.format === "manifest" ? "console plugin manifest" : `${host.key}.ares`, receipt: receiptPath, lock: `${host.file}.ares-setup.lock`, change: "refused", receiptChange: "none", reason: failureReason(error) }); }
|
|
232
|
+
}
|
|
233
|
+
return executePlans(plans, mode, desired, report);
|
|
234
|
+
}
|
|
235
|
+
function executePlans(plans: Plan[], mode: SetupMode, desired: SetupEntry, report: SetupReport): SetupReport {
|
|
236
|
+
if (mode === "dry-run" || report.actions.some(action => action.change === "refused")) return report;
|
|
237
|
+
for (let item of plans) {
|
|
238
|
+
if (["none", "unchanged"].includes(item.action.change)) continue;
|
|
239
|
+
let release: (() => void) | undefined;
|
|
240
|
+
try {
|
|
241
|
+
release = acquireSetupLock(item.action.lock);
|
|
242
|
+
const fresh = plan(item.host, item.action.receipt, mode, desired, true);
|
|
243
|
+
Object.assign(item.action, fresh.action); item = { ...fresh, action: item.action };
|
|
244
|
+
if (["none", "unchanged"].includes(item.action.change)) continue;
|
|
245
|
+
if (read(item.host.file, SETUP_CONFIG_BYTES) !== item.before || read(item.action.receipt, SETUP_RECEIPT_BYTES) !== item.receiptBefore) fail("config or receipt changed during setup; rerun preview before applying");
|
|
246
|
+
// Receipt is durable before apply changes client config. Undo keeps the receipt until restoration succeeds.
|
|
247
|
+
if (mode === "apply") write(item.action.receipt, item.receiptAfter, SETUP_RECEIPT_BYTES);
|
|
248
|
+
try {
|
|
249
|
+
if (read(item.host.file, SETUP_CONFIG_BYTES) !== item.before) fail("client config changed after receipt creation; rerun preview before applying");
|
|
250
|
+
write(item.host.file, item.after, SETUP_CONFIG_BYTES, item.host.format === "manifest");
|
|
251
|
+
}
|
|
252
|
+
catch (error) { if (mode === "apply" && read(item.action.receipt, SETUP_RECEIPT_BYTES) === item.receiptAfter) write(item.action.receipt, item.receiptBefore, SETUP_RECEIPT_BYTES); throw error; }
|
|
253
|
+
if (mode === "undo") write(item.action.receipt, null, SETUP_RECEIPT_BYTES);
|
|
254
|
+
report.applied = true;
|
|
255
|
+
} catch (error) { item.action.change = "refused"; item.action.reason = failureReason(error); break; }
|
|
256
|
+
finally { release?.(); }
|
|
257
|
+
}
|
|
258
|
+
return report;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** The manifest directory must satisfy agent-console's private user-owned registration boundary. */
|
|
262
|
+
function privateDirectory(dir: string, apply = false): void {
|
|
263
|
+
let info;
|
|
264
|
+
try { info = lstatSync(dir); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return; throw error; }
|
|
265
|
+
if (!info.isDirectory() || info.isSymbolicLink()) fail("console plugin directory must be a real directory");
|
|
266
|
+
if (info.uid !== process.getuid!()) fail("console plugin directory is owned by another user");
|
|
267
|
+
if (apply) chmodSync(dir, 0o700);
|
|
268
|
+
}
|
|
269
|
+
function privateModesNeeded(file: string): boolean {
|
|
270
|
+
try { if ((lstatSync(dirname(file)).mode & 0o777) !== 0o700) return true; } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
|
|
271
|
+
try { return (lstatSync(file).mode & 0o777) !== 0o600; } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; return false; }
|
|
272
|
+
}
|
|
273
|
+
/** Console registration is separate from agent-client `all`; no instruction blocks or other manifests are touched. */
|
|
274
|
+
export function consoleManifest(mode: SetupMode, env: NodeJS.ProcessEnv, requestedRoot = OWN_ROOT, versioned = false): SetupReport {
|
|
275
|
+
if (!["dry-run", "apply", "undo"].includes(mode)) fail("unsupported setup mode", "choose dry-run, apply or undo and rerun ares setup");
|
|
276
|
+
const root = install(requestedRoot), module = join(root, "src", "ui", "plugin.ts");
|
|
277
|
+
const mcp: McpEntry = { command: "bun", args: ["--smol", join(root, "bin", "ares.js"), "mcp"] };
|
|
278
|
+
let desired: SetupEntry = mcp;
|
|
279
|
+
if (mode !== "undo") {
|
|
280
|
+
guard(module, false);
|
|
281
|
+
let bytes: Buffer;
|
|
282
|
+
try { bytes = readBytesBounded(module, SETUP_CONFIG_BYTES, { noFollow: true }); }
|
|
283
|
+
catch { fail("console plugin module is missing, unreadable or over 4 MiB", "select an intact release containing src/ui/plugin.ts and rerun ares setup console"); }
|
|
284
|
+
desired = { api: 1, tool: "ares", module, sha256: sha256(bytes) };
|
|
285
|
+
}
|
|
286
|
+
const pkg = record(JSON.parse(read(join(root, "package.json"), 65_536, false)!)), version = pkg.version;
|
|
287
|
+
if (versioned && (typeof version !== "string" || version.length > 64 || !/^\d{1,9}\.\d{1,9}\.\d{1,9}(?:[-+][0-9A-Za-z.+-]{1,40})?$/.test(version))) fail("release version cannot name a console registration", "use an agent-research root with a semantic package version: three 1..9-digit parts, optional -/+ suffix of 1..40 characters from 0-9A-Za-z.+-, and at most 64 characters total");
|
|
288
|
+
const family = absolute(env.AGENTHUB_HOME?.trim() || join(absolute(env.HOME?.trim() || homedir(), "HOME"), ".agenthub"), "AGENTHUB_HOME");
|
|
289
|
+
const file = join(family, "console", "plugins", versioned ? `ares@${version}.json` : "ares.json");
|
|
290
|
+
const host: Host = { target: "console", file, key: "", format: "manifest" }, receiptPath = join(family, "research", "setup", `console-${sha256(file).slice(0, 16)}.json`);
|
|
291
|
+
const report: SetupReport = { mode, applied: false, root, entry: mcp, ...(mode === "undo" ? {} : { console: desired as ConsoleManifest }), actions: [], notes: ["Registers only the ares console plugin; restart the console to load changed code."] };
|
|
292
|
+
let item: Plan;
|
|
293
|
+
try { item = plan(host, receiptPath, mode, desired); }
|
|
294
|
+
catch (error) { report.actions.push({ target: "console", file, entry: "console plugin manifest", receipt: receiptPath, lock: `${file}.ares-setup.lock`, change: "refused", receiptChange: "none", reason: failureReason(error) }); return report; }
|
|
295
|
+
report.actions.push(item.action); return executePlans([item], mode, desired, report);
|
|
296
|
+
}
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import { existsSync, lstatSync, mkdirSync, readdirSync, realpathSync } from "node:fs";
|
|
2
|
+
import { basename, dirname, join, relative, resolve, isAbsolute, sep } from "node:path";
|
|
3
|
+
import { readAttemptRecords, type Attempt, type AttemptStatus } from "./exp/attempts.ts";
|
|
4
|
+
import { ablateResultPath, readClosedAblate } from "./exp/ablate.ts";
|
|
5
|
+
import { finalResultPath, readClosedFinal } from "./exp/final.ts";
|
|
6
|
+
import { readClosedInvestigation } from "./exp/investigate.ts";
|
|
7
|
+
import { readSelection } from "./exp/selection.ts";
|
|
8
|
+
import { designBinding, protocolHash, readProtocol } from "./exp/protocol.ts";
|
|
9
|
+
import { FORMAT_VERSIONS } from "./manifest.ts";
|
|
10
|
+
import { PAPER_EVIDENCE_BYTES, paperBuildPaths } from "./paper/build.ts";
|
|
11
|
+
import { assertWritable, openStudy, plain, readLedger, readTextBounded, sha256, withLock, writeAtomic, type LedgerEvent } from "./study.ts";
|
|
12
|
+
|
|
13
|
+
/** Reader bound for every bundle file; writeAtomic refuses anything larger. */
|
|
14
|
+
export const BUNDLE_FILE_BYTES = 64 * 1024 * 1024;
|
|
15
|
+
export const BUNDLE_FILES = ["manifest.json", "ledger.jsonl", "attempts.jsonl", "results.json", "reviews.jsonl", "evidence.json"] as const;
|
|
16
|
+
const STATUSES: AttemptStatus[] = ["running", "succeeded", "failed", "cancelled", "unknown"];
|
|
17
|
+
const SHA256 = /^[a-f0-9]{64}$/;
|
|
18
|
+
const HEX = /^[a-f0-9]{16,64}$/;
|
|
19
|
+
const TOKEN = /^[a-z][a-z0-9_.:-]{0,47}$/;
|
|
20
|
+
const GIT_ID = /^[a-f0-9]{40}$/, ATTEMPT_ID = /^[a-f0-9]{16}$/;
|
|
21
|
+
const ACTOR_KINDS = new Set(["person", "agent", "auto"]), EVIDENCE_FAMILIES = new Set(["attempt", "posthoc", "investigate", "ablate", "final"]);
|
|
22
|
+
/** Exact-shape field or null: hand-edited free text in an identity field is never copied. */
|
|
23
|
+
const shaped = (value: unknown, pattern: RegExp): string | null => typeof value === "string" && pattern.test(value) ? value : null;
|
|
24
|
+
const finite = (value: unknown): number | null => typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
25
|
+
const METRIC_KEYS = new Set(["name", "unit", "metric"]);
|
|
26
|
+
/** Closed data keys per ledger event type; unlisted types keep the envelope with empty data. Never host, containerId or hubProject. */
|
|
27
|
+
const LEDGER_KEYS: Record<string, readonly string[]> = {
|
|
28
|
+
"scope.set": ["sha256"], "autonomy.set": ["from", "to", "phase"], "gate.approve": ["gate", "binding"], "gate.reject": ["gate", "binding"],
|
|
29
|
+
"lit.search": ["sources", "added", "merged", "total"], "lit.screen": ["decision"], "lit.card": ["id", "sha256"], "lit.bib": ["entries", "sha256"], "lit.verify": ["verified", "failed"],
|
|
30
|
+
"archive.exported": ["fingerprint"], "skill.proposed": ["skillProposal", "sha256"], "skill.decided": ["skillProposal", "decision"], "watch.set": ["sha256"],
|
|
31
|
+
"plan.set": ["revision", "state", "sha256"], "plan.gates": ["revision"], "plan.paused": ["revision"], "plan.control": ["revision"], "node.observed": ["revision", "status"], "node.intent": ["revision"],
|
|
32
|
+
"executor.set": ["kind"], "protocol.set": ["sha256", "protocolHash", "treeHash"], "selection.set": ["sha256", "metric"], "final.seeds.set": ["sha256"],
|
|
33
|
+
"investigate.closed": ["run", "sha256", "ruleSha256", "winner"], "ablate.closed": ["sha256", "investigateSha256"], "final.closed": ["sha256", "status", "investigateSha256"],
|
|
34
|
+
"idea.decided": ["decision"], "paper.built": ["manuscriptSha256", "evidenceSha256", "sourceManuscriptSha256"], "review.added": [], "review.resolved": ["buildSha256"],
|
|
35
|
+
"attempt.started": ["id", "run", "seed", "commit", "treeId"],
|
|
36
|
+
};
|
|
37
|
+
for (const t of ["attempt.run", "attempt.snapshot", "attempt.stage.changed", "attempt.stage.evidence", "attempt.stage.recovered", "attempt.stage.started", "attempt.succeeded", "attempt.failed", "attempt.cancelled", "attempt.unknown"]) LEDGER_KEYS[t] = ["id", "run", "stage", "status"];
|
|
38
|
+
|
|
39
|
+
export interface BundleManifest {
|
|
40
|
+
bundle: 1; formats: typeof FORMAT_VERSIONS; studyIdSha256: string; mode: string; protocolHash: string | null; sealBinding: string | null;
|
|
41
|
+
window: { from: string | null; to: string | null }; counts: { attempts: number; byStatus: Record<AttemptStatus, number>; byRun: Record<string, Record<AttemptStatus, number>>; ledgerEvents: number; reviews: number };
|
|
42
|
+
files: Record<string, string>;
|
|
43
|
+
}
|
|
44
|
+
export interface BundleListing { bundle: string; files: string[]; wouldWrite: true }
|
|
45
|
+
|
|
46
|
+
/** A closed scalar: numbers, booleans, hashes and enum-like tokens survive; free text, paths and URLs do not. */
|
|
47
|
+
function closed(value: unknown): value is number | boolean | string | null {
|
|
48
|
+
if (value === null || typeof value === "boolean") return true;
|
|
49
|
+
if (typeof value === "number") return Number.isFinite(value);
|
|
50
|
+
return typeof value === "string" && (HEX.test(value) || TOKEN.test(value));
|
|
51
|
+
}
|
|
52
|
+
function reduceData(data: Record<string, unknown>, keys: readonly string[]): Record<string, unknown> {
|
|
53
|
+
const out: Record<string, unknown> = {};
|
|
54
|
+
for (const key of keys) { const value = Object.hasOwn(data, key) ? data[key] : undefined; if (closed(value) || (METRIC_KEYS.has(key) && typeof value === "string")) out[key] = value; else if (Array.isArray(value) && value.every(closed)) out[key] = value; }
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
/** Result keys that hold free text even when a value happens to be one token. */
|
|
58
|
+
const FREE_TEXT_KEYS = new Set(["removes", "description", "note", "notes", "label", "text", "message", "cause", "retryWhen", "method", "question"]);
|
|
59
|
+
/** Keep numbers, booleans, null, hashes and closed tokens recursively; drop free-text keys and every other string. */
|
|
60
|
+
function project(value: unknown): unknown {
|
|
61
|
+
if (Array.isArray(value)) return value.map(project).filter(v => v !== undefined);
|
|
62
|
+
if (plain(value)) { const out: Record<string, unknown> = {}; for (const [k, v] of Object.entries(value)) { if (FREE_TEXT_KEYS.has(k)) continue; const p = METRIC_KEYS.has(k) && typeof v === "string" ? v : project(v); if (p !== undefined) out[k] = p; } return out; }
|
|
63
|
+
return closed(value) ? value : undefined;
|
|
64
|
+
}
|
|
65
|
+
/** A parseable ISO timestamp or null. */
|
|
66
|
+
const isoAt = (value: unknown): string | null => typeof value === "string" && value.length <= 64 && /^\d{4}-\d{2}-\d{2}T[\d:.]+(?:Z|[+-]\d{2}:\d{2})$/.test(value) && Number.isFinite(Date.parse(value)) ? value : null;
|
|
67
|
+
function reduceEvent(event: LedgerEvent) {
|
|
68
|
+
const actor = { kind: ACTOR_KINDS.has(event.actor?.kind as string) ? event.actor.kind : "other", ...(event.actor.id !== undefined ? { idSha256: sha256(event.actor.id) } : {}) };
|
|
69
|
+
const at = isoAt(event.at);
|
|
70
|
+
return { v: event.v, at, actor, type: TOKEN.test(event.type) ? event.type : "other", data: reduceData(plain(event.data) ? event.data : {}, (Object.hasOwn(LEDGER_KEYS, event.type) ? LEDGER_KEYS[event.type]! : [])) };
|
|
71
|
+
}
|
|
72
|
+
// Closed codes emitted by attempts.ts, metrics.ts and artifacts.ts; never derive a class from user prose.
|
|
73
|
+
const ATTEMPT_REASON_CODES = new Set([
|
|
74
|
+
"executor-start", "executor-start-uncertain", "preparation", "lost", "wall-clock", "candidate-exit", "evaluator-exit",
|
|
75
|
+
"evidence-read", "invalid-metrics", "malformed-metrics", "undeclared-metric", "missing-metric", "invalid-metric-value",
|
|
76
|
+
"nonfinite-metric", "metrics-limit", "duplicate-metric", "missing-metrics", "missing-receipt", "changed-output",
|
|
77
|
+
"artifact-limit", "artifact-read", "invalid-artifact", "invalid-log-state", "missing-logs",
|
|
78
|
+
]);
|
|
79
|
+
function reasonClass(reason: string | undefined, status: AttemptStatus): string | undefined {
|
|
80
|
+
if (reason === undefined) return undefined;
|
|
81
|
+
if (status === "cancelled") return "cancel-reason";
|
|
82
|
+
return ATTEMPT_REASON_CODES.has(reason) ? reason : "other";
|
|
83
|
+
}
|
|
84
|
+
function duration(start: unknown, end: unknown): number | null {
|
|
85
|
+
if (typeof start !== "string" || typeof end !== "string") return null;
|
|
86
|
+
const ms = Date.parse(end) - Date.parse(start);
|
|
87
|
+
return Number.isFinite(ms) ? ms : null;
|
|
88
|
+
}
|
|
89
|
+
function digest(image: unknown): string | null {
|
|
90
|
+
const match = typeof image === "string" ? /@(sha256:[a-f0-9]{64})$/.exec(image) : null;
|
|
91
|
+
return match?.[1] ?? null;
|
|
92
|
+
}
|
|
93
|
+
function attemptRow(a: Attempt) {
|
|
94
|
+
const stages: Record<string, unknown> = {};
|
|
95
|
+
for (const name of ["candidate", "evaluator"] as const) {
|
|
96
|
+
const stage = plain(a.stages) && Object.hasOwn(a.stages, name) ? a.stages[name] : undefined;
|
|
97
|
+
if (!plain(stage)) continue;
|
|
98
|
+
stages[name] = { exitCode: Number.isSafeInteger(stage.exitCode) ? stage.exitCode : null, durationMs: duration(stage.startedAt, stage.endedAt), network: stage.network === "none" ? "none" : "other", limits: plain(stage.limits) ? reduceData(stage.limits, ["cpus", "memoryMb", "pidsLimit", "wallSeconds", "gpus"]) : null, imageDigest: digest(stage.image) };
|
|
99
|
+
}
|
|
100
|
+
const reason = reasonClass(a.reason, a.status);
|
|
101
|
+
return {
|
|
102
|
+
id: a.id, run: a.run, studyIdSha256: sha256(String(a.studyId)), protocolHash: shaped(a.protocolHash, SHA256), sealBinding: shaped(a.sealBinding, SHA256), commit: shaped(a.commit, GIT_ID), treeId: shaped(a.treeId, GIT_ID),
|
|
103
|
+
snapshotSha256: shaped(a.snapshotSha256, SHA256), seed: Number.isSafeInteger(a.seed) ? a.seed : null, labelSha256: sha256(String(a.label ?? "")), retryOf: shaped(a.retryOf, ATTEMPT_ID), status: a.status, stage: a.stage, ...(reason ? { reasonClass: reason } : {}),
|
|
104
|
+
durationMs: duration(a.startedAt, a.endedAt), stages,
|
|
105
|
+
metrics: (Array.isArray(a.metrics) ? a.metrics : []).map(m => ({ name: m.name, unit: m.unit, value: finite(m.value), provenance: m.provenance === "ares-observed" ? "ares-observed" : "other" })),
|
|
106
|
+
outputs: (Array.isArray(a.outputs) ? a.outputs : []).map(o => ({ sha256: shaped(o.sha256, SHA256), bytes: finite(o.bytes) })),
|
|
107
|
+
sealing: plain(a.sealing) ? { separateContainers: a.sealing.separateContainers === true, evaluatorReadOnly: a.sealing.evaluatorReadOnly === true, candidateHasNoEvaluator: a.sealing.candidateHasNoEvaluator === true } : null,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
function closedResult<T>(name: string, read: () => { result: T }): unknown {
|
|
111
|
+
try { return project(read().result); }
|
|
112
|
+
catch (error) { throw new Error(`study export: ${name} result is not a verified closed result (${(error as Error).message}); fix it as that message says, then export again`); }
|
|
113
|
+
}
|
|
114
|
+
function reviews(events: LedgerEvent[]) {
|
|
115
|
+
const resolved = new Map<string, number>();
|
|
116
|
+
for (const e of events) if (e.type === "review.resolved" && typeof e.data.reviewId === "string") resolved.set(e.data.reviewId, (resolved.get(e.data.reviewId) ?? 0) + 1);
|
|
117
|
+
return events.filter(e => e.type === "review.added" && plain(e.data.review)).map(e => {
|
|
118
|
+
const review = e.data.review as Record<string, unknown>, findings = Array.isArray(review.findings) ? review.findings as Record<string, unknown>[] : [];
|
|
119
|
+
const reviewer = plain(review.reviewer) ? review.reviewer : {}, kinds: Record<string, number> = {};
|
|
120
|
+
for (const f of findings) { const kind = typeof f.kind === "string" && TOKEN.test(f.kind) ? f.kind : "other"; kinds[kind] = (kinds[kind] ?? 0) + 1; }
|
|
121
|
+
const id = typeof e.data.id === "string" ? e.data.id : "";
|
|
122
|
+
const family = typeof reviewer.modelFamily === "string" ? reviewer.modelFamily.trim().toLowerCase() : "";
|
|
123
|
+
return { idSha256: sha256(id), at: isoAt(e.at), modelFamily: /^[a-z0-9][a-z0-9 ._-]{0,47}$/.test(family) ? family : "other", buildSha256: typeof review.buildSha256 === "string" && HEX.test(review.buildSha256) ? review.buildSha256 : null, findings: findings.length, blocking: findings.filter(f => f.blocking === true).length, kinds, resolved: resolved.get(id) ?? 0 };
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
function evidence(dir: string) {
|
|
127
|
+
const path = paperBuildPaths(dir).evidence;
|
|
128
|
+
if (!existsSync(path)) return null;
|
|
129
|
+
let raw: unknown;
|
|
130
|
+
try { raw = JSON.parse(readTextBounded(path, PAPER_EVIDENCE_BYTES)); } catch { throw new Error("study export: paper/build/evidence.json is unreadable, invalid JSON or oversized; rebuild the paper before exporting"); }
|
|
131
|
+
if (!plain(raw) || raw.v !== 1 || !Array.isArray(raw.placeholders)) throw new Error("study export: paper/build/evidence.json has an unsupported shape; rebuild the paper before exporting");
|
|
132
|
+
return {
|
|
133
|
+
v: 1, manuscriptSha256: typeof raw.manuscriptSha256 === "string" && SHA256.test(raw.manuscriptSha256) ? raw.manuscriptSha256 : null,
|
|
134
|
+
placeholders: (raw.placeholders as Record<string, unknown>[]).filter(p => p.kind === "number").map(p => {
|
|
135
|
+
const ref = typeof p.ref === "string" ? p.ref : "", attempt = /^(?:attempt|posthoc):([a-f0-9]{16})\./.exec(ref)?.[1];
|
|
136
|
+
const source = plain(p.source) ? p.source : {};
|
|
137
|
+
return { refSha256: sha256(ref), family: ((f: string | undefined) => f && EVIDENCE_FAMILIES.has(f) ? f : "other")(/^[a-z]+/.exec(ref)?.[0]), ...(attempt ? { attempt } : {}), sourceSha256: typeof source.sha256 === "string" && SHA256.test(source.sha256) ? source.sha256 : null };
|
|
138
|
+
}),
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Path rules run on decoded keys and string values, so a boundary is a real character (a raw newline or tab included). */
|
|
143
|
+
const PATH_SCANS: RegExp[] = [
|
|
144
|
+
// POSIX: `/` or `~/` not preceded by a letter, digit or underscore; units such as tokens/s keep a word character there.
|
|
145
|
+
/(?<![\p{L}\p{N}_])(?:\/|~\/)/u,
|
|
146
|
+
/\b[A-Za-z]:\\/,
|
|
147
|
+
// Windows root-relative (\Users\x), bare \ and UNC (\\server): any backslash not preceded by a word character or backslash.
|
|
148
|
+
/(?<![\p{L}\p{N}_\\])\\/u,
|
|
149
|
+
];
|
|
150
|
+
/** URL, key-like and env rules, also run on decoded strings. */
|
|
151
|
+
const SCANS: [string, RegExp][] = [
|
|
152
|
+
["url", /\b[a-z][a-z0-9+.-]*:\/\/|\b(?:mailto|urn|file|data):/i],
|
|
153
|
+
["key-like token", /\b(?:sk|pk|rk)[-_][A-Za-z0-9_-]{12,}|\b(?:ghp|gho|ghs|ghu|github_pat)_[A-Za-z0-9_]{12,}|\bxox[abprs]-[A-Za-z0-9-]{8,}|\bAKIA[0-9A-Z]{12,}|\beyJ[A-Za-z0-9_-]{16,}/],
|
|
154
|
+
["key-like token", /(?<![A-Za-z0-9_-])(?=[A-Za-z0-9_-]*[A-Z])(?=[A-Za-z0-9_-]*[a-z])(?=[A-Za-z0-9_-]*\d)[A-Za-z0-9_-]{32,}(?![A-Za-z0-9_-])/],
|
|
155
|
+
["env value", /(?<![A-Za-z0-9_])[A-Za-z_][A-Za-z0-9_]*=[^\s"]/],
|
|
156
|
+
];
|
|
157
|
+
/** Every key and string value of a JSON document, or of each JSONL line; text that is not JSON is scanned as one string. */
|
|
158
|
+
function* decodedStrings(text: string): Generator<string> {
|
|
159
|
+
const walk = function* (value: unknown): Generator<string> {
|
|
160
|
+
if (typeof value === "string") yield value;
|
|
161
|
+
else if (Array.isArray(value)) for (const item of value) yield* walk(item);
|
|
162
|
+
else if (value && typeof value === "object") for (const [key, item] of Object.entries(value)) { yield key; yield* walk(item); }
|
|
163
|
+
};
|
|
164
|
+
let whole: unknown, parsed = false;
|
|
165
|
+
try { whole = JSON.parse(text); parsed = true; } catch { /* JSONL or plain text */ }
|
|
166
|
+
if (parsed) { yield* walk(whole); return; }
|
|
167
|
+
for (const line of text.split("\n")) {
|
|
168
|
+
if (!line) continue;
|
|
169
|
+
try { yield* walk(JSON.parse(line)); } catch { yield line; }
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
/** Refuse the whole bundle if any file carries a path, URL, env assignment or key-like token. */
|
|
173
|
+
export function scanBundle(files: Record<string, string>): void {
|
|
174
|
+
const refuse = (name: string, kind: string): never => { throw new Error(`study export: refused; ${name} would contain a ${kind}. Remove it from the study record (for example an attempt label or ledger field) and retry; nothing was written`); };
|
|
175
|
+
for (const [name, text] of Object.entries(files)) {
|
|
176
|
+
for (const value of decodedStrings(text)) {
|
|
177
|
+
if (PATH_SCANS.some(pattern => pattern.test(value))) refuse(name, "absolute path");
|
|
178
|
+
for (const [kind, pattern] of SCANS) if (pattern.test(value)) refuse(name, kind);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function emptyDir(path: string): void {
|
|
184
|
+
if (existsSync(path)) { if (!lstatSync(path).isDirectory()) throw new Error("study export: --bundle exists and is not a directory; choose a new or empty directory"); if (readdirSync(path).length) throw new Error("study export: --bundle directory is not empty; choose a new or empty directory"); }
|
|
185
|
+
else mkdirSync(path, { recursive: true });
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Real path of the nearest existing ancestor with the missing tail re-appended, so symlinks cannot hide containment. */
|
|
189
|
+
function realTarget(path: string): string {
|
|
190
|
+
const tail: string[] = [];
|
|
191
|
+
let current = path;
|
|
192
|
+
while (!existsSync(current)) { const parent = dirname(current); if (parent === current) break; tail.unshift(basename(current)); current = parent; }
|
|
193
|
+
return join(realpathSync(current), ...tail);
|
|
194
|
+
}
|
|
195
|
+
/** Caller holds the study lock across every bounded source read. */
|
|
196
|
+
function readBundle(dirArg: string, maxFileBytes: number) {
|
|
197
|
+
const { dir, study } = openStudy(dirArg);
|
|
198
|
+
const events = readLedger(dir);
|
|
199
|
+
const zero = () => Object.fromEntries(STATUSES.map(s => [s, 0])) as Record<AttemptStatus, number>;
|
|
200
|
+
const byStatus = zero(), runCounts = new Map<string, Record<AttemptStatus, number>>(), attemptLines: string[] = [];
|
|
201
|
+
let attemptBytes = 0;
|
|
202
|
+
// One pass; only the projected row and counts outlive each raw record.
|
|
203
|
+
for (const a of study.mode === "experiment" ? readAttemptRecords(dir) : []) {
|
|
204
|
+
const line = JSON.stringify(attemptRow(a)) + "\n";
|
|
205
|
+
attemptBytes += Buffer.byteLength(line);
|
|
206
|
+
if (attemptBytes > maxFileBytes) throw new Error(`study export: attempts.jsonl would exceed ${maxFileBytes} bytes; nothing was written`);
|
|
207
|
+
attemptLines.push(line);
|
|
208
|
+
byStatus[a.status]++;
|
|
209
|
+
let row = runCounts.get(a.run); if (!row) runCounts.set(a.run, row = zero()); row[a.status]++;
|
|
210
|
+
}
|
|
211
|
+
const byRun: Record<string, Record<AttemptStatus, number>> = Object.create(null);
|
|
212
|
+
for (const [run, row] of [...runCounts].sort(([x], [y]) => x < y ? -1 : 1)) byRun[run] = row;
|
|
213
|
+
const experiment = study.mode === "experiment", current = experiment ? readProtocol(dir) : undefined, rule = current ? readSelection(dir, current) : undefined;
|
|
214
|
+
const investigate: Record<string, unknown> = {};
|
|
215
|
+
if (rule && existsSync(join(dir, "runs", rule.run, "investigate.json"))) investigate[rule.run] = closedResult("investigate", () => readClosedInvestigation(dir));
|
|
216
|
+
const reviewRows = reviews(events), protocol = study.mode === "experiment" ? readProtocol(dir) : undefined;
|
|
217
|
+
const lines = (rows: unknown[]) => rows.map(r => JSON.stringify(r)).join("\n") + (rows.length ? "\n" : "");
|
|
218
|
+
const files: Record<string, string> = {
|
|
219
|
+
"ledger.jsonl": lines(events.map(reduceEvent)),
|
|
220
|
+
"attempts.jsonl": attemptLines.join(""),
|
|
221
|
+
"results.json": JSON.stringify({
|
|
222
|
+
investigate,
|
|
223
|
+
ablate: experiment && existsSync(ablateResultPath(dir)) ? closedResult("ablate", () => readClosedAblate(dir)) : null,
|
|
224
|
+
final: experiment && existsSync(finalResultPath(dir)) ? closedResult("final", () => readClosedFinal(dir)) : null,
|
|
225
|
+
}, null, 2) + "\n",
|
|
226
|
+
"reviews.jsonl": lines(reviewRows),
|
|
227
|
+
"evidence.json": JSON.stringify(evidence(dir), null, 2) + "\n",
|
|
228
|
+
};
|
|
229
|
+
const manifest: BundleManifest = {
|
|
230
|
+
bundle: 1, formats: { ...FORMAT_VERSIONS }, studyIdSha256: sha256(study.id), mode: study.mode,
|
|
231
|
+
protocolHash: protocol ? protocolHash(protocol) : null, sealBinding: study.mode === "experiment" ? designBinding(dir) ?? null : null,
|
|
232
|
+
window: (() => { const ats = events.map(e => isoAt(e.at)).filter((v): v is string => v !== null); return { from: ats[0] ?? null, to: ats.at(-1) ?? null }; })(),
|
|
233
|
+
counts: { attempts: attemptLines.length, byStatus, byRun, ledgerEvents: events.length, reviews: reviewRows.length },
|
|
234
|
+
files: Object.fromEntries(Object.entries(files).map(([name, text]) => [name, sha256(text)])),
|
|
235
|
+
};
|
|
236
|
+
files["manifest.json"] = JSON.stringify(manifest, null, 2) + "\n";
|
|
237
|
+
return { files, manifest };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Read one locked study snapshot, then validate and publish without holding the study lock. */
|
|
241
|
+
export async function exportBundle(dirArg: string, options: { bundle: string; list?: boolean }, maxFileBytes = BUNDLE_FILE_BYTES): Promise<BundleManifest | BundleListing> {
|
|
242
|
+
const dir = resolve(dirArg), target = resolve(options.bundle), inside = relative(realpathSync(dir), realTarget(target));
|
|
243
|
+
if (inside === "" || (!(inside === ".." || inside.startsWith(".." + sep)) && !isAbsolute(inside))) throw new Error("study export: --bundle is inside the study directory; choose a directory outside the study");
|
|
244
|
+
if (options.list) {
|
|
245
|
+
await withLock(dir, () => openStudy(dir));
|
|
246
|
+
return { bundle: options.bundle, files: [...BUNDLE_FILES], wouldWrite: true };
|
|
247
|
+
}
|
|
248
|
+
const { files, manifest } = await withLock(dir, () => readBundle(dir, maxFileBytes));
|
|
249
|
+
scanBundle(files);
|
|
250
|
+
for (const name of BUNDLE_FILES) assertWritable(files[name]!, maxFileBytes, `study export: ${name}; nothing was written`);
|
|
251
|
+
emptyDir(target);
|
|
252
|
+
for (const name of BUNDLE_FILES) writeAtomic(join(target, name), files[name]!, maxFileBytes);
|
|
253
|
+
return manifest;
|
|
254
|
+
}
|