aiki-cli 0.3.2 → 0.3.3
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 +68 -0
- package/README.md +64 -4
- package/dist/bench/idea-v3-bench.js +104 -5
- package/dist/bench/idea-v3-rating.js +158 -3
- package/dist/cli/bench.js +5 -5
- package/dist/cli/index.js +12 -2
- package/dist/cli/resume.js +20 -0
- package/dist/cli/run.js +75 -4
- package/dist/cli/serve.js +48 -0
- package/dist/config/config.js +7 -2
- package/dist/council/view.js +13 -4
- package/dist/orchestration/auto-profile.js +97 -0
- package/dist/orchestration/claim-groups.js +56 -0
- package/dist/orchestration/context.js +66 -3
- package/dist/orchestration/decision-dossier.js +42 -10
- package/dist/orchestration/decision-graph.js +2 -2
- package/dist/orchestration/engine.js +8 -4
- package/dist/orchestration/evidence-origin.js +17 -0
- package/dist/orchestration/jsonStage.js +47 -5
- package/dist/orchestration/modes.js +4 -2
- package/dist/orchestration/preflight.js +19 -0
- package/dist/orchestration/quick-analysis.js +8 -3
- package/dist/orchestration/sanitize-paths.js +10 -0
- package/dist/orchestration/stages/s10-render.js +31 -7
- package/dist/orchestration/stages/s4-analyze.js +7 -1
- package/dist/orchestration/stages/s4b-challenge.js +97 -0
- package/dist/orchestration/stages/s5-drift.js +13 -3
- package/dist/orchestration/stages/s8-verify.js +44 -7
- package/dist/orchestration/stages/s9-judge.js +20 -5
- package/dist/orchestration/stages/s9b-plan.js +44 -15
- package/dist/orchestration/url-sources.js +21 -0
- package/dist/providers/adapter-core.js +1 -1
- package/dist/providers/claude.js +18 -0
- package/dist/schemas/index.js +45 -0
- package/dist/serve/flight-deck.js +830 -0
- package/dist/serve/followup.js +50 -0
- package/dist/serve/frames.js +168 -0
- package/dist/serve/gates.js +72 -0
- package/dist/serve/projections.js +283 -0
- package/dist/serve/server.js +219 -0
- package/dist/serve/threads.js +145 -0
- package/dist/serve-ui/Five.png +0 -0
- package/dist/serve-ui/app.js +820 -0
- package/dist/serve-ui/index.html +171 -0
- package/dist/serve-ui/workspace.css +662 -0
- package/dist/storage/runs.js +2 -1
- package/dist/workflows/idea-refinement.js +94 -16
- package/package.json +2 -2
|
@@ -0,0 +1,820 @@
|
|
|
1
|
+
// aiki serve workspace — dependency-free dark-council client. The browser receives structured
|
|
2
|
+
// frames only; model prose appears solely in the reader-safe report projection and follow-ups.
|
|
3
|
+
|
|
4
|
+
const DECK_TOKEN = document.querySelector('meta[name="deck-token"]')?.content ?? '';
|
|
5
|
+
const $ = (sel, root = document) => root.querySelector(sel);
|
|
6
|
+
const el = (tag, cls, text) => {
|
|
7
|
+
const node = document.createElement(tag);
|
|
8
|
+
if (cls) node.className = cls;
|
|
9
|
+
if (text != null) node.textContent = text;
|
|
10
|
+
return node;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
async function api(path, opts = {}) {
|
|
14
|
+
const headers = { ...(opts.headers ?? {}) };
|
|
15
|
+
if (opts.method === 'POST' || opts.method === 'PATCH') {
|
|
16
|
+
headers['content-type'] = 'application/json';
|
|
17
|
+
headers['x-deck-token'] = DECK_TOKEN;
|
|
18
|
+
}
|
|
19
|
+
const res = await fetch(path, { ...opts, headers });
|
|
20
|
+
const body = await res.json().catch(() => ({}));
|
|
21
|
+
if (!res.ok) throw new Error(body.error ?? `${path} → ${res.status}`);
|
|
22
|
+
return body;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const DISPLAY = { claude: 'Claude', codex: 'Codex', agy: 'Gemini' };
|
|
26
|
+
const DEFAULT_ROLE = { claude: 'judge', codex: 'verifier', agy: 'analyst' };
|
|
27
|
+
const STATUS_GLYPH = { running: '●', complete: '✓', failed: '⚠', cancelled: '◌' };
|
|
28
|
+
const state = {
|
|
29
|
+
settings: null, providers: [], threadId: null, runId: null, events: null,
|
|
30
|
+
attachments: [], gates: new Map(), turnKeys: new Set(), helloLastSeq: 0,
|
|
31
|
+
calls: 0, budget: 1, active: false, canFollowup: false, runKind: 'decision',
|
|
32
|
+
provCalls: {}, provInflight: {}, runStart: null, stageLabel: 'working',
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
// ── shell reads ───────────────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
function renderProviders(providers) {
|
|
38
|
+
state.providers = providers;
|
|
39
|
+
const list = $('#provider-list');
|
|
40
|
+
list.replaceChildren();
|
|
41
|
+
for (const p of providers) {
|
|
42
|
+
const li = el('li', 'provider');
|
|
43
|
+
li.dataset.seat = p.id;
|
|
44
|
+
li.title = p.fix ? `${p.label} — ${p.fix}` : p.label;
|
|
45
|
+
const dot = el('span', 'provider__dot'); dot.dataset.tone = p.tone;
|
|
46
|
+
const main = el('div', 'provider__main');
|
|
47
|
+
main.append(el('div', 'provider__name', p.name), el('div', 'provider__model', p.model ?? 'CLI default'));
|
|
48
|
+
const status = el('div', 'provider__status', p.label); status.dataset.tone = p.tone;
|
|
49
|
+
li.append(dot, main, status);
|
|
50
|
+
list.append(li);
|
|
51
|
+
}
|
|
52
|
+
renderHeaderModels(providers);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function renderHeaderModels(providers) {
|
|
56
|
+
const root = $('#header-models');
|
|
57
|
+
if (!root) return;
|
|
58
|
+
root.replaceChildren();
|
|
59
|
+
for (const p of providers) {
|
|
60
|
+
const avatar = el('span', 'avatar', p.name[0]);
|
|
61
|
+
avatar.dataset.seat = p.id;
|
|
62
|
+
avatar.title = `${p.name} · ${p.model ?? 'CLI default'}`;
|
|
63
|
+
root.append(avatar);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function renderQuorum(q) {
|
|
68
|
+
$('.lamp', $('#quorum')).dataset.tone = q.tone;
|
|
69
|
+
$('.lamp', $('#deck-toggle')).dataset.tone = q.tone;
|
|
70
|
+
$('.quorum__label', $('#quorum')).textContent = q.label;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// The council seat cards (right deck) — model tiles with live status, progress and activity.
|
|
74
|
+
function renderRoster(providers) {
|
|
75
|
+
const roster = $('#seat-roster');
|
|
76
|
+
if (!roster) return;
|
|
77
|
+
roster.replaceChildren();
|
|
78
|
+
const pinned = state.settings?.roles ?? {};
|
|
79
|
+
state.provCalls = {}; state.provInflight = {};
|
|
80
|
+
for (const p of providers) {
|
|
81
|
+
state.provCalls[p.id] = 0; state.provInflight[p.id] = 0;
|
|
82
|
+
const seat = el('li', 'seat'); seat.dataset.seat = p.id; seat.dataset.busy = 'false';
|
|
83
|
+
const row = el('div', 'seat__row');
|
|
84
|
+
row.append(el('span', 'seat__glyph', p.name[0]));
|
|
85
|
+
const id = el('div', 'seat__id');
|
|
86
|
+
const top = el('div', 'seat__top');
|
|
87
|
+
top.append(el('span', 'seat__name', p.name), el('span', 'seat__role', roleFor(p.id, pinned)));
|
|
88
|
+
id.append(top, el('span', 'seat__model', p.model ?? 'CLI default'));
|
|
89
|
+
const status = el('span', 'seat__status');
|
|
90
|
+
const lamp = el('span', 'seat__lamp'); lamp.dataset.tone = p.tone;
|
|
91
|
+
status.append(lamp, el('span', 'seat__status-label', 'Idle'));
|
|
92
|
+
row.append(id, status);
|
|
93
|
+
const track = el('div', 'seat__track'); track.append(el('span', 'seat__fill'));
|
|
94
|
+
seat.append(row, track, el('div', 'seat__activity', 'Standing by'));
|
|
95
|
+
roster.append(seat);
|
|
96
|
+
}
|
|
97
|
+
paintCost();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function roleFor(id, pinned) {
|
|
101
|
+
const roles = [];
|
|
102
|
+
if (pinned.judge === id) roles.push('judge');
|
|
103
|
+
if (pinned.verifier === id) roles.push('verifier');
|
|
104
|
+
if (pinned.analyst === id) roles.push('analyst');
|
|
105
|
+
if (pinned.responder === id) roles.push('responder');
|
|
106
|
+
if (Array.isArray(pinned.s4) && pinned.s4.includes(id)) roles.push('seat');
|
|
107
|
+
return roles.length ? roles.join(' · ') : DEFAULT_ROLE[id] ?? 'seat';
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function renderThreads(threads) {
|
|
111
|
+
const list = $('#thread-list'); list.replaceChildren();
|
|
112
|
+
if (!threads.length) { list.append(el('li', 'thread-item__title', 'No decisions yet.')); return; }
|
|
113
|
+
for (const t of threads) {
|
|
114
|
+
const btn = el('button', 'thread-item'); btn.type = 'button'; btn.dataset.id = t.id; btn.dataset.status = t.status;
|
|
115
|
+
btn.append(el('span', 'thread-item__glyph', STATUS_GLYPH[t.status] ?? '·'));
|
|
116
|
+
const title = el('span', 'thread-item__title', t.title); title.dataset.legacy = String(t.legacy);
|
|
117
|
+
btn.append(title, el('span', 'thread-item__time', relTime(t.updatedAt)));
|
|
118
|
+
btn.addEventListener('click', () => openThread(t.id, btn));
|
|
119
|
+
const li = el('li'); li.append(btn); list.append(li);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function setHeader(title, typeLabel) {
|
|
124
|
+
$('#session-title').textContent = title || 'New decision';
|
|
125
|
+
const type = $('#session-type');
|
|
126
|
+
if (typeLabel) { type.textContent = typeLabel; type.hidden = false; }
|
|
127
|
+
else type.hidden = true;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function openThread(id, btn) {
|
|
131
|
+
document.querySelectorAll('.thread-item[aria-current="true"]').forEach((b) => b.removeAttribute('aria-current'));
|
|
132
|
+
btn?.setAttribute('aria-current', 'true');
|
|
133
|
+
try {
|
|
134
|
+
const detail = await api(`/api/threads/${encodeURIComponent(id)}`);
|
|
135
|
+
state.threadId = detail.legacy ? null : detail.id;
|
|
136
|
+
state.runId = detail.resumeRunId;
|
|
137
|
+
renderThreadView(detail);
|
|
138
|
+
closePanels(false);
|
|
139
|
+
} catch (error) { toast(`Could not open decision (${error.message})`); }
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function renderThreadView(detail) {
|
|
143
|
+
state.canFollowup = !detail.legacy && detail.turns.some((turn) => turn.kind === 'report');
|
|
144
|
+
const hasReport = detail.turns.some((turn) => turn.kind === 'report' || turn.kind === 'report_md');
|
|
145
|
+
setHeader(detail.title, detail.legacy ? 'RECORDED' : hasReport ? 'COUNCIL' : 'THREAD');
|
|
146
|
+
const view = conversation(true);
|
|
147
|
+
view.append(el('p', 'thread-view__meta', detail.legacy ? 'Recorded run · read-only history' : 'Decision thread'));
|
|
148
|
+
for (const turn of detail.turns) {
|
|
149
|
+
if (turn.kind === 'report_md') view.append(el('div', 'report', turn.markdown));
|
|
150
|
+
else if (turn.kind === 'note') view.append(el('p', 'empty__lede', turn.text));
|
|
151
|
+
else if (turn.kind === 'user_message') appendUserTurn(turn, view);
|
|
152
|
+
else if (turn.kind === 'report') renderVerdict(turn.report, view);
|
|
153
|
+
else if (turn.kind === 'followup') renderFollowup(turn, view);
|
|
154
|
+
}
|
|
155
|
+
if (detail.resumeRunId) renderResumeCard(detail.resumeRunId, view);
|
|
156
|
+
updateComposer();
|
|
157
|
+
$('#center-scroll').scrollTop = 0;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function conversation(clear = false) {
|
|
161
|
+
$('#empty-state').hidden = true;
|
|
162
|
+
const view = $('#thread-view'); view.hidden = false;
|
|
163
|
+
if (clear) { view.replaceChildren(); state.gates.clear(); state.turnKeys.clear(); }
|
|
164
|
+
return view;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ── live frames ───────────────────────────────────────────────────────────
|
|
168
|
+
|
|
169
|
+
function connectEvents(runId) {
|
|
170
|
+
state.events?.close();
|
|
171
|
+
state.helloLastSeq = 0;
|
|
172
|
+
const source = new EventSource(`/api/runs/${encodeURIComponent(runId)}/events`);
|
|
173
|
+
state.events = source;
|
|
174
|
+
source.onmessage = (event) => {
|
|
175
|
+
const frame = JSON.parse(event.data);
|
|
176
|
+
if (frame.t === 'hello') {
|
|
177
|
+
state.helloLastSeq = frame.snapshot.lastSeq;
|
|
178
|
+
renderSnapshot(frame.snapshot);
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
applyFrame(frame, frame.seq <= state.helloLastSeq);
|
|
182
|
+
};
|
|
183
|
+
source.onerror = () => { if (!state.active) source.close(); };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function renderSnapshot(snapshot) {
|
|
187
|
+
state.calls = snapshot.calls.used;
|
|
188
|
+
state.budget = snapshot.calls.budget;
|
|
189
|
+
state.active = snapshot.status === 'gating' || snapshot.status === 'running';
|
|
190
|
+
state.runKind = snapshot.mode === 'followup' ? 'followup' : 'decision';
|
|
191
|
+
showSession(state.active, snapshot.status === 'gating' ? 'awaiting approval' : 'working');
|
|
192
|
+
renderDeck(snapshot);
|
|
193
|
+
snapshot.gates.forEach(renderGate);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function applyFrame(frame, historical) {
|
|
197
|
+
if (frame.t === 'turn' && frame.turn.kind === 'user_message') appendUserTurn(frame.turn);
|
|
198
|
+
else if (frame.t === 'turn') renderFollowup(frame.turn);
|
|
199
|
+
else if (frame.t === 'gate') renderGate(frame.gate);
|
|
200
|
+
else if (frame.t === 'gate_resolved') resolveGate(frame.gateId, frame.summary);
|
|
201
|
+
else if (frame.t === 'stage') updateStage(frame);
|
|
202
|
+
else if (frame.t === 'call') updateCall(frame, historical);
|
|
203
|
+
else if (frame.t === 'counters') updateCounters(frame);
|
|
204
|
+
else if (frame.t === 'report_ready') loadReport(frame.runId);
|
|
205
|
+
else if (frame.t === 'receipt') renderReceipt(frame.receipt);
|
|
206
|
+
else if (frame.t === 'done') finishRun(frame);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function appendUserTurn(turn, root = conversation()) {
|
|
210
|
+
const key = JSON.stringify(turn);
|
|
211
|
+
if (state.turnKeys.has(key)) return;
|
|
212
|
+
state.turnKeys.add(key);
|
|
213
|
+
const card = el('article', 'turn turn--user');
|
|
214
|
+
card.append(el('p', 'turn__text', turn.text));
|
|
215
|
+
if (turn.attachments?.length) {
|
|
216
|
+
const chips = el('div', 'turn__chips');
|
|
217
|
+
turn.attachments.forEach((item) => chips.append(el('span', 'turn__chip', item)));
|
|
218
|
+
card.append(chips);
|
|
219
|
+
}
|
|
220
|
+
card.append(el('p', 'turn__meta', turn.mode === 'followup' ? 'Follow-up' : turn.mode === 'quick' ? 'Quick council' : 'Full council'));
|
|
221
|
+
root.append(card); scrollConversation();
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function renderGate(gate) {
|
|
225
|
+
if (state.gates.has(gate.id)) return;
|
|
226
|
+
const card = el('article', 'gate-card'); card.dataset.gateId = gate.id; card.dataset.kind = gate.kind;
|
|
227
|
+
card.append(el('div', 'gate-card__eyebrow', gate.kind === 'attention' ? 'Needs attention' : 'Aiki access request'));
|
|
228
|
+
card.append(el('h3', null, gate.title));
|
|
229
|
+
gate.lines.forEach((line) => card.append(el('p', 'gate-card__line', line)));
|
|
230
|
+
if (gate.question) card.append(el('p', 'gate-card__question', gate.question));
|
|
231
|
+
if (gate.questions?.[0]) card.append(el('p', 'gate-card__question', gate.questions[0].prompt));
|
|
232
|
+
|
|
233
|
+
if (gate.scopes) {
|
|
234
|
+
const actions = el('div', 'gate-card__actions');
|
|
235
|
+
for (const [decision, label] of [['allow_once', 'Allow once y'], ['allow_session', 'Allow for session s'], ['deny', 'Deny n']]) {
|
|
236
|
+
const btn = el('button', `btn${decision === 'allow_once' ? ' btn--primary' : ''}`, label);
|
|
237
|
+
btn.type = 'button'; btn.addEventListener('click', () => answerGate(gate.id, { t: 'gate', gateId: gate.id, decision }));
|
|
238
|
+
actions.append(btn);
|
|
239
|
+
}
|
|
240
|
+
card.append(actions);
|
|
241
|
+
} else if (gate.kind === 'clarify') {
|
|
242
|
+
const actions = el('div', 'gate-card__actions');
|
|
243
|
+
gate.options?.forEach((option, index) => {
|
|
244
|
+
const btn = el('button', 'btn', `${index + 1}. ${option}`); btn.type = 'button';
|
|
245
|
+
btn.addEventListener('click', () => answerGate(gate.id, { t: 'answer', gateId: gate.id, value: index })); actions.append(btn);
|
|
246
|
+
});
|
|
247
|
+
card.append(actions, answerInput(gate.id));
|
|
248
|
+
} else if (gate.kind === 'grill') card.append(answerInput(gate.id));
|
|
249
|
+
else if (gate.fix) {
|
|
250
|
+
card.append(el('p', 'gate-card__line', gate.fix));
|
|
251
|
+
if (gate.kind === 'attention' && state.runKind === 'decision') {
|
|
252
|
+
const resume = el('button', 'btn', 'Resume from cached calls'); resume.type = 'button';
|
|
253
|
+
resume.addEventListener('click', () => resumeRun(state.runId)); card.append(resume);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
state.gates.set(gate.id, card);
|
|
258
|
+
conversation().append(card); scrollConversation();
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function answerInput(gateId) {
|
|
262
|
+
const form = el('form', 'gate-answer');
|
|
263
|
+
const input = el('input'); input.required = true; input.placeholder = 'Type your answer…';
|
|
264
|
+
const button = el('button', 'btn btn--primary', 'Answer'); button.type = 'submit';
|
|
265
|
+
form.append(input, button);
|
|
266
|
+
form.addEventListener('submit', (event) => {
|
|
267
|
+
event.preventDefault(); answerGate(gateId, { t: 'answer', gateId, value: input.value });
|
|
268
|
+
});
|
|
269
|
+
return form;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
async function answerGate(gateId, action) {
|
|
273
|
+
try {
|
|
274
|
+
return await api(`/api/runs/${encodeURIComponent(state.runId)}/actions`, { method: 'POST', body: JSON.stringify(action) });
|
|
275
|
+
} catch (error) { toast(error.message); }
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function resolveGate(id, summary) {
|
|
279
|
+
const card = state.gates.get(id);
|
|
280
|
+
if (!card) return;
|
|
281
|
+
card.classList.add('gate-card--resolved');
|
|
282
|
+
card.append(el('div', 'gate-receipt', summary));
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// ── the deck (right pane) ───────────────────────────────────────────────────
|
|
286
|
+
|
|
287
|
+
function setDeckState(label, live) {
|
|
288
|
+
const node = $('#deck-state');
|
|
289
|
+
node.textContent = label;
|
|
290
|
+
if (live) node.dataset.live = live;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function stageNode(stage, ord) {
|
|
294
|
+
const li = el('li', 'stage-node'); li.dataset.stage = stage.id; li.dataset.status = stage.status; li.dataset.ord = String(ord);
|
|
295
|
+
const rail = el('div', 'stage-node__rail');
|
|
296
|
+
rail.append(el('span', 'stage-node__dot', dotText(stage.status, ord)), el('span', 'stage-node__line'));
|
|
297
|
+
const body = el('div', 'stage-node__body');
|
|
298
|
+
body.append(el('span', 'stage-node__label', stage.label), el('span', 'stage-node__state', stageStateText(stage)));
|
|
299
|
+
li.append(rail, body);
|
|
300
|
+
return li;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function dotText(status, ord) {
|
|
304
|
+
return status === 'done' ? '✓' : status === 'failed' ? '✕' : status === 'skipped' ? '–' : String(ord);
|
|
305
|
+
}
|
|
306
|
+
function stageStateText(stage) {
|
|
307
|
+
if (stage.seat) return `${DISPLAY[stage.seat] ?? stage.seat} · ${stage.status}`;
|
|
308
|
+
return stage.status;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function renderDeck(snapshot) {
|
|
312
|
+
setDeckState(snapshot.status, snapshot.status === 'running' || snapshot.status === 'gating' ? 'live' : 'idle');
|
|
313
|
+
$('#run-id').textContent = snapshot.runId ? `run · ${String(snapshot.runId).slice(0, 12)}` : '';
|
|
314
|
+
const spine = $('#stage-spine'); spine.replaceChildren();
|
|
315
|
+
snapshot.stages.forEach((stage, i) => spine.append(stageNode(stage, i + 1)));
|
|
316
|
+
for (const [key, value] of Object.entries(snapshot.counters ?? {})) {
|
|
317
|
+
const node = $(`.deck-counter[data-counter="${key}"] strong`);
|
|
318
|
+
if (node) node.textContent = String(value ?? 0);
|
|
319
|
+
}
|
|
320
|
+
state.calls = snapshot.calls.used; state.budget = snapshot.calls.budget;
|
|
321
|
+
for (const [id, n] of Object.entries(snapshot.calls.byProvider ?? {})) { state.provCalls[id] = n; patchSeat(id); }
|
|
322
|
+
paintCost(snapshot.calls.replays);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function updateStage(frame) {
|
|
326
|
+
let node = $(`#stage-spine .stage-node[data-stage="${CSS.escape(frame.id)}"]`);
|
|
327
|
+
if (!node) {
|
|
328
|
+
const spine = $('#stage-spine');
|
|
329
|
+
node = stageNode(frame, spine.children.length + 1); spine.append(node);
|
|
330
|
+
}
|
|
331
|
+
node.dataset.status = frame.status;
|
|
332
|
+
$('.stage-node__dot', node).textContent = dotText(frame.status, Number(node.dataset.ord));
|
|
333
|
+
$('.stage-node__state', node).textContent = stageStateText(frame);
|
|
334
|
+
if (frame.status === 'running') state.stageLabel = frame.label;
|
|
335
|
+
setDeckState(frame.status === 'running' ? frame.label : frame.status, frame.status === 'running' ? 'live' : undefined);
|
|
336
|
+
$('#live-stage').textContent = frame.status === 'running' ? frame.label : $('#live-stage').textContent;
|
|
337
|
+
$('#announcer').textContent = `${frame.label} ${frame.status}`;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function patchSeat(id) {
|
|
341
|
+
const seat = $(`#seat-roster .seat[data-seat="${CSS.escape(id)}"]`);
|
|
342
|
+
if (!seat) return;
|
|
343
|
+
const busy = (state.provInflight[id] ?? 0) > 0;
|
|
344
|
+
const calls = state.provCalls[id] ?? 0;
|
|
345
|
+
seat.dataset.busy = String(busy);
|
|
346
|
+
const label = busy ? 'Running' : calls ? 'Done' : 'Idle';
|
|
347
|
+
$('.seat__status-label', seat).textContent = label;
|
|
348
|
+
const lamp = $('.seat__lamp', seat);
|
|
349
|
+
if (!busy) lamp.dataset.tone = calls ? 'green' : 'neutral';
|
|
350
|
+
$('.seat__fill', seat).style.width = busy ? '70%' : calls ? '100%' : '4%';
|
|
351
|
+
$('.seat__activity', seat).textContent = busy ? 'Working' : calls ? `${calls} call${calls === 1 ? '' : 's'}` : 'Standing by';
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function updateCall(frame, historical) {
|
|
355
|
+
const id = frame.provider;
|
|
356
|
+
if (frame.phase === 'start') {
|
|
357
|
+
state.provInflight[id] = (state.provInflight[id] ?? 0) + 1;
|
|
358
|
+
} else {
|
|
359
|
+
state.provInflight[id] = Math.max(0, (state.provInflight[id] ?? 0) - 1);
|
|
360
|
+
if (!frame.replayed) state.provCalls[id] = (state.provCalls[id] ?? 0) + 1;
|
|
361
|
+
if (frame.phase === 'end' && !frame.replayed && !historical) state.calls++;
|
|
362
|
+
}
|
|
363
|
+
patchSeat(id);
|
|
364
|
+
paintCost();
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function updateCounters(frame) {
|
|
368
|
+
for (const key of ['positions', 'evidence', 'disagreements', 'repairs']) {
|
|
369
|
+
if (frame[key] == null) continue;
|
|
370
|
+
const value = $(`.deck-counter[data-counter="${key}"] strong`);
|
|
371
|
+
if (value) value.textContent = String(frame[key]);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// Cost receipt: per-provider call rows + budget bar, driven by live call telemetry.
|
|
376
|
+
function paintCost(replays = 0) {
|
|
377
|
+
const root = $('#cost-receipt'); if (!root) return;
|
|
378
|
+
root.replaceChildren();
|
|
379
|
+
const pinned = state.settings?.roles ?? {};
|
|
380
|
+
for (const p of state.providers) {
|
|
381
|
+
const row = el('div', 'cost__row'); row.dataset.seat = p.id;
|
|
382
|
+
row.append(el('span', 'cost__swatch'), el('span', 'cost__name', `${p.name} · ${roleFor(p.id, pinned)}`), el('span', 'cost__calls', String(state.provCalls[p.id] ?? 0)));
|
|
383
|
+
root.append(row);
|
|
384
|
+
}
|
|
385
|
+
root.append(el('div', 'cost__rule'));
|
|
386
|
+
const bar = el('div', 'cost__bar'); const fill = el('span', 'cost__fill');
|
|
387
|
+
fill.style.width = `${Math.min(100, 100 * state.calls / Math.max(1, state.budget))}%`;
|
|
388
|
+
bar.append(fill); root.append(bar);
|
|
389
|
+
root.append(el('div', 'cost__note', 'Budget guard active · stops runaway repair loops'));
|
|
390
|
+
$('#cost-total').textContent = `${state.calls} / ${state.budget} calls${replays ? ` · ↻${replays}` : ''}`;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function resetDeck() {
|
|
394
|
+
state.calls = 0; state.provCalls = {}; state.provInflight = {};
|
|
395
|
+
$('#stage-spine').replaceChildren();
|
|
396
|
+
document.querySelectorAll('.deck-counter strong').forEach((n) => (n.textContent = '0'));
|
|
397
|
+
renderRoster(state.providers);
|
|
398
|
+
setDeckState('idle', 'idle');
|
|
399
|
+
$('#run-id').textContent = '';
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
async function loadReport(runId) {
|
|
403
|
+
try { renderVerdict(await api(`/api/runs/${encodeURIComponent(runId)}/report`)); }
|
|
404
|
+
catch (error) { toast(`Could not load verdict (${error.message})`); }
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function renderVerdict(report, root = conversation()) {
|
|
408
|
+
if (root.querySelector(`[data-report-id="${CSS.escape(report.runId)}"]`)) return;
|
|
409
|
+
const card = el('article', 'verdict-card'); card.dataset.tone = report.verdict.tone; card.dataset.reportId = report.runId;
|
|
410
|
+
|
|
411
|
+
const chair = el('div', 'verdict-chair');
|
|
412
|
+
chair.append(el('span', 'verdict-chair__mark', 'a'), el('span', 'verdict-chair__name', 'AIki'), el('span', 'verdict-chair__role', '· chairman of the council'));
|
|
413
|
+
chair.append(el('span', 'verdict-chair__calls', `${report.receipt.calls} provider calls`));
|
|
414
|
+
|
|
415
|
+
const panel = el('div', 'verdict-card__panel');
|
|
416
|
+
const banner = el('header', 'verdict-card__banner');
|
|
417
|
+
banner.append(el('div', 'verdict-card__eyebrow', 'Decision brief · BLUF'));
|
|
418
|
+
const head = el('div', 'verdict-card__head');
|
|
419
|
+
head.append(el('span', 'verdict-card__label', report.verdict.label), el('h2', null, report.headline));
|
|
420
|
+
banner.append(head);
|
|
421
|
+
if (report.confidence) {
|
|
422
|
+
const conf = el('div', 'verdict-conf'); conf.dataset.band = report.confidence.label.toLowerCase();
|
|
423
|
+
const track = el('span', 'verdict-conf__track'); const fill = el('span', 'verdict-conf__fill');
|
|
424
|
+
fill.style.width = `${report.confidence.score}%`; track.append(fill);
|
|
425
|
+
conf.append(track, el('span', 'verdict-conf__label', `Confidence ${report.confidence.score} · ${report.confidence.label}`));
|
|
426
|
+
banner.append(conf);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
const body = el('div', 'verdict-card__body');
|
|
430
|
+
body.append(el('p', 'verdict-card__lead', report.bottomLine));
|
|
431
|
+
report.warnings.forEach((warning) => body.append(el('p', 'verdict-warning', warning)));
|
|
432
|
+
|
|
433
|
+
if (report.milestones.length) {
|
|
434
|
+
const block = el('div', 'verdict-block');
|
|
435
|
+
block.append(el('span', 'verdict-block__label', 'Anchored validation plan'));
|
|
436
|
+
report.milestones.forEach((m) => {
|
|
437
|
+
const item = el('div', 'plan-item');
|
|
438
|
+
item.append(el('span', 'plan-item__badge', m.timebox));
|
|
439
|
+
const inner = el('div', 'plan-item__body');
|
|
440
|
+
inner.append(el('span', 'plan-item__text', m.outcome));
|
|
441
|
+
const done = el('span', 'plan-item__done'); done.append(el('b', null, 'done when'), document.createTextNode(` · ${m.doneWhen}`));
|
|
442
|
+
inner.append(done); item.append(inner); block.append(item);
|
|
443
|
+
});
|
|
444
|
+
body.append(block);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
const disclosures = [];
|
|
448
|
+
report.sections.forEach((section) => disclosures.push(disclosure(section.heading, section.summary, section.bullets)));
|
|
449
|
+
if (report.features.length) disclosures.push(disclosure('Feature priorities', '', report.features.map((item) => `${item.priority} · ${item.feature} — ${item.rationale}`)));
|
|
450
|
+
if (report.caveats.length) disclosures.push(disclosure('Caveats', '', report.caveats));
|
|
451
|
+
if (report.sources.length) disclosures.push(disclosure('Sources', '', report.sources.map((item) => `${item.label}${item.citedFor.length ? ` — ${item.citedFor.join('; ')}` : ''}`)));
|
|
452
|
+
if (disclosures.length) {
|
|
453
|
+
const bar = el('div', 'verdict-details-bar');
|
|
454
|
+
const toggle = el('button', 'verdict-toggle-all', 'Expand all'); toggle.type = 'button';
|
|
455
|
+
toggle.addEventListener('click', () => {
|
|
456
|
+
const anyClosed = disclosures.some((d) => !d.open);
|
|
457
|
+
disclosures.forEach((d) => (d.open = anyClosed));
|
|
458
|
+
toggle.textContent = anyClosed ? 'Collapse all' : 'Expand all';
|
|
459
|
+
});
|
|
460
|
+
bar.append(toggle); body.append(bar);
|
|
461
|
+
disclosures.forEach((d) => body.append(d));
|
|
462
|
+
disclosures[0].open = true; // lead with the reasoning so the report never reads as thin
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
const next = el('p', 'verdict-card__next'); next.append(el('strong', null, 'Next step · '), document.createTextNode(report.nextStep));
|
|
466
|
+
body.append(next);
|
|
467
|
+
|
|
468
|
+
const actions = el('div', 'verdict-card__actions');
|
|
469
|
+
const copy = el('button', 'btn', 'Copy summary'); copy.type = 'button';
|
|
470
|
+
copy.addEventListener('click', () => copySummary(report, copy));
|
|
471
|
+
actions.append(copy); body.append(actions);
|
|
472
|
+
|
|
473
|
+
panel.append(banner, body);
|
|
474
|
+
card.append(chair, panel, el('footer', 'verdict-card__receipt', receiptLine(report.receipt)));
|
|
475
|
+
root.append(card); scrollConversation();
|
|
476
|
+
if (state.threadId) { state.canFollowup = true; updateComposer(); }
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function copySummary(report, btn) {
|
|
480
|
+
const text = `${report.verdict.label} — ${report.headline}\n\n${report.bottomLine}\n\nNext step: ${report.nextStep}`;
|
|
481
|
+
navigator.clipboard?.writeText(text).then(() => { btn.textContent = 'Copied ✓'; setTimeout(() => (btn.textContent = 'Copy summary'), 1600); })
|
|
482
|
+
.catch(() => toast('Copy failed — select the text manually.'));
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function renderFollowup(turn, root = conversation()) {
|
|
486
|
+
const card = el('article', 'followup-card');
|
|
487
|
+
card.append(el('p', 'followup-card__answer', turn.answer), el('p', 'followup-card__meta', turn.label));
|
|
488
|
+
const reconvene = el('button', 'btn btn--icon', 'Re-convene council'); reconvene.type = 'button';
|
|
489
|
+
reconvene.addEventListener('click', () => sendMessage(turn.question, 'decision'));
|
|
490
|
+
card.append(reconvene); root.append(card); scrollConversation();
|
|
491
|
+
state.canFollowup = true; updateComposer();
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function renderResumeCard(runId, root = conversation()) {
|
|
495
|
+
const card = el('article', 'gate-card'); card.dataset.kind = 'resume';
|
|
496
|
+
card.append(el('div', 'gate-card__eyebrow', 'Interrupted council'), el('h3', null, 'Resume from cached calls?'));
|
|
497
|
+
card.append(el('p', 'gate-card__line', 'Aiki will replay completed calls free and ask before any new spend.'));
|
|
498
|
+
const button = el('button', 'btn btn--primary', 'Review resume cost'); button.type = 'button';
|
|
499
|
+
button.addEventListener('click', () => resumeRun(runId)); card.append(button); root.append(card);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
async function resumeRun(runId) {
|
|
503
|
+
if (!runId) return;
|
|
504
|
+
try {
|
|
505
|
+
state.runId = runId;
|
|
506
|
+
const outcome = await answerGate('', { t: 'resume' });
|
|
507
|
+
if (!outcome?.runId) return;
|
|
508
|
+
state.runId = outcome.runId; state.runKind = 'decision';
|
|
509
|
+
resetDeck();
|
|
510
|
+
showSession(true, 'awaiting resume approval'); connectEvents(outcome.runId);
|
|
511
|
+
} catch (error) { toast(error.message); }
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function disclosure(title, summary, bullets) {
|
|
515
|
+
const details = el('details'); const head = el('summary', null, title); details.append(head);
|
|
516
|
+
if (summary) details.append(el('p', null, summary));
|
|
517
|
+
if (bullets?.length) { const list = el('ul'); bullets.forEach((item) => list.append(el('li', null, item))); details.append(list); }
|
|
518
|
+
return details;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function renderReceipt(receipt) {
|
|
522
|
+
if (receipt.providers?.length) {
|
|
523
|
+
for (const p of receipt.providers) {
|
|
524
|
+
const seatId = Object.keys(DISPLAY).find((id) => DISPLAY[id] === p.name) ?? p.name;
|
|
525
|
+
state.provCalls[seatId] = p.calls;
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
state.calls = receipt.calls; state.budget = receipt.budget;
|
|
529
|
+
paintCost(receipt.replays);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
function receiptLine(r) {
|
|
533
|
+
return `${r.mode} · ${r.calls}/${r.budget} calls${r.replays ? ` · ${r.replays} replayed` : ''} · ${r.repairs} repairs · ${(r.durationMs / 1000).toFixed(1)}s model time`;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
function finishRun(frame) {
|
|
537
|
+
state.active = false; showSession(false);
|
|
538
|
+
setDeckState(frame.status === 'ok' ? 'complete' : frame.status, frame.status === 'ok' ? 'complete' : 'failed');
|
|
539
|
+
state.events?.close();
|
|
540
|
+
if (frame.status === 'ok') state.canFollowup = true;
|
|
541
|
+
updateComposer();
|
|
542
|
+
api('/api/bootstrap').then((snap) => renderThreads(snap.threads)).catch(() => {});
|
|
543
|
+
if (frame.status === 'aborted') toast(state.runKind === 'followup' ? 'Follow-up cancelled.' : 'Council cancelled. Partial artifacts were kept.');
|
|
544
|
+
else if (frame.status === 'failed') toast(`${state.runKind === 'followup' ? 'Follow-up' : 'Council'} needs attention. See the card in the conversation.`);
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
function showSession(active, stage = 'awaiting approval') {
|
|
548
|
+
state.active = active;
|
|
549
|
+
$('#composer').hidden = active;
|
|
550
|
+
$('#live-session').hidden = !active;
|
|
551
|
+
$('#thinking').hidden = !active;
|
|
552
|
+
if (active) {
|
|
553
|
+
state.runStart = state.runStart || Date.now();
|
|
554
|
+
state.stageLabel = stage;
|
|
555
|
+
$('#session-label').textContent = state.runKind === 'followup' ? 'Follow-up in progress' : 'Council in session';
|
|
556
|
+
$('#live-stage').textContent = stage;
|
|
557
|
+
startTicker();
|
|
558
|
+
} else {
|
|
559
|
+
stopTicker(); state.runStart = null;
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// Live elapsed clock so long gaps between frames never read as "stuck".
|
|
564
|
+
let ticker = null;
|
|
565
|
+
function startTicker() { if (!ticker) { tick(); ticker = setInterval(tick, 1000); } }
|
|
566
|
+
function stopTicker() { if (ticker) { clearInterval(ticker); ticker = null; } }
|
|
567
|
+
function tick() {
|
|
568
|
+
if (!state.active) return stopTicker();
|
|
569
|
+
const secs = Math.floor((Date.now() - (state.runStart || Date.now())) / 1000);
|
|
570
|
+
const label = `${state.stageLabel || 'working'} · ${Math.floor(secs / 60)}:${String(secs % 60).padStart(2, '0')}`;
|
|
571
|
+
const meta = $('.thinking__meta'); if (meta) meta.textContent = label;
|
|
572
|
+
const stage = $('#live-stage'); if (stage) stage.textContent = label;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
// ── composer + interactions ───────────────────────────────────────────────────
|
|
576
|
+
|
|
577
|
+
const decisionText = $('#decision-text');
|
|
578
|
+
|
|
579
|
+
$('#composer').addEventListener('submit', async (event) => {
|
|
580
|
+
event.preventDefault();
|
|
581
|
+
const text = decisionText.value.trim(); if (!text) return;
|
|
582
|
+
await sendMessage(text, state.threadId && state.canFollowup ? 'followup' : 'decision');
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
decisionText.addEventListener('input', autosize);
|
|
586
|
+
decisionText.addEventListener('keydown', (event) => {
|
|
587
|
+
if (event.key === 'Enter' && !event.shiftKey) { event.preventDefault(); $('#composer').requestSubmit(); }
|
|
588
|
+
});
|
|
589
|
+
function autosize() {
|
|
590
|
+
decisionText.style.height = 'auto';
|
|
591
|
+
decisionText.style.height = `${Math.min(160, decisionText.scrollHeight)}px`;
|
|
592
|
+
$('#composer-send').disabled = decisionText.value.trim().length === 0;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
async function sendMessage(text, kind) {
|
|
596
|
+
const mode = $('#mode').value;
|
|
597
|
+
try {
|
|
598
|
+
const newThread = !state.threadId;
|
|
599
|
+
const outcome = await api('/api/messages', { method: 'POST', body: JSON.stringify({
|
|
600
|
+
...(state.threadId ? { threadId: state.threadId } : {}), text, mode, kind,
|
|
601
|
+
attachments: kind === 'followup' ? [] : state.attachments,
|
|
602
|
+
}) });
|
|
603
|
+
state.threadId = outcome.threadId; state.runId = outcome.runId;
|
|
604
|
+
state.runKind = kind; state.canFollowup = kind === 'followup';
|
|
605
|
+
if (newThread) setHeader(text.length > 64 ? `${text.slice(0, 61)}…` : text, kind === 'followup' ? 'FOLLOW-UP' : mode === 'quick' ? 'QUICK' : 'COUNCIL');
|
|
606
|
+
conversation(newThread);
|
|
607
|
+
resetDeck();
|
|
608
|
+
showSession(true); connectEvents(outcome.runId);
|
|
609
|
+
decisionText.value = ''; state.attachments = []; renderAttachments(); autosize();
|
|
610
|
+
updateComposer();
|
|
611
|
+
} catch (error) { toast(error.message); }
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
function updateComposer() {
|
|
615
|
+
const followup = Boolean(state.threadId && state.canFollowup);
|
|
616
|
+
decisionText.placeholder = followup
|
|
617
|
+
? 'Ask a follow-up about this council answer…'
|
|
618
|
+
: 'Describe the decision, constraints, and what a good answer must include…';
|
|
619
|
+
const send = $('#composer-send'); send.title = followup ? 'Ask' : 'Convene'; send.setAttribute('aria-label', followup ? 'Ask' : 'Convene');
|
|
620
|
+
$('#attach').hidden = followup;
|
|
621
|
+
$('.mode-select').hidden = followup;
|
|
622
|
+
$('#estimate').textContent = followup ? '1 call · no council' : $('#mode').value === 'quick' ? '~3 calls · single-pass council' : '8–10 calls · council of installed models';
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
$('#attach').addEventListener('click', () => {
|
|
626
|
+
const value = prompt('Paste a local file path or a public http(s) URL:')?.trim();
|
|
627
|
+
if (!value) return;
|
|
628
|
+
state.attachments.push(/^https?:\/\//i.test(value) ? { kind: 'url', url: value } : { kind: 'file', path: value });
|
|
629
|
+
renderAttachments();
|
|
630
|
+
});
|
|
631
|
+
|
|
632
|
+
function renderAttachments() {
|
|
633
|
+
const root = $('#attachment-chips'); root.replaceChildren();
|
|
634
|
+
state.attachments.forEach((item, index) => {
|
|
635
|
+
const chip = el('span', 'attachment-chip', item.kind === 'url' ? item.url : item.path.split(/[\\/]/).pop());
|
|
636
|
+
const remove = el('button', null, '×'); remove.type = 'button'; remove.setAttribute('aria-label', 'Remove attachment');
|
|
637
|
+
remove.addEventListener('click', () => { state.attachments.splice(index, 1); renderAttachments(); });
|
|
638
|
+
chip.append(remove); root.append(chip);
|
|
639
|
+
});
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
$('#mode').addEventListener('change', () => {
|
|
643
|
+
$('#estimate').textContent = $('#mode').value === 'quick' ? '~3 calls · single-pass council' : '8–10 calls · council of installed models';
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
$('#cancel-run').addEventListener('click', () => answerGate('', { t: 'cancel' }));
|
|
647
|
+
|
|
648
|
+
$('#new-decision').addEventListener('click', () => {
|
|
649
|
+
if (state.active) return toast('A council is already in session.');
|
|
650
|
+
state.threadId = null; state.runId = null;
|
|
651
|
+
state.canFollowup = false; state.runKind = 'decision'; updateComposer();
|
|
652
|
+
setHeader('New decision', null);
|
|
653
|
+
$('#thread-view').hidden = true; $('#thread-view').replaceChildren(); $('#empty-state').hidden = false;
|
|
654
|
+
resetDeck();
|
|
655
|
+
closePanels(false);
|
|
656
|
+
decisionText.focus();
|
|
657
|
+
});
|
|
658
|
+
|
|
659
|
+
document.addEventListener('keydown', (event) => {
|
|
660
|
+
if (['INPUT', 'TEXTAREA', 'SELECT'].includes(document.activeElement?.tagName)) return;
|
|
661
|
+
const pending = [...state.gates.entries()].reverse().find(([, card]) => !card.classList.contains('gate-card--resolved') && $('.gate-card__actions', card));
|
|
662
|
+
if (!pending) return;
|
|
663
|
+
const decision = event.key.toLowerCase() === 'y' ? 'allow_once' : event.key.toLowerCase() === 's' ? 'allow_session' : event.key.toLowerCase() === 'n' ? 'deny' : null;
|
|
664
|
+
if (decision) { event.preventDefault(); answerGate(pending[0], { t: 'gate', gateId: pending[0], decision }); }
|
|
665
|
+
});
|
|
666
|
+
|
|
667
|
+
// ── provider check + settings ─────────────────────────────────────────────────────
|
|
668
|
+
|
|
669
|
+
$('#check-connections').addEventListener('click', async (event) => {
|
|
670
|
+
if (!confirm('Check connections?\n\nThis makes up to 3 tiny provider calls. Continue?')) return;
|
|
671
|
+
const btn = event.currentTarget; btn.setAttribute('aria-busy', 'true');
|
|
672
|
+
try {
|
|
673
|
+
const providers = await api('/api/providers/check', { method: 'POST', body: JSON.stringify({ fresh: true }) });
|
|
674
|
+
renderProviders(providers); renderQuorum(quorumFrom(providers)); renderRoster(providers); toast('Connections checked.');
|
|
675
|
+
} catch (error) { toast(`Check failed (${error.message})`); }
|
|
676
|
+
finally { btn.removeAttribute('aria-busy'); }
|
|
677
|
+
});
|
|
678
|
+
|
|
679
|
+
function renderSettings(settings) {
|
|
680
|
+
const body = $('#settings-body'); body.replaceChildren();
|
|
681
|
+
const form = el('form', 'settings-form');
|
|
682
|
+
const scope = settings.scope.startsWith('project') ? 'project config (.aiki/config.json)' : 'global config (~/.aiki/config.json)';
|
|
683
|
+
form.append(el('p', 'settings-scope', `Saving to ${scope}. Changes apply to the next run.`));
|
|
684
|
+
|
|
685
|
+
const local = settings.overrides ?? { models: settings.models, roles: settings.roles };
|
|
686
|
+
const models = el('fieldset', 'settings-group'); models.append(el('legend', null, 'Models'));
|
|
687
|
+
for (const id of ['claude', 'codex', 'agy']) {
|
|
688
|
+
const label = el('label', 'settings-field');
|
|
689
|
+
label.append(el('span', null, DISPLAY[id]));
|
|
690
|
+
const input = el('input', 'settings-input'); input.name = `model-${id}`; input.autocomplete = 'off'; input.spellcheck = false;
|
|
691
|
+
input.value = local.models[id] ?? '';
|
|
692
|
+
input.placeholder = settings.models[id] ? `Inherited: ${settings.models[id]}` : 'CLI default';
|
|
693
|
+
label.append(input); models.append(label);
|
|
694
|
+
}
|
|
695
|
+
form.append(models);
|
|
696
|
+
|
|
697
|
+
const roles = el('fieldset', 'settings-group'); roles.append(el('legend', null, 'Roles'));
|
|
698
|
+
const roleFields = [['judge', 'Judge'], ['verifier', 'Verifier'], ['analyst', 'Analyst'], ['responder', 'Follow-up responder']];
|
|
699
|
+
for (const [key, label] of roleFields) roles.append(roleField(key, label, local.roles?.[key], settings.roles?.[key]));
|
|
700
|
+
const seats = local.roles?.s4 ?? [];
|
|
701
|
+
roles.append(roleField('s4-0', 'Council seat 1', seats[0], settings.roles?.s4?.[0]));
|
|
702
|
+
roles.append(roleField('s4-1', 'Council seat 2', seats[1], settings.roles?.s4?.[1]));
|
|
703
|
+
roles.append(el('p', 'settings-note', 'Claude is recommended for the judge role as the strongest default.'));
|
|
704
|
+
form.append(roles, el('p', 'settings-note', 'Clear an override to fall back to the global or CLI default. No credentials are stored here.'));
|
|
705
|
+
|
|
706
|
+
const actions = el('div', 'settings-actions');
|
|
707
|
+
const save = el('button', 'btn btn--primary', 'Save settings'); save.type = 'submit'; actions.append(save); form.append(actions);
|
|
708
|
+
form.addEventListener('submit', async (event) => {
|
|
709
|
+
event.preventDefault();
|
|
710
|
+
const data = new FormData(form);
|
|
711
|
+
const seat1 = String(data.get('role-s4-0') ?? '');
|
|
712
|
+
const seat2 = String(data.get('role-s4-1') ?? '');
|
|
713
|
+
if (Boolean(seat1) !== Boolean(seat2)) return toast('Choose both council seats, or leave both on Default.');
|
|
714
|
+
const value = (name) => String(data.get(name) ?? '').trim() || null;
|
|
715
|
+
const patch = {
|
|
716
|
+
models: { claude: value('model-claude'), codex: value('model-codex'), agy: value('model-agy') },
|
|
717
|
+
roles: {
|
|
718
|
+
judge: value('role-judge'), verifier: value('role-verifier'), analyst: value('role-analyst'),
|
|
719
|
+
responder: value('role-responder'), s4: seat1 && seat2 ? [seat1, seat2] : null,
|
|
720
|
+
},
|
|
721
|
+
};
|
|
722
|
+
save.setAttribute('aria-busy', 'true'); save.disabled = true;
|
|
723
|
+
try {
|
|
724
|
+
state.settings = await api('/api/settings', { method: 'PATCH', body: JSON.stringify(patch) });
|
|
725
|
+
renderSettings(state.settings); renderRoster(state.providers); toast('Settings saved for the next run.');
|
|
726
|
+
} catch (error) { toast(`Could not save settings (${error.message})`); }
|
|
727
|
+
finally { save.removeAttribute('aria-busy'); save.disabled = false; }
|
|
728
|
+
});
|
|
729
|
+
body.append(form);
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
function roleField(key, labelText, selected, inherited) {
|
|
733
|
+
const label = el('label', 'settings-field'); label.append(el('span', null, labelText));
|
|
734
|
+
const select = el('select', 'settings-select'); select.name = `role-${key}`;
|
|
735
|
+
const fallback = el('option', null, inherited ? `Default (${DISPLAY[inherited]})` : 'Default'); fallback.value = ''; select.append(fallback);
|
|
736
|
+
for (const id of ['claude', 'codex', 'agy']) {
|
|
737
|
+
const option = el('option', null, DISPLAY[id]); option.value = id; option.selected = selected === id; select.append(option);
|
|
738
|
+
}
|
|
739
|
+
label.append(select); return label;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
let settingsFocus;
|
|
743
|
+
async function openSettings() {
|
|
744
|
+
settingsFocus = document.activeElement;
|
|
745
|
+
const sheet = $('#settings-sheet'); sheet.hidden = false;
|
|
746
|
+
$('#settings-body').replaceChildren(el('p', 'settings-empty', 'Loading settings…'));
|
|
747
|
+
try {
|
|
748
|
+
state.settings = await api('/api/settings'); renderSettings(state.settings);
|
|
749
|
+
$('.settings-input, .settings-select', sheet)?.focus();
|
|
750
|
+
} catch (error) {
|
|
751
|
+
const message = el('p', 'settings-error', `Could not load settings. ${error.message}`); message.setAttribute('role', 'alert');
|
|
752
|
+
$('#settings-body').replaceChildren(message);
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
function closeSettings() {
|
|
756
|
+
$('#settings-sheet').hidden = true;
|
|
757
|
+
settingsFocus?.focus?.();
|
|
758
|
+
}
|
|
759
|
+
$('#settings-open').addEventListener('click', openSettings);
|
|
760
|
+
document.querySelectorAll('#settings-sheet [data-close]').forEach((node) => node.addEventListener('click', closeSettings));
|
|
761
|
+
|
|
762
|
+
// ── responsive rails and drawers ────────────────────────────────────────────────
|
|
763
|
+
|
|
764
|
+
function setPanel(name, open) {
|
|
765
|
+
document.body.classList.toggle(`${name}-open`, open);
|
|
766
|
+
$(`#${name}-toggle`)?.setAttribute('aria-expanded', String(open));
|
|
767
|
+
$('#drawer-backdrop').hidden = !document.body.classList.contains('rail-open') && !document.body.classList.contains('deck-open');
|
|
768
|
+
if (open) $(`#${name === 'rail' ? 'sessions-rail' : 'council-deck'} button`)?.focus();
|
|
769
|
+
}
|
|
770
|
+
function closePanels(restoreFocus = true) {
|
|
771
|
+
const open = document.body.classList.contains('rail-open') ? 'rail' : document.body.classList.contains('deck-open') ? 'deck' : null;
|
|
772
|
+
setPanel('rail', false); setPanel('deck', false);
|
|
773
|
+
if (restoreFocus && open) $(`#${open}-toggle`)?.focus();
|
|
774
|
+
}
|
|
775
|
+
$('#rail-toggle').addEventListener('click', () => setPanel('rail', !document.body.classList.contains('rail-open')));
|
|
776
|
+
$('#deck-toggle').addEventListener('click', () => setPanel('deck', !document.body.classList.contains('deck-open')));
|
|
777
|
+
$('#drawer-backdrop').addEventListener('click', closePanels);
|
|
778
|
+
document.querySelectorAll('[data-close-panel]').forEach((node) => node.addEventListener('click', closePanels));
|
|
779
|
+
document.addEventListener('keydown', (event) => {
|
|
780
|
+
if (event.key !== 'Escape') return;
|
|
781
|
+
if (!$('#settings-sheet').hidden) closeSettings();
|
|
782
|
+
else closePanels();
|
|
783
|
+
});
|
|
784
|
+
|
|
785
|
+
// ── helpers + boot ──────────────────────────────────────────────────────────────────
|
|
786
|
+
|
|
787
|
+
function quorumFrom(providers) {
|
|
788
|
+
const ready = providers.filter((p) => p.kind === 'ready').length;
|
|
789
|
+
if (ready >= 3) return { label: '3/3 council ready', tone: 'green' };
|
|
790
|
+
if (ready === 2) return { label: '2/3 degraded', tone: 'amber' };
|
|
791
|
+
return { label: 'council unavailable', tone: 'red' };
|
|
792
|
+
}
|
|
793
|
+
function relTime(iso) {
|
|
794
|
+
const seconds = Math.max(0, (Date.now() - Date.parse(iso)) / 1000);
|
|
795
|
+
if (!Number.isFinite(seconds) || seconds < 90) return 'just now';
|
|
796
|
+
if (seconds < 3600) return `${Math.round(seconds / 60)}m ago`;
|
|
797
|
+
if (seconds < 86400) return `${Math.round(seconds / 3600)}h ago`;
|
|
798
|
+
return `${Math.round(seconds / 86400)}d ago`;
|
|
799
|
+
}
|
|
800
|
+
function scrollConversation() { requestAnimationFrame(() => { $('#center-scroll').scrollTop = $('#center-scroll').scrollHeight; }); }
|
|
801
|
+
let toastTimer;
|
|
802
|
+
function toast(message) {
|
|
803
|
+
const node = $('#toast'); node.textContent = message; node.hidden = false;
|
|
804
|
+
clearTimeout(toastTimer); toastTimer = setTimeout(() => { node.hidden = true; }, 4200);
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
async function boot() {
|
|
808
|
+
try {
|
|
809
|
+
const snapshot = await api('/api/bootstrap');
|
|
810
|
+
state.settings = snapshot.settings;
|
|
811
|
+
$('#version').textContent = `v${snapshot.version}`;
|
|
812
|
+
renderProviders(snapshot.providers); renderQuorum(snapshot.quorum); renderRoster(snapshot.providers); renderThreads(snapshot.threads);
|
|
813
|
+
updateComposer(); autosize();
|
|
814
|
+
} catch (error) {
|
|
815
|
+
$('.empty__title').textContent = 'The workspace could not load.';
|
|
816
|
+
$('.empty__lede').textContent = error.message;
|
|
817
|
+
toast(`Failed to load workspace (${error.message})`);
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
boot();
|