@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/ui/plugin.ts
ADDED
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
/** Console api1 contract subset; runtime imports stay within ares/Node/Bun. Real contract/conformance is test-only. */
|
|
2
|
+
import { lstatSync, realpathSync, statSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { version } from "../../package.json";
|
|
6
|
+
import * as ops from "../commands.ts";
|
|
7
|
+
import { foldPlan, canonical, PLAN_GATE_BYTES } from "../plan/state.ts";
|
|
8
|
+
import { readRegistry, MAX_REGISTERED_STUDIES } from "../console/registry.ts";
|
|
9
|
+
import { readAttemptRecords, type Attempt } from "../exp/attempts.ts";
|
|
10
|
+
import { createHttp } from "../http.ts";
|
|
11
|
+
import { previewArchive, ARCHIVE_NOTES, ArchivePartialError } from "../memory/archive.ts";
|
|
12
|
+
import { spawnRunner, type ProcessRunner } from "../exp/executor.ts";
|
|
13
|
+
import { readProtocol } from "../exp/protocol.ts";
|
|
14
|
+
import { readSelection, selectionPath } from "../exp/selection.ts";
|
|
15
|
+
import { investigateResultPath } from "../exp/investigate.ts";
|
|
16
|
+
import { readClosedFinal, finalResultPath } from "../exp/final.ts";
|
|
17
|
+
import { readCurrentBuild, paperBuildPaths } from "../paper/build.ts";
|
|
18
|
+
import { loadCandidates } from "../lit/candidates.ts";
|
|
19
|
+
import { openStudy, readLedger, readBytesBounded, readTextBounded, sha256, boundedText, plain, type LedgerEvent } from "../study.ts";
|
|
20
|
+
|
|
21
|
+
type Args = Record<string, unknown>;
|
|
22
|
+
interface Context { home: string; log(line: string): void; features?: readonly string[] }
|
|
23
|
+
interface Verb { id: string; label: string; destructive?: boolean; reason?: "optional" | "required"; form?: { questions: { id: string; prompt: string; options: { id: string; label: string }[]; other?: { label?: string; maxLength?: number } }[] } }
|
|
24
|
+
interface Item { id: string; group: "decision" | "candidate" | "review" | "alert"; kind: string; title: string; detail?: string; project?: string; at: string; verbs: Verb[]; link: { panel: string; params: Record<string, string> } }
|
|
25
|
+
type ActionResult = { ok: true; message: string } | { ok: false; error: string } | { ok: false; needsConfirm: true; fingerprint: string; preview: string[] };
|
|
26
|
+
interface Runtime { runner?: ProcessRunner; env?: NodeJS.ProcessEnv; now?: () => number }
|
|
27
|
+
// Console calls allow 30s; leave 5s for partial receipts and the response after a child timeout.
|
|
28
|
+
export const CONSOLE_ARCHIVE_MS = 25_000;
|
|
29
|
+
interface Provider {
|
|
30
|
+
assets: { js: string; css: string };
|
|
31
|
+
snapshot(input: { after: number; panel: string } & Args): Promise<Args>;
|
|
32
|
+
action(input: { action: string; confirmed?: true; fingerprint?: string } & Args, meta?: { identity: string; node: string; via: "console" }): Promise<ActionResult>;
|
|
33
|
+
asset(input: { id: string }): Promise<{ bytes: Uint8Array; mime: string; name: string } | null>;
|
|
34
|
+
}
|
|
35
|
+
const EPOCH = new Date(0).toISOString(), ASSET_BYTES = 2 * 1024 * 1024;
|
|
36
|
+
const str = (value: unknown) => typeof value === "string" ? value : undefined;
|
|
37
|
+
const line = (value: string, max = 400) => value.replace(/\s+/g, " ").slice(0, max);
|
|
38
|
+
const error = (value: unknown) => value instanceof Error ? line(value.message) : "unreadable study data; restore the last valid study files";
|
|
39
|
+
const stamp = (value: unknown, fallback = EPOCH) => typeof value === "string" && Number.isFinite(Date.parse(value)) ? value : fallback;
|
|
40
|
+
function readCall<T>(name: string, fn: () => T, errors: Record<string, string>): T | null {
|
|
41
|
+
try { return fn(); } catch (cause) { errors[name] = error(cause); return null; }
|
|
42
|
+
}
|
|
43
|
+
export interface GateInputs { selectionRun?: string; review?: { manuscriptSha256: string; evidenceSha256: string; ids: ReadonlySet<string> } }
|
|
44
|
+
/** Every gate advances only when an input consumed by its binder changes, never merely on an event kind. */
|
|
45
|
+
export function gateWaitingTimes(events: LedgerEvent[], inputs: GateInputs, observeSignature?: (gate: string, signature: string | undefined) => void): Map<string, string> {
|
|
46
|
+
const waiting = new Map<string, string>(), previous = new Map<string, string | undefined>();
|
|
47
|
+
const observe = (gate: string, input: unknown, at: string) => {
|
|
48
|
+
const signature = input === undefined ? undefined : canonical(input);
|
|
49
|
+
observeSignature?.(gate, signature);
|
|
50
|
+
if (previous.get(gate) === signature) return;
|
|
51
|
+
previous.set(gate, signature);
|
|
52
|
+
if (signature === undefined) waiting.delete(gate); else waiting.set(gate, at);
|
|
53
|
+
};
|
|
54
|
+
foldPlan(events, (state, event) => {
|
|
55
|
+
const names = new Set<string>();
|
|
56
|
+
for (const node of state.nodes) if (node.kind === "gate") { const gate = `plan:${node.id}`; names.add(gate); observe(gate, node.gate?.current, event.at); }
|
|
57
|
+
for (const gate of previous.keys()) if (gate.startsWith("plan:") && !names.has(gate)) { observe(gate, undefined, event.at); previous.delete(gate); }
|
|
58
|
+
});
|
|
59
|
+
const included = new Set<string>();
|
|
60
|
+
let investigate: string | undefined, protocol: [string, string] | undefined, selection: string | undefined, reserved: string | undefined, ablate: string | undefined, hasAblations = false;
|
|
61
|
+
let paper: [string, string] | undefined, reviewChanges = 0;
|
|
62
|
+
const experiment = (at: string) => {
|
|
63
|
+
// These are the exact source hashes in readDesign/finalGateBinding; only the selected run's close is consumed.
|
|
64
|
+
const design = protocol ? [...protocol, ...(selection === undefined ? [] : [selection]), ...(reserved === undefined ? [] : [reserved])] : undefined;
|
|
65
|
+
observe("design", design, at);
|
|
66
|
+
observe("final", design && selection && reserved && investigate && (!hasAblations || ablate) ? [investigate, ablate ?? null, design] : undefined, at);
|
|
67
|
+
};
|
|
68
|
+
const review = (at: string) => {
|
|
69
|
+
const current = inputs.review;
|
|
70
|
+
// reviewBinding hashes only this build's added/resolved review events. Each valid selected event adds a hash.
|
|
71
|
+
observe("review", current && paper?.[0] === current.manuscriptSha256 && paper[1] === current.evidenceSha256 ? [paper, reviewChanges] : undefined, at);
|
|
72
|
+
};
|
|
73
|
+
for (const event of events) {
|
|
74
|
+
const data = event.data;
|
|
75
|
+
switch (event.type) {
|
|
76
|
+
case "scope.set": if (typeof data.sha256 === "string") observe("scope", data.sha256, event.at); break;
|
|
77
|
+
case "lit.screen": {
|
|
78
|
+
let changed = false;
|
|
79
|
+
for (const id of Array.isArray(data.ids) ? data.ids : []) if (typeof id === "string") {
|
|
80
|
+
if (data.decision === "include" && !included.has(id)) { included.add(id); changed = true; }
|
|
81
|
+
else if (data.decision === "exclude" && included.delete(id)) changed = true;
|
|
82
|
+
}
|
|
83
|
+
if (changed) observe("screen", included.size ? [...included].sort() : undefined, event.at); break;
|
|
84
|
+
}
|
|
85
|
+
case "protocol.set":
|
|
86
|
+
if (typeof data.sha256 === "string" && typeof data.treeHash === "string") protocol = [data.sha256, data.treeHash];
|
|
87
|
+
experiment(event.at); break;
|
|
88
|
+
case "selection.set": if (typeof data.sha256 === "string") selection = data.sha256; experiment(event.at); break;
|
|
89
|
+
case "final.seeds.set": if (typeof data.sha256 === "string") reserved = data.sha256; experiment(event.at); break;
|
|
90
|
+
case "investigate.closed": if (data.run === inputs.selectionRun && typeof data.sha256 === "string") investigate = typeof data.winner === "string" && /^[a-f0-9]{40}$/.test(data.winner) ? data.sha256 : undefined; experiment(event.at); break;
|
|
91
|
+
case "ablation.added": hasAblations = true; experiment(event.at); break;
|
|
92
|
+
case "ablate.closed": if (typeof data.sha256 === "string") ablate = data.sha256; experiment(event.at); break;
|
|
93
|
+
case "paper.built":
|
|
94
|
+
if (typeof data.manuscriptSha256 === "string" && typeof data.evidenceSha256 === "string") paper = [data.manuscriptSha256, data.evidenceSha256];
|
|
95
|
+
review(event.at); break;
|
|
96
|
+
case "review.added": if (typeof data.id === "string" && inputs.review?.ids.has(data.id)) { reviewChanges++; review(event.at); } break;
|
|
97
|
+
case "review.resolved": if (typeof data.reviewId === "string" && inputs.review?.ids.has(data.reviewId)) { reviewChanges++; review(event.at); } break;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return waiting;
|
|
101
|
+
}
|
|
102
|
+
function attempts(dir: string, all: boolean) {
|
|
103
|
+
const byStatus = { running: 0, succeeded: 0, failed: 0, cancelled: 0, unknown: 0 }, rows: Pick<Attempt, "id" | "status" | "reason">[] = [];
|
|
104
|
+
let total = 0;
|
|
105
|
+
for (const row of readAttemptRecords(dir)) {
|
|
106
|
+
total++; byStatus[row.status]++;
|
|
107
|
+
if (!all || (row.status !== "failed" && row.status !== "unknown")) continue;
|
|
108
|
+
if (row.reason !== undefined && (typeof row.reason !== "string" || row.reason.length > 500)) throw new Error("attempt inbox: malformed reason; restore the last valid bounded attempt record and refresh");
|
|
109
|
+
rows.push({ id: row.id, status: row.status, ...(row.reason === undefined ? {} : { reason: row.reason }) });
|
|
110
|
+
}
|
|
111
|
+
return { total, byStatus, rows };
|
|
112
|
+
}
|
|
113
|
+
/** Presence is not validity: any present artifact is still checked by its existing bounded reader. */
|
|
114
|
+
function recordPresent(file: string): boolean {
|
|
115
|
+
try { lstatSync(file); return true; } catch (cause) { if ((cause as NodeJS.ErrnoException).code === "ENOENT") return false; throw cause; }
|
|
116
|
+
}
|
|
117
|
+
function loadStudy(dir: string, features: readonly string[], input: { panel: string; study?: string; id?: string }) {
|
|
118
|
+
const { study } = openStudy(dir), events = readLedger(dir), errors: Record<string, string> = {}, key = sha256(dir).slice(0, 16);
|
|
119
|
+
const detailed = input.panel === "study" && (input.study === dir || !input.study && input.id === study.id), inbox = input.panel === "inbox";
|
|
120
|
+
const targets = new Map<string, Args>();
|
|
121
|
+
const at = stamp(events.at(-1)?.at, stamp(study.createdAt)), items: Item[] = [], link = { panel: "study", params: { study: dir } };
|
|
122
|
+
const add = (kind: string, entity: string, group: Item["group"], title: string, time: string, detail = "", verbs: Verb[] = [], subject?: Args) => {
|
|
123
|
+
const id = `${kind}:${key}:${sha256(entity).slice(0, 16)}`;
|
|
124
|
+
items.push({ id, group, kind, title: line(title, 180), detail: line(detail, 1000), project: study.id, at: stamp(time, at), verbs, link });
|
|
125
|
+
if (subject) targets.set(id, { study: dir, ...subject, actions: verbs.map(verb => verb.id) });
|
|
126
|
+
};
|
|
127
|
+
const status = ops.status(dir), gates = ops.gateList(dir);
|
|
128
|
+
const attempt = study.mode === "experiment" ? readCall("attempts", () => attempts(dir, inbox), errors) : null;
|
|
129
|
+
let investigationArtifact: { state: "missing"; recalculated: true; message: string } | undefined;
|
|
130
|
+
const investigation = detailed && study.mode === "experiment" ? readCall("investigation", () => {
|
|
131
|
+
const configured = recordPresent(selectionPath(dir)) || events.some(event => event.type === "selection.set" || event.type === "investigate.closed");
|
|
132
|
+
if (!configured) return null;
|
|
133
|
+
const result = ops.investigateShow(dir);
|
|
134
|
+
if (events.some(event => event.type === "investigate.closed" && event.data.run === result.run) && !recordPresent(investigateResultPath(dir, result.run))) {
|
|
135
|
+
investigationArtifact = { state: "missing", recalculated: true,
|
|
136
|
+
message: `Missing receipted artifact runs/${result.run}/investigate.json; this investigation result is recalculated from current inputs. Restore the artifact or run ares investigate close before using a closed result.` };
|
|
137
|
+
}
|
|
138
|
+
return result;
|
|
139
|
+
}, errors) : null;
|
|
140
|
+
const final = study.mode === "experiment" ? readCall("final", () => {
|
|
141
|
+
const recorded = recordPresent(finalResultPath(dir)) || events.some(event => event.type === "final.closed");
|
|
142
|
+
return recorded ? readClosedFinal(dir).result : null;
|
|
143
|
+
}, errors) : null;
|
|
144
|
+
const posthoc = detailed && study.mode === "experiment" ? readCall("posthoc", () => ops.posthocShow(dir), errors) : null;
|
|
145
|
+
const review = readCall("review", () => ops.reviewShow(dir), errors), skills = detailed || inbox ? readCall("skills", () => ops.skillList(dir), errors) : null;
|
|
146
|
+
const reviewFailure = review?.unmet.find(row => row.code === "review-journal");
|
|
147
|
+
if (reviewFailure) errors.review = line(reviewFailure.message);
|
|
148
|
+
const watch = readCall("watch", () => ops.watchShow(dir), errors), lastWatch = watch?.runs[0] ?? null;
|
|
149
|
+
if (inbox) {
|
|
150
|
+
const protocol = gates.some(row => row.gate === "final" && row.state === "pending") ? readProtocol(dir) : undefined;
|
|
151
|
+
const gateTimes = gateWaitingTimes(events, {
|
|
152
|
+
...(protocol ? { selectionRun: readSelection(dir, protocol)?.run } : {}),
|
|
153
|
+
...(review?.buildSha256 && review.manuscriptSha256 ? { review: { manuscriptSha256: review.manuscriptSha256, evidenceSha256: review.buildSha256, ids: new Set(review.reviews.map(row => row.id)) } } : {}),
|
|
154
|
+
});
|
|
155
|
+
for (const gate of gates.filter(row => row.state === "pending")) add("gate", `${gate.gate}:${gate.current}`, "decision", `Approve or reject ${gate.gate}`, stamp(gateTimes.get(gate.gate), stamp(events[0]?.at, stamp(study.createdAt))), study.question,
|
|
156
|
+
[{ id: "gate.approve", label: "Approve", destructive: true }, { id: "gate.reject", label: "Reject", destructive: true, reason: "required" }], { gate: gate.gate });
|
|
157
|
+
const current = new Map(loadCandidates(dir).filter(row => row.status === "new").map(row => [row.id, row])), watched = new Map<string, LedgerEvent>();
|
|
158
|
+
for (const event of events) if (event.type === "watch.ran" && Array.isArray(event.data.addedIds)) for (const id of event.data.addedIds) if (typeof id === "string" && current.has(id)) watched.set(id, event);
|
|
159
|
+
for (const [id, event] of watched) add("candidate", id, "candidate", current.get(id)!.title, event.at, `${watch?.config?.rubric ? "See study rubric in the linked study panel (display-only)." : "No study rubric recorded."}\n${id}`,
|
|
160
|
+
[{ id: "screen.include", label: "Include", destructive: true, reason: "required" }, { id: "screen.exclude", label: "Exclude", destructive: true, reason: "required" }], { ids: [id] });
|
|
161
|
+
for (const [index, event] of events.entries()) if (event.type === "watch.ran" && Array.isArray(event.data.addedIds) && Number.isSafeInteger(event.data.addedIdsTotal)) {
|
|
162
|
+
const overflow = Number(event.data.addedIdsTotal) - event.data.addedIds.length;
|
|
163
|
+
if (overflow > 0) add("watch-overflow", String(index), "alert", `${overflow} watch candidate ids exceed the receipt list`, event.at,
|
|
164
|
+
"Only recorded ids appear as candidate items. Unrecorded ids have no individual watch provenance; inspect ares watch show and the study's new candidates.");
|
|
165
|
+
}
|
|
166
|
+
for (const skill of skills ?? []) if (skill.state === "proposed") {
|
|
167
|
+
const verbs: Verb[] = [{ id: "skill.reject", label: "Reject", destructive: true, reason: "required" }, { id: "skill.withdraw", label: "Withdraw", destructive: true, reason: "required" }];
|
|
168
|
+
if (features.includes("inbox.form")) verbs.unshift({ id: "skill.accept", label: "Accept", destructive: true, reason: "required", form: { questions: [{ id: "pr", prompt: "Provide the reviewed PR URL", options: [{ id: "reviewed", label: "Reviewed PR is ready" }, { id: "pending", label: "Keep proposed" }], other: { label: "Reviewed PR URL", maxLength: 1000 } }] } });
|
|
169
|
+
add("skill", skill.name, "review", skill.name, skill.at, skill.summary, verbs, { name: skill.name });
|
|
170
|
+
}
|
|
171
|
+
for (const finding of review?.openBlocking ?? []) {
|
|
172
|
+
const row = review!.reviews.find(row => row.id === finding.reviewId);
|
|
173
|
+
add("review", `${finding.reviewId}/${finding.findingId}`, "review", `${finding.kind}: ${finding.text}`, row?.at ?? at, finding.location);
|
|
174
|
+
}
|
|
175
|
+
const attemptEvents = new Map<string, LedgerEvent>();
|
|
176
|
+
for (const event of events) if (typeof event.data.id === "string" && (event.data.status === "failed" || event.data.status === "unknown")) attemptEvents.set(`${event.data.id}:${event.data.status}`, event);
|
|
177
|
+
for (const row of attempt?.rows ?? []) if (row.status === "failed" || row.status === "unknown") {
|
|
178
|
+
const event = attemptEvents.get(`${row.id}:${row.status}`);
|
|
179
|
+
add("attempt", row.id, "alert", `${row.status} attempt ${row.id}`, event?.at ?? at, row.reason ?? "Inspect ares attempt show before retrying");
|
|
180
|
+
}
|
|
181
|
+
for (const [index, event] of events.entries()) if (event.type === "watch.ran" && event.data.ok === false) {
|
|
182
|
+
const cause = event.data.cause, known = typeof cause === "string" && ["terminated", "filesystem", "internal", "network", "rate", "server", "client", "format"].includes(cause);
|
|
183
|
+
const title = cause === undefined ? "Every watch source failed" : known ? `Watch run failed (${cause})` : "Watch run failed (unknown cause)";
|
|
184
|
+
const detail = cause === undefined ? "Inspect ares watch show and retry after restoring source access" : "Inspect ares watch show and lit list; restore study storage or source access as needed, then rerun ares watch run";
|
|
185
|
+
add("watch", event.at + index, "alert", title, event.at, detail);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
const archive = detailed ? readCall("archive", () => { const preview = previewArchive(dir); return { pendingCount: preview.notes.length, fingerprint: preview.fingerprint }; }, errors) : null;
|
|
189
|
+
const manuscript = detailed && readCall("manuscript", () => {
|
|
190
|
+
const paths = paperBuildPaths(dir), recorded = recordPresent(paths.manuscript) || recordPresent(paths.evidence) || events.some(event => event.type === "paper.built");
|
|
191
|
+
return recorded ? readCurrentBuild(dir) : null;
|
|
192
|
+
}, errors) ? { id: `manuscript:${study.id}` } : null;
|
|
193
|
+
const failures = Object.entries(errors);
|
|
194
|
+
if (inbox && failures.length) {
|
|
195
|
+
const perFailure = Math.floor(800 / failures.length);
|
|
196
|
+
add("partial-read", "records", "alert", "Study data partially unavailable", at,
|
|
197
|
+
failures.map(([name, reason]) => `${name}: ${line(reason, Math.max(20, perFailure - name.length - 2))}`).join("; ") + "; Inspect the study panel, restore readable records or use a compatible ares version, then refresh.");
|
|
198
|
+
}
|
|
199
|
+
const summary = { id: study.id, dir, available: true, question: status.question, mode: status.mode, autonomy: status.autonomy, gates,
|
|
200
|
+
attempts: attempt ? { total: attempt.total, byStatus: attempt.byStatus } : study.mode === "experiment" ? { available: false, reason: errors.attempts ?? "Attempt records could not be read; inspect ares attempt list and restore readable records, then refresh" } : { total: 0, byStatus: {} },
|
|
201
|
+
final: final ? { status: final.status, ...(final.reason ? { reason: final.reason } : {}) } : null,
|
|
202
|
+
review: review ? { ready: review.ready, unmet: review.unmet } : null, lastWatch };
|
|
203
|
+
return { summary, targets, detail: detailed ? { ...summary, investigation, investigationArtifact: investigationArtifact ?? null, final, posthoc, review, skills, watch, archive, manuscript, errors } : null, items, at };
|
|
204
|
+
}
|
|
205
|
+
interface Selection { wireAction: string; action: "gate" | "screen" | "skill.decide" | "archive.confirm"; study: string; gate?: string; ids?: string[]; name?: string; decision?: string; reason?: string; pr?: string }
|
|
206
|
+
const ACTIONS: Record<string, { action: Selection["action"]; decision?: string; fields: readonly string[] }> = {
|
|
207
|
+
"gate.approve": { action: "gate", decision: "approve", fields: ["gate", "reason"] }, "gate.reject": { action: "gate", decision: "reject", fields: ["gate", "reason"] },
|
|
208
|
+
"screen.include": { action: "screen", decision: "include", fields: ["ids", "reason"] }, "screen.exclude": { action: "screen", decision: "exclude", fields: ["ids", "reason"] },
|
|
209
|
+
"skill.accept": { action: "skill.decide", decision: "accept", fields: ["name", "reason", "pr"] }, "skill.reject": { action: "skill.decide", decision: "reject", fields: ["name", "reason"] }, "skill.withdraw": { action: "skill.decide", decision: "withdraw", fields: ["name", "reason"] },
|
|
210
|
+
"archive.confirm": { action: "archive.confirm", fields: [] },
|
|
211
|
+
};
|
|
212
|
+
function refusal(message: string): never { throw new Error(`console action: ${message}; refresh the study and review a new preview before confirming`); }
|
|
213
|
+
function field(value: unknown, name: string, max = 500): string {
|
|
214
|
+
let text: string;
|
|
215
|
+
try { text = boundedText(value, `console ${name}`, max); } catch { refusal(`${name} requires nonblank text within ${max} bytes`); }
|
|
216
|
+
if (Buffer.byteLength(text) > max) refusal(`${name} exceeds ${max} bytes`); return text;
|
|
217
|
+
}
|
|
218
|
+
const SHELL_FIELDS = ["action", "study", "id", "answers", "confirmed", "fingerprint"];
|
|
219
|
+
function validateActionInput(input: Args): void {
|
|
220
|
+
if (typeof input.action !== "string" || !Object.hasOwn(ACTIONS, input.action)) refusal("unknown action");
|
|
221
|
+
const route = ACTIONS[input.action]!;
|
|
222
|
+
const extra = Object.keys(input).find(key => !SHELL_FIELDS.includes(key) && !route.fields.includes(key));
|
|
223
|
+
if (extra !== undefined) refusal(`unknown field ${line(extra, 80)} for ${input.action}; remove it`);
|
|
224
|
+
}
|
|
225
|
+
function selection(input: Args, home: string): Selection {
|
|
226
|
+
if (typeof input.action !== "string" || !Object.hasOwn(ACTIONS, input.action)) refusal("unknown action");
|
|
227
|
+
const route = ACTIONS[input.action]!, action = route.action;
|
|
228
|
+
const study = typeof input.study === "string" ? input.study : refusal("study must be a registered absolute directory");
|
|
229
|
+
if (!readRegistry(home).studies.includes(study)) refusal("study is not registered; use ares console add for this readable study");
|
|
230
|
+
const result: Selection = { action, wireAction: input.action, study };
|
|
231
|
+
if (action === "archive.confirm") return result;
|
|
232
|
+
result.decision = route.decision!;
|
|
233
|
+
if (action === "gate") {
|
|
234
|
+
result.gate = field(input.gate, "gate", PLAN_GATE_BYTES);
|
|
235
|
+
if (!["approve", "reject"].includes(result.decision)) refusal("gate decision must be approve or reject");
|
|
236
|
+
} else if (action === "screen") {
|
|
237
|
+
if (!["include", "exclude"].includes(result.decision)) refusal("screen decision must be include or exclude");
|
|
238
|
+
if (!Array.isArray(input.ids) || !input.ids.length || input.ids.length > 200) refusal("screen requires 1..200 candidate ids");
|
|
239
|
+
result.ids = [...new Set(input.ids.map(id => field(id, "candidate id", 300)))].sort();
|
|
240
|
+
} else {
|
|
241
|
+
result.name = field(input.name, "skill name", 64);
|
|
242
|
+
if (!["accept", "reject", "withdraw"].includes(result.decision)) refusal("skill decision must be accept, reject or withdraw");
|
|
243
|
+
if (input.pr !== undefined) {
|
|
244
|
+
result.pr = field(input.pr, "PR URL", 1000);
|
|
245
|
+
try { const url = new URL(result.pr); if (!["http:", "https:"].includes(url.protocol) || url.username || url.password) throw new Error(); }
|
|
246
|
+
catch { refusal("PR must be an HTTP(S) URL without credentials"); }
|
|
247
|
+
}
|
|
248
|
+
if (result.decision === "accept" && !result.pr) refusal("accept requires the reviewed PR URL");
|
|
249
|
+
}
|
|
250
|
+
if (input.reason !== undefined || action !== "gate" || result.decision === "reject") result.reason = field(input.reason, "reason", action === "skill.decide" ? 16384 : 500);
|
|
251
|
+
return result;
|
|
252
|
+
}
|
|
253
|
+
function boundAction(value: Selection) {
|
|
254
|
+
const { study, action } = value, identity = openStudy(study).study, preview = [`Study: ${identity.question}`];
|
|
255
|
+
if (realpathSync(study) !== study) refusal("registered study path now resolves elsewhere; register the resolved directory");
|
|
256
|
+
let binding: unknown, archiveFingerprint: string | undefined;
|
|
257
|
+
if (action === "gate") {
|
|
258
|
+
const gate = ops.gateList(study).find(row => row.gate === value.gate);
|
|
259
|
+
if (!gate?.current || gate.state !== "pending") refusal("gate is missing or no longer pending");
|
|
260
|
+
binding = gate; preview.push(`${value.decision} gate ${value.gate}`, `Binding: ${gate.current}`);
|
|
261
|
+
} else if (action === "screen") {
|
|
262
|
+
const all = loadCandidates(study), chosen = value.ids!.map(id => all.find(row => row.id === id));
|
|
263
|
+
if (chosen.some(row => !row || row.status !== "new")) refusal("candidate selection is missing or no longer new");
|
|
264
|
+
binding = all; preview.push(`${value.decision} ${chosen.length} candidate(s)`, ...chosen.map(row => line(`${row!.id}: ${row!.title}`, 250)));
|
|
265
|
+
} else if (action === "skill.decide") {
|
|
266
|
+
const skill = ops.skillList(study).find(row => row.name === value.name);
|
|
267
|
+
if (!skill || skill.state !== "proposed") refusal("proposal is missing or already decided");
|
|
268
|
+
binding = skill; preview.push(`${value.decision} skill ${skill.name}`, `Proposal: ${skill.sha256}`, ...(value.pr ? [`Reviewed PR: ${value.pr}`] : []));
|
|
269
|
+
} else {
|
|
270
|
+
const archive = previewArchive(study); archiveFingerprint = archive.fingerprint; binding = { fingerprint: archive.fingerprint, pending: archive.notes.map(row => row.sha256) };
|
|
271
|
+
preview.push(`Export ${Math.min(ARCHIVE_NOTES, archive.notes.length)} of ${archive.notes.length} pending lesson(s) to amem`, `Archive fingerprint: ${archive.fingerprint}`);
|
|
272
|
+
}
|
|
273
|
+
if (value.reason) preview.push(`Reason: ${value.reason}`);
|
|
274
|
+
return { fingerprint: sha256(canonical({ selection: value, study: identity, binding })), preview, archiveFingerprint };
|
|
275
|
+
}
|
|
276
|
+
export function createPlugin(runtime: Runtime = {}) { return {
|
|
277
|
+
manifest: { api: 1 as const, tool: "ares", version, title: "Research", icon: "agents", pollMs: 15000,
|
|
278
|
+
panels: [{ id: "studies", title: "Studies", order: 1 }, { id: "study", title: "Study", order: 2 }],
|
|
279
|
+
capabilities: { actions: Object.keys(ACTIONS), confirm: true, inbox: true, multiProject: false, search: false } },
|
|
280
|
+
async open(ctx: Context): Promise<Provider> {
|
|
281
|
+
const assets = { js: readTextBounded(fileURLToPath(new URL("./panel.js", import.meta.url)), 262144), css: readTextBounded(fileURLToPath(new URL("./panel.css", import.meta.url)), 262144) };
|
|
282
|
+
const seen = new Map<string, string>(), lastAt = new Map<string, string>(), targets = new Map<string, Args>();
|
|
283
|
+
const env = { ...(runtime.env ?? process.env), AGENTHUB_HOME: ctx.home };
|
|
284
|
+
const http = createHttp(env, async () => { throw new Error("console actions do not perform network searches"); });
|
|
285
|
+
return {
|
|
286
|
+
assets,
|
|
287
|
+
async snapshot(input) {
|
|
288
|
+
const registry = readRegistry(ctx.home);
|
|
289
|
+
const cursor = registry.studies.reduce((sum, dir) => {
|
|
290
|
+
try { const info = statSync(join(dir, "ledger.jsonl")); return sum + (info.isFile() ? info.size : 0); } catch { return sum; }
|
|
291
|
+
}, 0);
|
|
292
|
+
const view = `${input.panel}:${str(input.study) ?? str(input.id) ?? ""}`;
|
|
293
|
+
const deliver = (payload: Args): Args => {
|
|
294
|
+
const hash = sha256(canonical(payload));
|
|
295
|
+
if (input.after === cursor && seen.get(view) === hash) return { cursor, changed: false };
|
|
296
|
+
if (!seen.has(view) && seen.size >= MAX_REGISTERED_STUDIES + 2) seen.clear();
|
|
297
|
+
seen.set(view, hash);
|
|
298
|
+
return { ...payload, changed: true };
|
|
299
|
+
};
|
|
300
|
+
const registered = new Set(registry.studies); for (const dir of lastAt.keys()) if (!registered.has(dir)) lastAt.delete(dir);
|
|
301
|
+
if (input.panel === "inbox") targets.clear();
|
|
302
|
+
const studies: Args[] = [], details = new Map<string, Args>(), items: Item[] = [];
|
|
303
|
+
for (const dir of registry.studies) {
|
|
304
|
+
try { const row = loadStudy(dir, ctx.features ?? [], { panel: input.panel, study: str(input.study), id: str(input.id) }); studies.push(row.summary); if (row.detail) details.set(dir, row.detail); items.push(...row.items); if (input.panel === "inbox") for (const [id, target] of row.targets) targets.set(id, target); lastAt.set(dir, row.at); }
|
|
305
|
+
catch (cause) {
|
|
306
|
+
const key = sha256(dir).slice(0, 16), reason = error(cause);
|
|
307
|
+
let at = lastAt.get(dir) ?? EPOCH; try { at = stamp(readLedger(dir).at(-1)?.at, at); } catch { /* no readable ledger; keep stable fallback */ }
|
|
308
|
+
lastAt.set(dir, at);
|
|
309
|
+
const row = { id: key, dir, available: false, reason }; studies.push(row); details.set(dir, row);
|
|
310
|
+
items.push({ id: `unavailable:${key}`, group: "alert", kind: "unavailable", title: "Study unavailable", detail: `${reason}${at === EPOCH ? "; ledger time unavailable" : ""}`, project: key, at, verbs: [], link: { panel: "study", params: { study: dir } } });
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
if (input.panel === "inbox") return deliver({ cursor, items });
|
|
314
|
+
if (input.panel === "study") {
|
|
315
|
+
const selected = str(input.study) ?? registry.studies.find(dir => details.get(dir)?.id === str(input.id));
|
|
316
|
+
return deliver({ cursor, studies, study: selected ? details.get(selected) ?? null : null, ...(!selected ? { error: "Choose a registered study" } : !details.has(selected) ? { error: "Requested study is not registered; use ares console add or choose a registered study" } : {}) });
|
|
317
|
+
}
|
|
318
|
+
return deliver({ cursor, studies });
|
|
319
|
+
},
|
|
320
|
+
async action(input, meta) {
|
|
321
|
+
try {
|
|
322
|
+
validateActionInput(input);
|
|
323
|
+
const now = runtime.now ?? (() => performance.now()), deadline = now() + CONSOLE_ARCHIVE_MS;
|
|
324
|
+
let request = input;
|
|
325
|
+
if (input.study === undefined && typeof input.id === "string") {
|
|
326
|
+
const stored = targets.get(input.id);
|
|
327
|
+
if (!stored || !Array.isArray(stored.actions) || !stored.actions.includes(input.action)) refusal("inbox selection is stale or unavailable");
|
|
328
|
+
request = { ...input, ...stored };
|
|
329
|
+
}
|
|
330
|
+
if (input.action === "skill.accept" && request.pr === undefined && plain(input.answers) && plain(input.answers.pr)) request = { ...request, pr: input.answers.pr.other };
|
|
331
|
+
const target = selection(request, ctx.home);
|
|
332
|
+
if (input.confirmed !== true) return { ok: false, needsConfirm: true, ...boundAction(target) };
|
|
333
|
+
if (typeof input.fingerprint !== "string" || !/^[a-f0-9]{64}$/.test(input.fingerprint)) refusal("confirmation requires the current preview fingerprint");
|
|
334
|
+
const runner: ProcessRunner = request => {
|
|
335
|
+
const remaining = Math.floor(deadline - now());
|
|
336
|
+
if (remaining <= 0) return Promise.resolve({ exitCode: -1, stdout: "", stderr: "", timedOut: true });
|
|
337
|
+
return (runtime.runner ?? spawnRunner)({ ...request, timeoutMs: Math.min(request.timeoutMs ?? CONSOLE_ARCHIVE_MS, remaining) });
|
|
338
|
+
};
|
|
339
|
+
const actor = { kind: "person" as const, ...(meta ? { id: field(meta.identity, "authenticated identity", 500) } : {}) }, command = { actor, http, runner };
|
|
340
|
+
const archive = { confirm: "" };
|
|
341
|
+
const guard = () => {
|
|
342
|
+
let current: ReturnType<typeof boundAction>;
|
|
343
|
+
try { if (!readRegistry(ctx.home).studies.includes(target.study)) refusal("study is no longer registered"); current = boundAction(target); }
|
|
344
|
+
catch { refusal("stale confirmation: the selected study or bound content changed"); }
|
|
345
|
+
if (current.fingerprint !== input.fingerprint) refusal("stale confirmation: the bound content changed");
|
|
346
|
+
archive.confirm = current.archiveFingerprint ?? "";
|
|
347
|
+
};
|
|
348
|
+
if (target.action === "gate") await ops.gateDecide(target.study, target.gate!, target.decision as "approve" | "reject", target.reason, command, guard);
|
|
349
|
+
else if (target.action === "screen") await ops.screen(target.study, target.ids!, target.decision as "include" | "exclude", target.reason, command, guard);
|
|
350
|
+
else if (target.action === "skill.decide") await ops.skillDecide(target.study, target.name!, target.decision!, target.reason!, target.pr, command, guard);
|
|
351
|
+
else await ops.archive(target.study, archive, command, env, guard);
|
|
352
|
+
seen.clear(); return { ok: true, message: `${target.wireAction} recorded; refresh the study to inspect its ledger` };
|
|
353
|
+
} catch (cause) {
|
|
354
|
+
const message = cause instanceof ArchivePartialError
|
|
355
|
+
? line(`archive: ${cause.exportedCount} note${cause.exportedCount === 1 ? "" : "s"} exported and receipted; open a new archive preview and confirm it to export the rest; ${cause.failureMessage}`)
|
|
356
|
+
: error(cause);
|
|
357
|
+
return { ok: false, error: message };
|
|
358
|
+
}
|
|
359
|
+
},
|
|
360
|
+
async asset(input) {
|
|
361
|
+
if (!input.id.startsWith("manuscript:")) return null;
|
|
362
|
+
const id = input.id.slice("manuscript:".length), matches = readRegistry(ctx.home).studies.filter(dir => { try { return openStudy(dir).study.id === id; } catch { return false; } });
|
|
363
|
+
if (matches.length !== 1) return null;
|
|
364
|
+
try {
|
|
365
|
+
const dir = realpathSync(matches[0]!), file = join(dir, "paper", "build", "manuscript.md");
|
|
366
|
+
for (const part of [join(dir, "paper"), join(dir, "paper", "build"), file]) if (lstatSync(part).isSymbolicLink()) return null;
|
|
367
|
+
const built = readCurrentBuild(dir), bytes = readBytesBounded(file, ASSET_BYTES, { noFollow: true });
|
|
368
|
+
return sha256(bytes) === built.manuscriptSha256 ? { bytes, mime: "text/plain", name: "manuscript.md" } : null;
|
|
369
|
+
} catch { return null; }
|
|
370
|
+
},
|
|
371
|
+
};
|
|
372
|
+
},
|
|
373
|
+
}; }
|
|
374
|
+
export const plugin = createPlugin();
|
|
375
|
+
export default plugin;
|