@staix/agent-console 0.0.0-stage → 0.1.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 +49 -0
- package/LICENSE +21 -0
- package/README.md +354 -2
- package/bin/acon.js +8 -0
- package/package.json +38 -4
- package/shell/asset-viewer.css +5 -0
- package/shell/asset-viewer.html +11 -0
- package/shell/asset-viewer.js +23 -0
- package/shell/icon.svg +1 -0
- package/shell/index.html +53 -0
- package/shell/shell.css +534 -0
- package/shell/shell.js +2214 -0
- package/shell/space-view.js +164 -0
- package/shell/space.js +241 -0
- package/shell/space3d.js +364 -0
- package/shell/static.js +111 -0
- package/shell/vendor/BufferGeometryUtils.js +1523 -0
- package/shell/vendor/GLTFLoader.js +5006 -0
- package/shell/vendor/NOTICE +30 -0
- package/shell/vendor/SkeletonUtils.js +496 -0
- package/shell/vendor/manifest.json +42 -0
- package/shell/vendor/mermaid/NOTICE +25 -0
- package/shell/vendor/mermaid/manifest.json +19 -0
- package/shell/vendor/mermaid/mermaid.js +3580 -0
- package/shell/vendor/three.core.js +61162 -0
- package/shell/vendor/three.module.js +19537 -0
- package/shell/visual-frame.css +5 -0
- package/shell/visual-frame.html +14 -0
- package/shell/visual-frame.js +54 -0
- package/src/assets.ts +200 -0
- package/src/canonical.ts +16 -0
- package/src/cli.ts +246 -0
- package/src/conformance.ts +299 -0
- package/src/contract.ts +276 -0
- package/src/install.ts +248 -0
- package/src/pets.ts +63 -0
- package/src/plugins.ts +357 -0
- package/src/serve.ts +184 -0
- package/src/server.ts +462 -0
- package/src/setup.ts +74 -0
- package/src/space.ts +106 -0
- package/src/static.ts +102 -0
- package/test/fixtures/review/cases.json +726 -0
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtempSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { CONSOLE_FEATURES, type ConsolePlugin, type ConsoleProvider } from "./contract.ts";
|
|
6
|
+
import { ASSET_TYPES, assetResultProblems, manifestProblems, providerProblems, withTimeout } from "./plugins.ts";
|
|
7
|
+
import { spaceSceneProblems } from "./space.ts";
|
|
8
|
+
|
|
9
|
+
export { spaceSceneProblems } from "./space.ts";
|
|
10
|
+
|
|
11
|
+
export interface ConformanceOptions {
|
|
12
|
+
/** Family home passed to open(); a fresh temp directory by default. */
|
|
13
|
+
home?: string;
|
|
14
|
+
/** Actions that must answer needsConfirm when sent without confirmation. */
|
|
15
|
+
destructive?: ({ action: string } & Record<string, unknown>)[];
|
|
16
|
+
/** Asset ids that must answer a served type (provider.asset). */
|
|
17
|
+
assets?: string[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const GROUPS = ["decision", "review", "result", "candidate", "alert"];
|
|
21
|
+
/** What the console passes as action meta (#27), so a plugin that reads meta.identity is checked as it runs. */
|
|
22
|
+
const META = { identity: "local", node: "local", via: "console" } as const;
|
|
23
|
+
const within = (v: unknown, max: number) => typeof v === "string" && v.length > 0 && v.length <= max;
|
|
24
|
+
|
|
25
|
+
/** Problems with a verb's choice form against the #27 bounds; the shell skips an item whose form has any. */
|
|
26
|
+
export function verbFormProblems(form: unknown): string[] {
|
|
27
|
+
const f = form as { questions?: unknown } | null;
|
|
28
|
+
if (!f || typeof f !== "object" || !Array.isArray(f.questions)) return ["form.questions must be an array"];
|
|
29
|
+
const out: string[] = [];
|
|
30
|
+
if (f.questions.length < 1 || f.questions.length > 4) out.push("form needs 1-4 questions");
|
|
31
|
+
const ids = new Set<unknown>();
|
|
32
|
+
f.questions.forEach((q: Record<string, unknown> | null, i: number) => {
|
|
33
|
+
const at = `form.questions[${i}]`;
|
|
34
|
+
if (!q || typeof q !== "object") { out.push(`${at} must be an object`); return; }
|
|
35
|
+
if (!within(q.id, 64) || ids.has(q.id)) out.push(`${at}.id must be a unique string of at most 64 characters`);
|
|
36
|
+
ids.add(q.id);
|
|
37
|
+
if (q.header !== undefined && !within(q.header, 80)) out.push(`${at}.header must be at most 80 characters`);
|
|
38
|
+
if (!within(q.prompt, 1000)) out.push(`${at}.prompt must be 1-1000 characters`);
|
|
39
|
+
if (q.multiple !== undefined && typeof q.multiple !== "boolean") out.push(`${at}.multiple must be a boolean`);
|
|
40
|
+
const options = q.options as Record<string, unknown>[] | undefined;
|
|
41
|
+
if (!Array.isArray(options) || options.length < 2 || options.length > 8) out.push(`${at}.options needs 2-8 options`);
|
|
42
|
+
else {
|
|
43
|
+
const oids = new Set<unknown>();
|
|
44
|
+
options.forEach((o, j) => {
|
|
45
|
+
if (!o || !within(o.id, 64) || oids.has(o.id)) out.push(`${at}.options[${j}].id must be a unique string of at most 64 characters`);
|
|
46
|
+
oids.add(o?.id);
|
|
47
|
+
if (!o || !within(o.label, 80)) out.push(`${at}.options[${j}].label must be 1-80 characters`);
|
|
48
|
+
if (o?.description !== undefined && !within(o.description, 240)) out.push(`${at}.options[${j}].description must be at most 240 characters`);
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
const other = q.other as Record<string, unknown> | undefined;
|
|
52
|
+
if (other !== undefined) {
|
|
53
|
+
if (!other || typeof other !== "object") out.push(`${at}.other must be an object`);
|
|
54
|
+
else {
|
|
55
|
+
if (other.label !== undefined && !within(other.label, 80)) out.push(`${at}.other.label must be at most 80 characters`);
|
|
56
|
+
if (other.maxLength !== undefined && !(Number.isSafeInteger(other.maxLength) && (other.maxLength as number) >= 1 && (other.maxLength as number) <= 1000)) out.push(`${at}.other.maxLength must be 1-1000`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
const isText = (v: unknown) => typeof v === "string" && v.length > 0;
|
|
63
|
+
|
|
64
|
+
export function inboxItemProblems(item: unknown): string[] {
|
|
65
|
+
const i = item as Record<string, unknown> | null;
|
|
66
|
+
if (!i || typeof i !== "object") return ["item must be an object"];
|
|
67
|
+
const out: string[] = [];
|
|
68
|
+
for (const key of ["id", "kind", "title", "at"]) if (!isText(i[key])) out.push(`item.${key} must be a non-empty string`);
|
|
69
|
+
if (!GROUPS.includes(i.group as string)) out.push("item.group must be decision, review, result, candidate or alert");
|
|
70
|
+
if (typeof i.at === "string" && Number.isNaN(Date.parse(i.at))) out.push("item.at must be an ISO time");
|
|
71
|
+
if (!Array.isArray(i.verbs) || i.verbs.some((v) => !v || !isText(v.id) || !isText(v.label))) out.push("item.verbs must be {id, label}[]");
|
|
72
|
+
else {
|
|
73
|
+
if (i.verbs.some((v) => v.reason !== undefined && v.reason !== "optional" && v.reason !== "required")) out.push("item.verbs[].reason must be optional or required");
|
|
74
|
+
for (const v of i.verbs) if (v.form !== undefined) out.push(...verbFormProblems(v.form).map((p) => `verb ${String(v.id)}: ${p}`));
|
|
75
|
+
}
|
|
76
|
+
const seen = i.seen as Record<string, unknown> | undefined;
|
|
77
|
+
if (seen !== undefined && (!seen || !isText(seen.action))) out.push("item.seen.action must be a non-empty string");
|
|
78
|
+
const link = i.link as Record<string, unknown> | undefined;
|
|
79
|
+
if (!link || !isText(link.panel)) out.push("item.link.panel is required");
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Items present in both inbox snapshots whose `at` changed; snooze and oldest-first order key on `at`. */
|
|
84
|
+
export function stableAtProblems(first: unknown[], second: unknown[]): string[] {
|
|
85
|
+
const at = new Map<unknown, unknown>();
|
|
86
|
+
for (const item of first) if (item && typeof item === "object") at.set((item as { id?: unknown }).id, (item as { at?: unknown }).at);
|
|
87
|
+
const out: string[] = [];
|
|
88
|
+
for (const item of second) {
|
|
89
|
+
const { id, at: now } = (item ?? {}) as { id?: unknown; at?: unknown };
|
|
90
|
+
if (at.has(id) && at.get(id) !== now) out.push(`item ${String(id)}: at changed from ${String(at.get(id))} to ${String(now)} with nothing changed`);
|
|
91
|
+
}
|
|
92
|
+
return out;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Problems in a ui.table column list; panels and #29-style payloads can assert this is empty. */
|
|
96
|
+
export function tableColumnProblems(columns: unknown): string[] {
|
|
97
|
+
if (!Array.isArray(columns)) return ["columns must be an array"];
|
|
98
|
+
const out: string[] = [];
|
|
99
|
+
if (columns.length > 12) out.push(`columns: ${columns.length} exceeds 12; ui.table shows the first 12`);
|
|
100
|
+
const keys = new Set<unknown>();
|
|
101
|
+
columns.forEach((c, i) => {
|
|
102
|
+
const col = (c ?? {}) as Record<string, unknown>;
|
|
103
|
+
if (!isText(col.key)) out.push(`columns[${i}].key must be a non-empty string`);
|
|
104
|
+
else if (keys.has(col.key)) out.push(`columns[${i}].key ${String(col.key)} repeats`);
|
|
105
|
+
keys.add(col.key);
|
|
106
|
+
if (!isText(col.label)) out.push(`columns[${i}].label must be a non-empty string`);
|
|
107
|
+
if (col.align !== undefined && col.align !== "start" && col.align !== "end") out.push(`columns[${i}].align must be start or end`);
|
|
108
|
+
if (col.format !== undefined && !["text", "number", "percent"].includes(col.format as string)) out.push(`columns[${i}].format must be text, number or percent`);
|
|
109
|
+
});
|
|
110
|
+
return out;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Review bounds shared with agent-hub through test/fixtures/review/cases.json. */
|
|
114
|
+
export const REVIEW_BOUNDS = Object.freeze({ exact: 2000, context: 64, selectors: 4, threads: 200, body: 4000 });
|
|
115
|
+
const SHA256 = /^[0-9a-f]{64}$/;
|
|
116
|
+
const posInt = (v: unknown) => Number.isInteger(v) && (v as number) >= 1;
|
|
117
|
+
const finiteNum = (v: unknown) => typeof v === "number" && Number.isFinite(v);
|
|
118
|
+
|
|
119
|
+
function selectorProblems(s: unknown, at: string): string[] {
|
|
120
|
+
const x = (s ?? {}) as Record<string, unknown>;
|
|
121
|
+
if (!s || typeof s !== "object") return [`${at} must be an object`];
|
|
122
|
+
switch (x.kind) {
|
|
123
|
+
case "lineRange":
|
|
124
|
+
if (!posInt(x.startLine) || !posInt(x.endLine)) return [`${at}: startLine and endLine must be integers >= 1`];
|
|
125
|
+
return (x.startLine as number) > (x.endLine as number) ? [`${at}: startLine must not exceed endLine`] : [];
|
|
126
|
+
case "textQuote": {
|
|
127
|
+
const out: string[] = [];
|
|
128
|
+
if (typeof x.exact !== "string" || !x.exact) out.push(`${at}.exact must be a non-empty string`);
|
|
129
|
+
else if (x.exact.length > REVIEW_BOUNDS.exact) out.push(`${at}.exact is over ${REVIEW_BOUNDS.exact} characters`);
|
|
130
|
+
for (const k of ["prefix", "suffix"]) {
|
|
131
|
+
if (typeof x[k] !== "string") out.push(`${at}.${k} must be a string`);
|
|
132
|
+
else if ((x[k] as string).length > REVIEW_BOUNDS.context) out.push(`${at}.${k} is over ${REVIEW_BOUNDS.context} characters`);
|
|
133
|
+
}
|
|
134
|
+
return out;
|
|
135
|
+
}
|
|
136
|
+
case "diffHunk":
|
|
137
|
+
return typeof x.hunkHeader === "string" && x.hunkHeader.startsWith("@@") ? [] : [`${at}.hunkHeader must be a unified diff hunk header`];
|
|
138
|
+
case "structural":
|
|
139
|
+
return isText(x.scheme) && isText(x.ref) ? [] : [`${at}: scheme and ref must be non-empty strings`];
|
|
140
|
+
case "region": {
|
|
141
|
+
const out = ["x", "y", "width", "height"].filter((k) => !finiteNum(x[k]) || (x[k] as number) < 0).map((k) => `${at}.${k} must be a number >= 0`);
|
|
142
|
+
if (x.page !== undefined && !posInt(x.page)) out.push(`${at}.page must be an integer >= 1`);
|
|
143
|
+
return out;
|
|
144
|
+
}
|
|
145
|
+
default:
|
|
146
|
+
return [`${at}.kind must be lineRange, textQuote, diffHunk, structural or region`];
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Problems in one ReviewAnchor. */
|
|
151
|
+
export function reviewAnchorProblems(anchor: unknown): string[] {
|
|
152
|
+
const a = (anchor ?? {}) as Record<string, unknown>;
|
|
153
|
+
if (!anchor || typeof anchor !== "object" || Array.isArray(anchor)) return ["anchor must be an object"];
|
|
154
|
+
const out: string[] = [];
|
|
155
|
+
if (!isText(a.path)) out.push("anchor.path must be a non-empty string");
|
|
156
|
+
if (a.side !== undefined && a.side !== "base" && a.side !== "head") out.push("anchor.side must be base or head");
|
|
157
|
+
if (a.contentHash !== undefined && (typeof a.contentHash !== "string" || !SHA256.test(a.contentHash))) out.push("anchor.contentHash must be a lowercase hex sha256");
|
|
158
|
+
if (a.projection !== undefined) {
|
|
159
|
+
const p = (a.projection ?? {}) as Record<string, unknown>;
|
|
160
|
+
if (!a.projection || typeof a.projection !== "object" || !isText(p.name) || !isText(p.version)) out.push("anchor.projection must be {name, version}");
|
|
161
|
+
}
|
|
162
|
+
if (!Array.isArray(a.selectors) || !a.selectors.length) out.push("anchor.selectors must be a non-empty array");
|
|
163
|
+
else {
|
|
164
|
+
if (a.selectors.length > REVIEW_BOUNDS.selectors) out.push(`anchor.selectors has more than ${REVIEW_BOUNDS.selectors} selectors`);
|
|
165
|
+
a.selectors.forEach((s, i) => out.push(...selectorProblems(s, `anchor.selectors[${i}]`)));
|
|
166
|
+
}
|
|
167
|
+
return out;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Problems in one ReviewThread. */
|
|
171
|
+
export function reviewThreadProblems(thread: unknown): string[] {
|
|
172
|
+
const t = (thread ?? {}) as Record<string, unknown>;
|
|
173
|
+
if (!thread || typeof thread !== "object" || Array.isArray(thread)) return ["thread must be an object"];
|
|
174
|
+
const out: string[] = [];
|
|
175
|
+
if (!isText(t.id)) out.push("thread.id must be a non-empty string");
|
|
176
|
+
if (!isText(t.author)) out.push("thread.author must be a non-empty string");
|
|
177
|
+
if (typeof t.body !== "string" || !t.body) out.push("thread.body must be a non-empty string");
|
|
178
|
+
else if (t.body.length > REVIEW_BOUNDS.body) out.push(`thread.body is over ${REVIEW_BOUNDS.body} characters`);
|
|
179
|
+
if (typeof t.at !== "string" || Number.isNaN(Date.parse(t.at))) out.push("thread.at must be an ISO time");
|
|
180
|
+
if (t.state !== undefined && t.state !== "open" && t.state !== "resolved") out.push("thread.state must be open or resolved");
|
|
181
|
+
out.push(...reviewAnchorProblems(t.anchor).map((p) => `thread.${p}`));
|
|
182
|
+
return out;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Problems in the thread list a plugin passes to ui.review: shape, bounds and unique ids. */
|
|
186
|
+
export function reviewProblems(threads: unknown): string[] {
|
|
187
|
+
if (!Array.isArray(threads)) return ["threads must be an array"];
|
|
188
|
+
const out: string[] = [];
|
|
189
|
+
if (threads.length > REVIEW_BOUNDS.threads) out.push(`threads: ${threads.length} is over ${REVIEW_BOUNDS.threads}; ui.review shows the first ${REVIEW_BOUNDS.threads}`);
|
|
190
|
+
const ids = new Set<unknown>();
|
|
191
|
+
threads.forEach((t, i) => {
|
|
192
|
+
out.push(...reviewThreadProblems(t).map((p) => `threads[${i}]: ${p}`));
|
|
193
|
+
const id = (t as { id?: unknown } | null)?.id;
|
|
194
|
+
if (ids.has(id)) out.push(`threads[${i}]: id ${String(id)} repeats`);
|
|
195
|
+
ids.add(id);
|
|
196
|
+
});
|
|
197
|
+
return out;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Expands the compact forms in test/fixtures/review/cases.json: "<N x c>" strings and {repeat: {count, of, idPrefix}} lists. */
|
|
201
|
+
export function expandReviewCase(value: unknown): unknown {
|
|
202
|
+
if (typeof value === "string") {
|
|
203
|
+
const m = /^<(\d+) x (.)>$/u.exec(value);
|
|
204
|
+
return m ? m[2]!.repeat(Number(m[1])) : value;
|
|
205
|
+
}
|
|
206
|
+
if (Array.isArray(value)) return value.map(expandReviewCase);
|
|
207
|
+
if (value && typeof value === "object") return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, expandReviewCase(v)]));
|
|
208
|
+
return value;
|
|
209
|
+
}
|
|
210
|
+
export function reviewCaseValue(c: { value?: unknown; repeat?: { count: number; of: unknown; idPrefix: string } }): unknown {
|
|
211
|
+
if (c.repeat) return Array.from({ length: c.repeat.count }, (_, i) => ({ ...(expandReviewCase(c.repeat!.of) as object), id: `${c.repeat!.idPrefix}${i}` }));
|
|
212
|
+
return expandReviewCase(c.value);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Registers the api 1 conformance suite for a plugin under bun:test. */
|
|
216
|
+
export function registerConsoleConformance(plugin: ConsolePlugin, options: ConformanceOptions = {}): void {
|
|
217
|
+
const tool = plugin.manifest?.tool ?? "plugin";
|
|
218
|
+
describe(`console conformance: ${tool}`, () => {
|
|
219
|
+
let provider: ConsoleProvider;
|
|
220
|
+
beforeAll(async () => {
|
|
221
|
+
const home = options.home ?? mkdtempSync(join(tmpdir(), "acon-conf-"));
|
|
222
|
+
provider = await withTimeout(plugin.open({ home, log: () => {}, features: [...CONSOLE_FEATURES] }), 5_000, "open()");
|
|
223
|
+
});
|
|
224
|
+
afterAll(async () => { await provider?.close?.(); });
|
|
225
|
+
|
|
226
|
+
test("manifest is valid JSON-serializable data", () => {
|
|
227
|
+
expect(manifestProblems(plugin.manifest)).toEqual([]);
|
|
228
|
+
expect(() => JSON.stringify(plugin.manifest)).not.toThrow();
|
|
229
|
+
});
|
|
230
|
+
test("open() resolves to a provider", () => expect(providerProblems(provider)).toEqual([]));
|
|
231
|
+
test("every panel answers a snapshot within 5 s", async () => {
|
|
232
|
+
for (const panel of plugin.manifest.panels) {
|
|
233
|
+
const result = await withTimeout(provider.snapshot({ after: 0, panel: panel.id }), 5_000, `snapshot ${panel.id}`);
|
|
234
|
+
expect(result && typeof result === "object" && !Array.isArray(result)).toBe(true);
|
|
235
|
+
}
|
|
236
|
+
});
|
|
237
|
+
if (plugin.manifest.capabilities?.inbox) test("inbox items have the InboxItem shape", async () => {
|
|
238
|
+
const result = await provider.snapshot({ after: 0, panel: "inbox" });
|
|
239
|
+
expect(Array.isArray(result.items)).toBe(true);
|
|
240
|
+
for (const item of result.items as unknown[]) expect(inboxItemProblems(item)).toEqual([]);
|
|
241
|
+
});
|
|
242
|
+
if (plugin.manifest.capabilities?.inbox) test("a console without features gets no result items or verb forms", async () => {
|
|
243
|
+
// An older console (no ConsoleContext.features) reports both as malformed; a plugin must check the features.
|
|
244
|
+
const plain = await withTimeout(plugin.open({ home: options.home ?? mkdtempSync(join(tmpdir(), "acon-conf-")), log: () => {} }), 5_000, "open()");
|
|
245
|
+
try {
|
|
246
|
+
const result = await plain.snapshot({ after: 0, panel: "inbox" });
|
|
247
|
+
const items = (Array.isArray(result.items) ? result.items : []) as { id?: unknown; group?: unknown; verbs?: { form?: unknown }[] }[];
|
|
248
|
+
expect(items.filter((i) => i.group === "result" || i.verbs?.some((v) => v.form !== undefined)).map((i) => String(i.id))).toEqual([]);
|
|
249
|
+
} finally { await plain.close?.(); }
|
|
250
|
+
});
|
|
251
|
+
if (plugin.manifest.capabilities?.inbox) test("inbox at is stable across snapshots", async () => {
|
|
252
|
+
// Copy before the next call: a provider may reuse and mutate the same item objects.
|
|
253
|
+
const first = structuredClone(await provider.snapshot({ after: 0, panel: "inbox" }));
|
|
254
|
+
await Bun.sleep(50);
|
|
255
|
+
const second = await provider.snapshot({ after: 0, panel: "inbox" });
|
|
256
|
+
expect(stableAtProblems(first.items as unknown[], second.items as unknown[])).toEqual([]);
|
|
257
|
+
});
|
|
258
|
+
if (plugin.manifest.capabilities?.search) test("search answers hits and total", async () => {
|
|
259
|
+
const result = await provider.snapshot({ after: 0, panel: "search", q: "a", limit: 10 });
|
|
260
|
+
expect(Array.isArray(result.hits)).toBe(true);
|
|
261
|
+
expect(typeof result.total).toBe("number");
|
|
262
|
+
});
|
|
263
|
+
if (plugin.manifest.capabilities?.space) test("space answers a public scene within 5 s", async () => {
|
|
264
|
+
const result = await withTimeout(provider.snapshot({ after: 0, panel: "space" }), 5_000, "space snapshot");
|
|
265
|
+
expect(spaceSceneProblems(result.scene)).toEqual([]);
|
|
266
|
+
});
|
|
267
|
+
test("asset, when present, answers null for an unknown id within 5 s", async () => {
|
|
268
|
+
if (typeof provider.asset !== "function") return;
|
|
269
|
+
const result = await withTimeout(provider.asset({ id: "acon-conformance-unknown" }), 5_000, "asset");
|
|
270
|
+
expect(result).toBeNull();
|
|
271
|
+
});
|
|
272
|
+
for (const id of options.assets ?? []) test(`asset ${id} is a served type with bytes and a name`, async () => {
|
|
273
|
+
expect(typeof provider.asset).toBe("function");
|
|
274
|
+
const result = await withTimeout(provider.asset!({ id }), 5_000, `asset ${id}`);
|
|
275
|
+
expect(assetResultProblems(result)).toEqual([]);
|
|
276
|
+
expect(ASSET_TYPES.has(result!.mime)).toBe(true);
|
|
277
|
+
});
|
|
278
|
+
test("an unknown action answers ok: false", async () => {
|
|
279
|
+
const result = await provider.action({ action: "acon-conformance-unknown" }, META);
|
|
280
|
+
expect(result.ok).toBe(false);
|
|
281
|
+
});
|
|
282
|
+
for (const input of options.destructive ?? []) test(`${input.action} needs confirmation`, async () => {
|
|
283
|
+
const result = await provider.action(input, META);
|
|
284
|
+
expect(result.ok).toBe(false);
|
|
285
|
+
expect("needsConfirm" in result && result.needsConfirm).toBe(true);
|
|
286
|
+
if ("needsConfirm" in result) {
|
|
287
|
+
expect(typeof result.fingerprint === "string" && result.fingerprint.length > 0).toBe(true);
|
|
288
|
+
expect(Array.isArray(result.preview)).toBe(true);
|
|
289
|
+
}
|
|
290
|
+
});
|
|
291
|
+
test("panel.js is an ES module with a default export", () => {
|
|
292
|
+
const scan = new Bun.Transpiler({ loader: "js" }).scan(provider.assets.js);
|
|
293
|
+
expect(scan.exports).toContain("default");
|
|
294
|
+
});
|
|
295
|
+
test("panel css loads nothing remote", () => {
|
|
296
|
+
expect(/@import|url\(\s*["']?(https?:)?\/\//i.test(provider.assets.css ?? "")).toBe(false);
|
|
297
|
+
});
|
|
298
|
+
});
|
|
299
|
+
}
|
package/src/contract.ts
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
/// <reference lib="dom" />
|
|
2
|
+
/**
|
|
3
|
+
* Console plugin contract, api 1. Changes are additive only.
|
|
4
|
+
* Plan of record: entelecheia/ai-workspace#60 §2.3; UX additions: STAIxBWLB/vault-mem#48.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export const CONSOLE_API = 1 as const;
|
|
8
|
+
/** The api range this console speaks (#26). A plugin loads when its [apiMin, apiMax] overlaps it. */
|
|
9
|
+
export const CONSOLE_API_MIN = 1;
|
|
10
|
+
export const CONSOLE_API_MAX = 1;
|
|
11
|
+
export const TOOL_ID = /^[a-z][a-z0-9-]{1,15}$/;
|
|
12
|
+
export const PANEL_ID = /^[a-z][a-z0-9-]{0,31}$/;
|
|
13
|
+
export const ACTION_ID = /^[a-z][a-z0-9_.:-]{0,63}$/;
|
|
14
|
+
|
|
15
|
+
export interface ConsolePanelManifest {
|
|
16
|
+
api: 1;
|
|
17
|
+
/** The api range the plugin works with; each defaults to `api` (#26). */
|
|
18
|
+
apiMin?: number;
|
|
19
|
+
apiMax?: number;
|
|
20
|
+
tool: string;
|
|
21
|
+
version: string;
|
|
22
|
+
title: string;
|
|
23
|
+
/** Area icon name the shell knows (agents, memory, vault, ...); unknown names fall back to a generic glyph. */
|
|
24
|
+
icon?: string;
|
|
25
|
+
panels: { id: string; title: string; icon?: string; order?: number }[];
|
|
26
|
+
/** Palette entries; each runs action({...input, action}) through the same confirm path as a verb. */
|
|
27
|
+
commands?: ConsoleCommand[];
|
|
28
|
+
/** Poll interval for the visible panel; the shell clamps it to 2-60 s. */
|
|
29
|
+
pollMs?: number;
|
|
30
|
+
capabilities?: {
|
|
31
|
+
actions?: string[];
|
|
32
|
+
confirm?: boolean;
|
|
33
|
+
multiProject?: boolean;
|
|
34
|
+
/** snapshot({panel: "inbox", after}) returns {items: InboxItem[]}. */
|
|
35
|
+
inbox?: boolean;
|
|
36
|
+
/** snapshot({panel: "search", q, limit}) returns {hits: SearchHit[], total}. */
|
|
37
|
+
search?: boolean;
|
|
38
|
+
/** snapshot({panel: "space", after}) returns {scene: SpaceScene}, public fields only. */
|
|
39
|
+
space?: boolean;
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface ConsoleCommand {
|
|
44
|
+
id: string;
|
|
45
|
+
title: string;
|
|
46
|
+
hint?: string;
|
|
47
|
+
action: string;
|
|
48
|
+
/** Fixed action input; it may not set action, confirmed, fingerprint or confirmToken. */
|
|
49
|
+
input?: Record<string, unknown>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface ConsoleContext {
|
|
53
|
+
/** Family home ($AGENTHUB_HOME or ~/.agenthub). */
|
|
54
|
+
home: string;
|
|
55
|
+
log(line: string): void;
|
|
56
|
+
/**
|
|
57
|
+
* What this console renders beyond api 1's base (#27); set by the console, absent in older consoles. Send verb
|
|
58
|
+
* `form`s only with "inbox.form" and `result` items only with "inbox.result"; "action.meta" means action() gets `meta`.
|
|
59
|
+
*/
|
|
60
|
+
features?: readonly string[];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Features this console advertises in ConsoleContext.features. */
|
|
64
|
+
export const CONSOLE_FEATURES = ["inbox.form", "inbox.result", "action.meta"] as const;
|
|
65
|
+
|
|
66
|
+
/** Who acted, built by the console from the verified session; the page cannot set it (#27). */
|
|
67
|
+
export interface ActionMeta {
|
|
68
|
+
/** "local" under `acon ui`; under `acon serve` "access:<subject>", "bearer" or "loopback", as authenticated. */
|
|
69
|
+
identity: string;
|
|
70
|
+
node: string;
|
|
71
|
+
via: "console";
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface ConsolePlugin {
|
|
75
|
+
manifest: ConsolePanelManifest;
|
|
76
|
+
open(ctx: ConsoleContext): Promise<ConsoleProvider>;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface ConsoleProvider {
|
|
80
|
+
/** panel.js is an ES module: `export default (host) => ({ show(panelId, params), hide(), destroy() })`. */
|
|
81
|
+
assets: { js: string; css?: string };
|
|
82
|
+
snapshot(input: { after: number; panel: string } & Record<string, unknown>): Promise<Record<string, unknown>>;
|
|
83
|
+
/** `meta` is present when the console advertises "action.meta"; tools use it as evidence of who acted, under their own rules. */
|
|
84
|
+
action(input: { action: string; confirmed?: true; fingerprint?: string } & Record<string, unknown>, meta?: ActionMeta): Promise<ActionResult>;
|
|
85
|
+
/** Optional file for GET /p/<tool>/asset?id=; null when the id is unknown. Types: see README "Plugin assets". */
|
|
86
|
+
asset?(input: { id: string }): Promise<{ bytes: Uint8Array; mime: string; name: string } | null>;
|
|
87
|
+
close?(): Promise<void>;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface Change { op: "create" | "modify" | "delete" | "log"; target: string; note?: string }
|
|
91
|
+
|
|
92
|
+
export type ActionResult =
|
|
93
|
+
| ({ ok: true; message?: string; undo?: { action: string } & Record<string, unknown> } & Record<string, unknown>)
|
|
94
|
+
| { ok: false; error: string; unconfirmed?: true }
|
|
95
|
+
| {
|
|
96
|
+
ok: false;
|
|
97
|
+
needsConfirm: true;
|
|
98
|
+
preview: string[];
|
|
99
|
+
fingerprint: string;
|
|
100
|
+
/** Verb shown on the confirm button ("Promote note"). */
|
|
101
|
+
verb?: string;
|
|
102
|
+
changes?: Change[];
|
|
103
|
+
warnings?: string[];
|
|
104
|
+
/** Authority-raising settings: the person types this text to confirm. */
|
|
105
|
+
confirmText?: string;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
/** "result" needs the "inbox.result" feature (#27); an older console reports it as malformed. */
|
|
109
|
+
export type InboxGroup = "decision" | "review" | "result" | "candidate" | "alert";
|
|
110
|
+
export type Tone = "ok" | "warn" | "err" | "neutral";
|
|
111
|
+
|
|
112
|
+
export interface InboxItem {
|
|
113
|
+
id: string;
|
|
114
|
+
group: InboxGroup;
|
|
115
|
+
kind: string;
|
|
116
|
+
title: string;
|
|
117
|
+
detail?: string;
|
|
118
|
+
actor?: string;
|
|
119
|
+
project?: string;
|
|
120
|
+
/** ISO time the item started waiting. Keep it stable across snapshots: snooze and oldest-first order key on it. */
|
|
121
|
+
at: string;
|
|
122
|
+
chip?: { text: string; tone: Tone };
|
|
123
|
+
/** With `reason`, the shell asks for a reason and sends it as `reason` in the action input; "required" refuses an empty one. */
|
|
124
|
+
verbs: InboxVerb[];
|
|
125
|
+
link: { panel: string; params?: Record<string, string> };
|
|
126
|
+
/**
|
|
127
|
+
* For `result` items: the shell runs action({action, id, seenAt}) once per page session after the result body was
|
|
128
|
+
* in view for 1 s in a focused, visible window, and with `explicit: true` from "Mark all read".
|
|
129
|
+
*/
|
|
130
|
+
seen?: { action: string };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export interface InboxVerb {
|
|
134
|
+
id: string; label: string; primary?: boolean; destructive?: boolean; reason?: "optional" | "required";
|
|
135
|
+
/** Choice questions (needs "inbox.form"); submit sends action({action: id, id: item.id, answers}). */
|
|
136
|
+
form?: VerbForm;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Bounds: 1-4 questions, 2-8 options, label <= 80, description <= 240, prompt <= 1000, other.maxLength <= 1000 (500). */
|
|
140
|
+
export interface VerbForm {
|
|
141
|
+
questions: {
|
|
142
|
+
id: string; header?: string; prompt: string;
|
|
143
|
+
options: { id: string; label: string; description?: string }[];
|
|
144
|
+
multiple?: boolean;
|
|
145
|
+
other?: { label?: string; maxLength?: number };
|
|
146
|
+
}[];
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Answers by question id: chosen option ids, and the "Other" text when given. */
|
|
150
|
+
export type InboxAnswers = Record<string, { selected: string[]; other?: string }>;
|
|
151
|
+
|
|
152
|
+
export interface SearchHit {
|
|
153
|
+
id: string;
|
|
154
|
+
kind: string;
|
|
155
|
+
title: string;
|
|
156
|
+
detail?: string;
|
|
157
|
+
at?: string;
|
|
158
|
+
link: { panel: string; params?: Record<string, string> };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Public collaboration facts, never message bodies, task details, paths or symbols. */
|
|
162
|
+
export interface SpaceScene {
|
|
163
|
+
actors: {
|
|
164
|
+
id: string; name: string; state: "idle" | "working" | "waiting" | "blocked" | "away";
|
|
165
|
+
pet?: string; card?: string;
|
|
166
|
+
marks?: { kind: "approval" | "inbox" | "paused" | "mode"; text: string }[];
|
|
167
|
+
link: { panel: string; params?: Record<string, string> };
|
|
168
|
+
}[];
|
|
169
|
+
zones: { id: string; title: string; kind?: "queue" | "work" | "review" | "done"; count: number }[];
|
|
170
|
+
cards: {
|
|
171
|
+
id: string; title: string; zone: string; owner?: string; tone?: Tone;
|
|
172
|
+
link: { panel: string; params?: Record<string, string> };
|
|
173
|
+
}[];
|
|
174
|
+
links?: { kind: "message" | "overlap" | "conflict" | "cohort"; from: string; to: string; label?: string; at?: string }[];
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** A ui.table column. Cells render as text; `number` and `percent` format numeric cells, and `percent` expects percent units (38.5, not 0.385). */
|
|
178
|
+
export interface TableColumn { key: string; label: string; align?: "start" | "end"; format?: "text" | "number" | "percent" }
|
|
179
|
+
|
|
180
|
+
/** Where a review comment points. The kit creates lineRange, textQuote and diffHunk; it only displays the others. */
|
|
181
|
+
export type ReviewSelector =
|
|
182
|
+
| { kind: "lineRange"; startLine: number; endLine: number }
|
|
183
|
+
| { kind: "textQuote"; exact: string; prefix: string; suffix: string }
|
|
184
|
+
| { kind: "diffHunk"; hunkHeader: string }
|
|
185
|
+
| { kind: "structural"; scheme: string; ref: string }
|
|
186
|
+
| { kind: "region"; x: number; y: number; width: number; height: number; page?: number };
|
|
187
|
+
/**
|
|
188
|
+
* A review anchor, stored unchanged by the owning tool. `contentHash` is the lowercase hex sha256 of the text the
|
|
189
|
+
* anchor was made against: the projected text when `projection` is set, else the raw file bytes.
|
|
190
|
+
* Bounds: exact <= 2000 chars, prefix/suffix <= 64, 1-4 selectors. Shared cases: test/fixtures/review/cases.json.
|
|
191
|
+
*/
|
|
192
|
+
export interface ReviewAnchor {
|
|
193
|
+
path: string;
|
|
194
|
+
side?: "base" | "head";
|
|
195
|
+
contentHash?: string;
|
|
196
|
+
projection?: { name: string; version: string };
|
|
197
|
+
selectors: ReviewSelector[];
|
|
198
|
+
}
|
|
199
|
+
/** One comment thread on an anchor; body <= 4000 chars, at most 200 threads per view. */
|
|
200
|
+
export interface ReviewThread { id: string; anchor: ReviewAnchor; body: string; author: string; at: string; state?: "open" | "resolved" }
|
|
201
|
+
/** The document under review: plain text, prose (paragraphs split on blank lines, Markdown shown as source) or a unified diff. */
|
|
202
|
+
export type ReviewDoc =
|
|
203
|
+
| { kind: "text" | "prose"; path: string; text: string; contentHash: string; projection?: { name: string; version: string } }
|
|
204
|
+
| { kind: "diff"; path: string; unified: string; contentHash: string };
|
|
205
|
+
/** One placement result reported by ui.review; the tool's stored anchor stays the authority. */
|
|
206
|
+
export interface ReviewPlacement { threadId: string; placed: boolean; via: "hash" | "quote" | null }
|
|
207
|
+
|
|
208
|
+
/** One side of a before/after visual: text, or this plugin's asset id (#24) with alt text. */
|
|
209
|
+
export type VisualSide = { text: string } | { asset: string; alt: string };
|
|
210
|
+
/**
|
|
211
|
+
* A visual a plugin shows with ui.visual. Mermaid renders only in a sandboxed frame; tables use ui.table; asset
|
|
212
|
+
* sides use ui.img. Bounds: mermaid source <= 20 KB; comparison 2-6 options with at most 10 pros and 10 cons each.
|
|
213
|
+
*/
|
|
214
|
+
export type VisualPayload =
|
|
215
|
+
| { kind: "mermaid"; source: string; title?: string }
|
|
216
|
+
| { kind: "comparison"; title?: string; options: { title: string; summary?: string; pros?: string[]; cons?: string[]; recommended?: boolean }[] }
|
|
217
|
+
| { kind: "table"; caption?: string; columns: TableColumn[]; rows: Record<string, string | number>[] }
|
|
218
|
+
| { kind: "beforeAfter"; caption?: string; before: VisualSide; after: VisualSide };
|
|
219
|
+
|
|
220
|
+
/** Text-only DOM kit supplied by the shell. Classes and tokens are documented in README. */
|
|
221
|
+
export interface ConsoleUi {
|
|
222
|
+
h(tag: string, props?: Record<string, unknown>, ...kids: (Node | string | null | false)[]): HTMLElement;
|
|
223
|
+
rows<T>(items: T[], render: (item: T) => HTMLElement): HTMLElement | null;
|
|
224
|
+
row(o: { title: string; sub?: string; onOpen?: () => void; chip?: { text: string; tone?: Tone }; verbs?: HTMLElement[] }): HTMLElement;
|
|
225
|
+
empty(title: string, hint?: string): HTMLElement;
|
|
226
|
+
chip(text: string, tone?: Tone): HTMLElement;
|
|
227
|
+
/** A `button.btn` with `type="button"`; `tone` adds the `primary` or `danger` modifier. */
|
|
228
|
+
button(label: string, onClick: () => void, o?: { tone?: "primary" | "danger"; disabled?: boolean }): HTMLButtonElement;
|
|
229
|
+
/** `err` is announced assertively (`role="alert"`); other tones are polite (`role="status"`). */
|
|
230
|
+
notice(text: string, tone?: Tone): HTMLElement;
|
|
231
|
+
form(fields: { name: string; label: string; required?: boolean; value?: string }[], submit: { label: string; primary?: boolean }, onSubmit: (values: Record<string, string>) => void): HTMLFormElement;
|
|
232
|
+
detail(o: {
|
|
233
|
+
kind: string; title: string; state?: { text: string; tone?: Tone }; whoseMove?: "you" | "agent";
|
|
234
|
+
actions?: HTMLElement[]; summary?: string; provenance?: { label: string; value: string; at?: string }[];
|
|
235
|
+
sections?: { title: string; body: Node | string; open?: boolean }[]; footer?: string[];
|
|
236
|
+
}): HTMLElement;
|
|
237
|
+
/** At most 500 rows and 12 columns; extra input is cut with a notice. Sortable unless `sortable: false`. */
|
|
238
|
+
table(o: { columns: TableColumn[]; rows: Record<string, string | number>[]; caption?: string; sortable?: boolean }): HTMLElement;
|
|
239
|
+
/** Horizontal bars with an optional target marker per item; at most 50 items. */
|
|
240
|
+
bars(o: { items: { label: string; value: number; target?: number }[]; max?: number; caption: string }): HTMLElement;
|
|
241
|
+
/** A small line of the last 365 values at most. */
|
|
242
|
+
spark(o: { values: number[]; caption: string }): HTMLElement;
|
|
243
|
+
/** An image of this plugin's asset `id` (PNG or JPEG). Only asset ids are accepted, never URLs. */
|
|
244
|
+
img(o: { asset: string; alt: string }): HTMLElement;
|
|
245
|
+
/** A same-origin frame of this plugin's asset `id`, for PDF and text. */
|
|
246
|
+
frame(o: { asset: string; title: string }): HTMLElement;
|
|
247
|
+
/** A link to this plugin's asset or to one of its panels; any other target throws TypeError. */
|
|
248
|
+
link(to: { asset: string } | { panel: string; params?: Record<string, string> }, text: string): HTMLElement;
|
|
249
|
+
/** Line-numbered review of a text, prose or diff document: select a range, comment on it, see placed and unplaced threads. */
|
|
250
|
+
review(o: {
|
|
251
|
+
doc: ReviewDoc; threads: ReviewThread[];
|
|
252
|
+
onComment(c: { anchor: ReviewAnchor; body: string }): void | Promise<unknown>;
|
|
253
|
+
onPlacement?(results: ReviewPlacement[]): void;
|
|
254
|
+
}): HTMLElement;
|
|
255
|
+
/** A diagram (sandboxed Mermaid frame), comparison cards, a table or a before/after pair; failures show the source. */
|
|
256
|
+
visual(payload: VisualPayload): HTMLElement;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Browser-side host each panel module receives. Declared for panel authors; the shell implements it. */
|
|
260
|
+
export interface ConsoleHost {
|
|
261
|
+
tool: string;
|
|
262
|
+
node: string;
|
|
263
|
+
mount: HTMLElement;
|
|
264
|
+
ui: ConsoleUi;
|
|
265
|
+
theme: { mode: "light" | "dark"; tokens: Record<string, string> };
|
|
266
|
+
snapshot(extra?: Record<string, unknown>): Promise<Record<string, unknown>>;
|
|
267
|
+
action(input: { action: string } & Record<string, unknown>): Promise<ActionResult | ({ ok: true } & Record<string, unknown>)>;
|
|
268
|
+
navigate(tool: string, panel: string, params?: Record<string, string>): void;
|
|
269
|
+
onSnapshot(cb: (snapshot: Record<string, unknown>) => void): void;
|
|
270
|
+
onTheme(cb: (theme: ConsoleHost["theme"]) => void): void;
|
|
271
|
+
toast(text: string, options?: { undo?: () => void }): void;
|
|
272
|
+
sheet: { open(title: string): HTMLElement; close(): void };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Manifest file written by a tool's setup under $AGENTHUB_HOME/console/plugins/<tool>.json. */
|
|
276
|
+
export interface PluginManifestFile { api: 1; tool: string; module: string; sha256: string }
|