@prplkane/roundtable 0.0.0-stage → 0.6.4

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.
Files changed (70) hide show
  1. package/CHANGELOG.md +529 -0
  2. package/LICENSE +21 -0
  3. package/README.md +189 -3
  4. package/SECURITY.md +299 -0
  5. package/docs/workflow.md +1344 -0
  6. package/package.json +52 -6
  7. package/scripts/agent.mjs +40 -0
  8. package/scripts/export.mjs +20 -0
  9. package/scripts/mcp.mjs +175 -0
  10. package/scripts/run-debate.mjs +70 -0
  11. package/scripts/server.mjs +28 -0
  12. package/scripts/setup.mjs +182 -0
  13. package/skills/roundtable/SKILL.md +57 -0
  14. package/src/adapters/agy.js +7 -0
  15. package/src/adapters/catalogue.js +49 -0
  16. package/src/adapters/claude.js +6 -0
  17. package/src/adapters/codex.js +6 -0
  18. package/src/adapters/common.js +304 -0
  19. package/src/adapters/config.js +432 -0
  20. package/src/adapters/endpoint.js +178 -0
  21. package/src/adapters/errors.js +69 -0
  22. package/src/adapters/index.js +11 -0
  23. package/src/adapters/models.js +106 -0
  24. package/src/adapters/quota.js +305 -0
  25. package/src/adapters/spawn.js +178 -0
  26. package/src/agent-client.js +108 -0
  27. package/src/cli.js +44 -0
  28. package/src/contract.js +256 -0
  29. package/src/diagnostics.js +133 -0
  30. package/src/doctor.js +101 -0
  31. package/src/engine.js +902 -0
  32. package/src/estimate.js +331 -0
  33. package/src/export.js +307 -0
  34. package/src/fake.js +75 -0
  35. package/src/folder-picker.js +46 -0
  36. package/src/history.js +220 -0
  37. package/src/local-http.js +39 -0
  38. package/src/local-server.js +110 -0
  39. package/src/notes.js +16 -0
  40. package/src/personas.js +49 -0
  41. package/src/preflight.js +126 -0
  42. package/src/progress.js +30 -0
  43. package/src/prompts.js +278 -0
  44. package/src/research.js +113 -0
  45. package/src/review-view.js +157 -0
  46. package/src/seats.js +272 -0
  47. package/src/server.js +928 -0
  48. package/src/settings.js +27 -0
  49. package/src/setup-toml.js +124 -0
  50. package/src/snapshot.js +93 -0
  51. package/src/store.js +303 -0
  52. package/src/terminal.js +77 -0
  53. package/src/terms.js +52 -0
  54. package/src/ui/app.js +1967 -0
  55. package/src/ui/avatars.js +132 -0
  56. package/src/ui/ceremony.js +87 -0
  57. package/src/ui/design/app.css +714 -0
  58. package/src/ui/design/preset-dusk.css +91 -0
  59. package/src/ui/design/tokens.css +129 -0
  60. package/src/ui/diagnostics.js +61 -0
  61. package/src/ui/help.js +122 -0
  62. package/src/ui/icons.js +64 -0
  63. package/src/ui/index.html +272 -0
  64. package/src/ui/loader.js +73 -0
  65. package/src/ui/logo.svg +4 -0
  66. package/src/ui/scan.js +57 -0
  67. package/src/ui/scribe.js +64 -0
  68. package/src/ui/sound.js +80 -0
  69. package/src/validate.js +112 -0
  70. package/src/version.js +5 -0
@@ -0,0 +1,157 @@
1
+ // Shared presentation rules. No model calls are needed to collect questions or explain state.
2
+ import { verdictSections, recommendationPick, optionLabel } from './contract.js';
3
+ export const escapeHtml = (value) => String(value ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
4
+
5
+ export function groupQuestions(questions = []) {
6
+ const groups = new Map();
7
+ for (const q of questions) {
8
+ if (!q.question?.trim()) continue;
9
+ const key = q.question.normalize('NFKC').toLowerCase().replace(/[’]/g, "'").replace(/\s+/g, ' ').replace(/[?.\s]+$/, '').trim();
10
+ if (!groups.has(key)) groups.set(key, { id: `q${groups.size + 1}`, question: q.question.trim(), sources: [], seats: [] });
11
+ const group = groups.get(key);
12
+ if (!group.sources.includes(q.lens)) group.sources.push(q.lens);
13
+ if (q.seat && !group.seats.includes(q.seat)) group.seats.push(q.seat);
14
+ }
15
+ return [...groups.values()];
16
+ }
17
+
18
+ export function answerMessage(questions, answers = [], note = '') {
19
+ const groups = groupQuestions(questions), seen = new Set(), lines = [];
20
+ for (const answer of answers) {
21
+ const group = groups.find((q) => q.id === answer.id);
22
+ if (!group || seen.has(answer.id)) throw Object.assign(new Error('The question list changed. Review the current questions before sending your answers.'), { status: 409 });
23
+ seen.add(answer.id);
24
+ if (answer.text.trim()) lines.push(`Question (${group.sources.join(', ')}): ${group.question}\nAnswer: ${answer.text.trim()}`);
25
+ }
26
+ if (note.trim()) lines.push(note.trim());
27
+ return lines.join('\n\n');
28
+ }
29
+
30
+ export function stopExplanation(reason, context) {
31
+ const labels = {
32
+ human: 'You stopped the review. Replies already received are kept.',
33
+ finish: 'You ended discussion and requested a verdict from the collected replies.',
34
+ 'wall-clock': context === 'waiting' ? 'The review time limit expired while waiting for your input. No answer was assumed.' : 'The review time limit expired. Calls still running were cancelled.',
35
+ 'round-cap': 'The planned rounds are complete.', consensus: 'The seats reached the same position.',
36
+ stable: 'The positions stopped changing and the seats had nothing new to add.',
37
+ 'all-pass': 'All remaining seats had nothing new to add.',
38
+ 'all-failed': 'No seat returned a usable reply in the last round.',
39
+ 'seats-down': 'Too few providers remained available for another discussion round.',
40
+ budget: 'The budget prevented another call.',
41
+ 'research-failed': 'Research did not produce a usable saved source bundle. Discussion was not started.',
42
+ };
43
+ return labels[reason] || 'The discussion has ended.';
44
+ }
45
+
46
+ export function cancellationReason(reply, stopReason, lastRound) {
47
+ if (reply.cancelReason) return reply.cancelReason;
48
+ // Older transcripts did not distinguish an expected cancellation from a provider failure.
49
+ if (reply.failed && reply.errorKind === 'killed' && reply.round === lastRound && ['human', 'wall-clock'].includes(stopReason)) return stopReason;
50
+ return null;
51
+ }
52
+
53
+ export function remainingWindow(quota) {
54
+ const windows = (quota?.windows || []).filter((w) => typeof w.used === 'number' && Number.isFinite(w.used));
55
+ if (!windows.length) return null;
56
+ const window = windows.reduce((a, b) => a.used > b.used ? a : b);
57
+ return { label: window.label, remaining: Math.max(0, Math.min(100, (1 - window.used) * 100)), at: quota.at };
58
+ }
59
+
60
+ // Literal emphasis, not sentiment analysis. Section labels carry the meaning as well as color.
61
+ export function argumentHtml(text, section) {
62
+ const pattern = section === 'disagree'
63
+ ? /\b(?:not|never|cannot|can['’]t|don['’]t|doesn['’]t|isn['’]t|aren['’]t|won['’]t|shouldn['’]t|reject(?:ed|s|ing)?|avoid|unsupported)\b/giu
64
+ : section === 'agree' ? /\b(?:agree|accept|support|supported|feasible|benefit(?:s)?)\b/giu : null;
65
+ if (!pattern) return escapeHtml(text);
66
+ let out = '', at = 0;
67
+ for (const match of String(text).matchAll(pattern)) {
68
+ out += escapeHtml(text.slice(at, match.index));
69
+ const negated = section === 'agree' && /\b(?:not|never|no|don['’]t|doesn['’]t|cannot)(?:\s+\w+){0,2}\s*$/iu.test(text.slice(Math.max(0, match.index - 40), match.index));
70
+ out += negated ? escapeHtml(match[0]) : `<strong class="term-${section}">${escapeHtml(match[0])}</strong>`;
71
+ at = match.index + match[0].length;
72
+ }
73
+ return out + escapeHtml(text.slice(at));
74
+ }
75
+
76
+ // Which session cue a board event earns: 'verdict', 'cancelled', 'ended' or 'failed'. Replayed
77
+ // history never plays, and neither does a fatal error, which only comes from opening a saved
78
+ // transcript that cannot be read. The engine sends no 'done' after an error, so cues never stack.
79
+ const SEATS_LOST = new Set(['all-failed', 'seats-down']);
80
+ export function ceremonyOutcome(event) {
81
+ if (!event || event.replayed) return null;
82
+ if (event.type === 'error') return event.fatal ? null : 'failed';
83
+ if (event.type !== 'done') return null;
84
+ // Explicit cancellation wins even if an older server finished a verdict after Stop.
85
+ // A separately requested, completed verdict can still succeed after a stopped discussion.
86
+ if (event.cancelled === true || event.cancelled == null && event.reason === 'human') return 'cancelled';
87
+ if (event.synthesis === 'done') return 'verdict';
88
+ if (event.synthesis === 'failed' || SEATS_LOST.has(event.reason)) return 'failed';
89
+ return 'ended';
90
+ }
91
+
92
+
93
+ // Presentation only: the saved verdict and its machine-readable contract are unchanged.
94
+ // Untagged questions from older reviews stay visible; we cannot infer their importance.
95
+ const absent = (text) => /^(?:none|n\/?a|not applicable|нет|не применимо|[-–—])[.!]?$/iu.test(text.replace(/[*_`]/g, '').trim());
96
+ function verdictItems(lines = []) {
97
+ const items = [];
98
+ for (const line of lines) {
99
+ if (!line.trim()) continue;
100
+ const bullet = /^\s*(?:[-*•]|\d+[.)])\s+(.*)$/.exec(line);
101
+ if (bullet || !items.length) items.push((bullet?.[1] ?? line).trim());
102
+ else items[items.length - 1] += ` ${line.trim()}`;
103
+ }
104
+ return items.filter((item) => !absent(item));
105
+ }
106
+
107
+ export function verdictView(text, options = []) {
108
+ const sections = verdictSections(text);
109
+ const lines = [...(sections.RECOMMENDATION || [])];
110
+ while (lines.length && !lines[0].trim()) lines.shift();
111
+ const pick = recommendationPick(lines[0], options);
112
+ if (pick) lines.shift(); // only remove a recognized grammar line, never legacy prose
113
+ const paragraphs = lines.join('\n').trim().split(/\n\s*\n/).map((p) => p.trim()).filter(Boolean);
114
+ const questions = { needed: [], optional: [], unclassified: [] };
115
+ for (const item of verdictItems(sections.OPEN_QUESTIONS)) {
116
+ const tag = /^\*{0,2}(NEEDED|OPTIONAL)\*{0,2}:\*{0,2}\s*(.*)$/i.exec(item);
117
+ const question = (tag ? tag[2] : item).trim();
118
+ if (question && !absent(question)) questions[tag ? tag[1].toLowerCase() : 'unclassified'].push(question);
119
+ }
120
+ return {
121
+ parsed: Object.keys(sections).length > 0,
122
+ pick,
123
+ option: pick && pick !== 'none' ? optionLabel(pick, options) : null,
124
+ answer: paragraphs[0] || '',
125
+ reasons: paragraphs.slice(1),
126
+ dissent: verdictItems(sections.DISSENT),
127
+ reconsider: verdictItems(sections.KILL_CRITERIA),
128
+ questions,
129
+ };
130
+ }
131
+
132
+ // Visible search draft only: private context and discussion answers are never copied here.
133
+ export function defaultSearchQuery(fieldNotes = {}) {
134
+ const question = String(fieldNotes.decision || '').trim();
135
+ const options = (fieldNotes.options || []).map((option) => String(option).trim()).filter(Boolean);
136
+ return [question, options.length ? `Options to investigate:\n${options.map((option) => `- ${option}`).join('\n')}` : ''].filter(Boolean).join('\n\n');
137
+ }
138
+
139
+ export function verdictHeadline(view) {
140
+ return view.pick === 'none' ? 'NO SINGLE OPTION RECOMMENDED' : view.option ? view.option.toLocaleUpperCase() : '';
141
+ }
142
+
143
+ // Copy the conclusion from source data, not the DOM: no hidden tooltips, seat cards or paths.
144
+ export function verdictResultText(text, options = [], question = '') {
145
+ const v = verdictView(text, options);
146
+ if (!v.parsed) return String(text || '').trim();
147
+ const parts = question.trim() ? [question.trim(), ''] : [];
148
+ parts.push('Answer', ...(verdictHeadline(v) ? [verdictHeadline(v)] : []), v.answer || 'No conclusion text was provided.');
149
+ if (v.reasons.length) parts.push('', 'Why', v.reasons.join('\n\n'));
150
+ const list = (title, items) => { if (items.length) parts.push('', title, ...items.map((item) => `- ${item}`)); };
151
+ list('Main reservation', v.dissent);
152
+ list('What could change this', v.reconsider);
153
+ list('Clarification needed from you', v.questions.needed);
154
+ list('Questions for you', v.questions.unclassified);
155
+ list('Further exploration (optional)', v.questions.optional);
156
+ return parts.join('\n').trim();
157
+ }
package/src/seats.js ADDED
@@ -0,0 +1,272 @@
1
+ // Seats are instances: one vendor CLI, one session, one lens, one mood, one model, one effort.
2
+ // This module turns what the setup screen posts into the seat list the engine runs, and
3
+ // rejects what would break anonymity or the CLIs. Pure, so it is unit tested.
4
+
5
+ export const MOODS = {
6
+ grill: 'Your default posture is sceptical: attack assumptions, demand evidence, and do not agree until the case is made.',
7
+ steelman: 'Before judging an option, state its strongest version; then say where even that version fails.',
8
+ builder: 'Judge by what it takes to ship: effort, sequencing, what breaks first.',
9
+ contrarian: 'Take the stance the other seats are least likely to take, and defend it with evidence.',
10
+ };
11
+ export const DEFAULT_MOOD = 'grill';
12
+
13
+ // A custom mood matching a persona code uses its prompt, avatar and light model settings.
14
+ // The server opts board requests into these shortcuts; ordinary API requests keep custom text.
15
+ import { HIDDEN_MOODS } from './personas.js';
16
+ import { modelEfforts } from './adapters/models.js';
17
+ export { HIDDEN_MOODS } from './personas.js';
18
+ export function hiddenMood(text) {
19
+ const word = String(text || '').trim().toLowerCase();
20
+ return word ? HIDDEN_MOODS.find((h) => h.codes.includes(word)) || null : null;
21
+ }
22
+ // Remove complete parentheticals in one pass, preserving unmatched openings. A regex that
23
+ // searches again after every unmatched opening or whitespace character can stall the server.
24
+ export function filterReply(mood, text) {
25
+ const h = mood?.hidden && HIDDEN_MOODS.find((x) => x.art === mood.hidden);
26
+ if (!h?.stripParentheticals) return text;
27
+ const body = String(text), parts = [];
28
+ let from = 0, search = 0;
29
+ while (search < body.length) {
30
+ const open = body.indexOf('(', search);
31
+ if (open < 0) break;
32
+ const close = body.indexOf(')', open + 1);
33
+ if (close < 0) break;
34
+ let start = open;
35
+ while (start > from && /\s/.test(body[start - 1])) start--;
36
+ parts.push(body.slice(from, start));
37
+ from = search = close + 1;
38
+ }
39
+ return parts.join('') + body.slice(from);
40
+ }
41
+ // The cheapest model each CLI offers (adapters/models.js marks claude's and codex's lightest).
42
+ // agy: Flash-low over gpt-oss-120b-medium, measured on a hidden-mood prompt: the same input,
43
+ // but gpt-oss wrote 1.7k-4k output tokens to Flash-low's ~190, and took two to four times longer.
44
+ export const LIGHTEST = { claude: 'claude-haiku-4-5', codex: 'gpt-6-luna', agy: 'gemini-3.8-flash-low' };
45
+ // agy's list depends on the account (`agy models`): a Flash-Lite model, where offered, is lighter
46
+ // than Flash, and a family's "-low" level is its lightest. LIGHTEST.agy when the list has neither.
47
+ export function lightestModel(vendor, models = []) {
48
+ if (vendor !== 'agy') return LIGHTEST[vendor];
49
+ const rank = (id) => (/flash-lite/.test(id) ? 0 : 3) + (/-low$/.test(id) ? 0 : /-medium$/.test(id) ? 1 : 2);
50
+ const ids = models.map((m) => m?.id || m).filter((id) => /^gemini-.*flash/.test(String(id)));
51
+ return ids.sort((a, b) => rank(a) - rank(b))[0] || LIGHTEST.agy;
52
+ }
53
+
54
+ const STARTER_LENSES = ['risk and failure modes', 'cost and effort', 'evidence and assumptions',
55
+ 'long-term effects', 'practical steps', 'alternatives', 'people affected', 'opportunities'];
56
+ export function nextSeatLens(seats) {
57
+ const used = new Set(seats.map((s) => String(s?.lens || '').trim().toLowerCase()));
58
+ const starter = STARTER_LENSES.find((lens) => !used.has(lens));
59
+ if (starter) return starter;
60
+ let n = seats.length + 1;
61
+ while (used.has(`perspective ${n}`)) n++;
62
+ return `perspective ${n}`;
63
+ }
64
+
65
+ export function fillMissingSeatLenses(seats) {
66
+ const out = seats.map((s) => ({ ...s }));
67
+ for (const seat of out) if (!String(seat.lens || '').trim()) seat.lens = nextSeatLens(out);
68
+ return out;
69
+ }
70
+
71
+ // Shared by the form and API, with field coordinates rather than private input in errors.
72
+ export function seatSetupIssue(seats) {
73
+ const seen = new Map();
74
+ for (const [seatIndex, seat] of seats.entries()) {
75
+ if (!seat) continue;
76
+ const lens = String(seat.lens || '').trim().toLowerCase();
77
+ if (!lens) return { seatIndex, field: 'lens', code: 'SEAT_LENS_REQUIRED',
78
+ message: `Seat ${seatIndex + 1} needs a lens. Give it a perspective, such as risk, evidence or long-term effects.` };
79
+ if (seen.has(lens)) return { seatIndex, field: 'lens', code: 'SEAT_LENS_DUPLICATE',
80
+ message: `Seats ${seen.get(lens) + 1} and ${seatIndex + 1} share the lens. Give each seat a different perspective.` };
81
+ seen.set(lens, seatIndex);
82
+ const preset = typeof seat.mood === 'string' ? seat.mood : seat.mood?.preset;
83
+ const text = typeof seat.mood === 'object' ? seat.mood?.text : seat.moodText;
84
+ if (preset === 'custom' && !String(text || '').trim()) return { seatIndex, field: 'moodText', code: 'SEAT_MOOD_REQUIRED',
85
+ message: `Seat ${seatIndex + 1} needs a custom mood sentence. Describe how it should argue, or choose a preset.` };
86
+ }
87
+ return null;
88
+ }
89
+
90
+ // Seat colours from the signature-design accent family, cycled when a table has more seats.
91
+ export const COLOURS = ['rust', 'olive', 'teal', 'dusk', 'steel'];
92
+
93
+ // First use starts small. Prefer ready default logins; one ready vendor can fill both seats.
94
+ // With no ready result yet, show two editable CLI choices and their status lamps.
95
+ export function initialSeatVendors(vendors, checks = []) {
96
+ const cli = vendors.filter((v) => v.kind !== 'api').map((v) => v.vendor);
97
+ const ready = cli.filter((v) => checks.some((p) => p.key === v && p.state === 'ok'));
98
+ const choices = ready.length ? ready : cli;
99
+ return choices.length ? [choices[0], choices[1] || choices[0]] : [];
100
+ }
101
+
102
+ // A profile name is a directory name under the roundtable data dir, so it is kept plain.
103
+ // (Here rather than in adapters/spawn.js because this module also runs in the browser.)
104
+ export const PROFILE_RE = /^[a-z0-9][a-z0-9_.-]{0,39}$/i;
105
+
106
+ // The quota pool a seat draws from, as a key: the vendor's own login is `codex`; a named profile
107
+ // with its own login is `codex@work`. A vendor whose CLI has no separate state directory
108
+ // (`profiles` false: agy) has one pool whatever the seat says. Quota snapshots, calibration and
109
+ // the board's pool rows are all keyed by it.
110
+ export function poolKey(vendor, account, profiles = true) {
111
+ return account && profiles ? `${vendor}@${account}` : vendor;
112
+ }
113
+ export const poolVendor = (key) => String(key).split('@')[0].split(':')[0];
114
+ export const poolAccount = (key) => String(key).split(':')[0].split('@')[1] || null;
115
+
116
+ // An optional profile name: the seat then runs with its own CLI state directory under the OS
117
+ // user-data dir (adapters/spawn.js profileHome), which the user logged in themselves. Empty
118
+ // means the vendor's real state directory, shared with the terminal.
119
+ export function normaliseAccount(raw) {
120
+ const name = String(raw || '').trim();
121
+ if (!name) return undefined;
122
+ if (!PROFILE_RE.test(name)) throw new Error(`Account profile "${name}" must be 1-40 letters, digits, dots, dashes or underscores.`);
123
+ return name;
124
+ }
125
+
126
+ // The one line a seat sees about how to approach the question. Custom text wins when given.
127
+ export function moodLine(mood) {
128
+ if (!mood) return MOODS[DEFAULT_MOOD];
129
+ if (typeof mood === 'string') return MOODS[mood] || MOODS[DEFAULT_MOOD];
130
+ if (mood.hidden) { const h = HIDDEN_MOODS.find((x) => x.art === mood.hidden); if (h) return h.text; }
131
+ if (mood.preset === 'custom' && mood.text) return String(mood.text).trim();
132
+ return MOODS[mood.preset] || MOODS[DEFAULT_MOOD];
133
+ }
134
+
135
+ // hidden: whether this request may unlock a hidden mood (only the board's own requests may).
136
+ function normaliseMood(m, { hidden = false } = {}) {
137
+ if (!m) return { preset: DEFAULT_MOOD };
138
+ if (typeof m === 'string') return MOODS[m] ? { preset: m } : { preset: DEFAULT_MOOD };
139
+ if (m.preset === 'custom') {
140
+ const text = String(m.text || '').trim();
141
+ if (!text) throw new Error('A custom mood needs its sentence.');
142
+ const h = hidden ? hiddenMood(text) : null;
143
+ return { preset: 'custom', text: text.slice(0, 400), ...(h ? { hidden: h.art } : {}) };
144
+ }
145
+ return { preset: MOODS[m.preset] ? m.preset : DEFAULT_MOOD };
146
+ }
147
+
148
+ // ---------- API endpoint seats ----------
149
+ // A seat on an OpenAI-compatible endpoint names a base URL, a model and, for a hosted API, the
150
+ // NAME of the environment variable that holds the key (never the key). These rules are shared
151
+ // with the browser, so the card can say the same thing the server will.
152
+
153
+ export const KEY_ENV_RE = /^[A-Z_][A-Z0-9_]{0,63}$/; // an identifier: a pasted key is refused on shape alone
154
+ // Browser preferences must never retain an invalid value while the form reports its error.
155
+ export function keyEnvName(value) {
156
+ const name = typeof value === 'string' ? value.trim() : '';
157
+ return KEY_ENV_RE.test(name) ? name : '';
158
+ }
159
+
160
+ // On this machine or on a private network: loopback, link-local, RFC 1918, ULA, a bare LAN
161
+ // name, or the local-only suffixes. Plain http is allowed only here, so a key never crosses the
162
+ // open internet in clear.
163
+ export function privateHost(hostname) {
164
+ const h = String(hostname || '').toLowerCase().replace(/^\[|\]$/g, '');
165
+ if (h === 'localhost' || h === '::1' || h === '0.0.0.0' || h === '::') return true;
166
+ if (/\.(localhost|local|test|internal|lan|home|home\.arpa)$/.test(h)) return true;
167
+ if (!h.includes('.') && !h.includes(':')) return true; // a bare LAN hostname
168
+ // URL callers already canonicalize alternate IPv4 spellings. Check the whole address:
169
+ // a DNS name such as 10.api.example.com must not inherit a private address's HTTP policy.
170
+ const ipv4 = /^(?:\d{1,3}\.){3}\d{1,3}$/.test(h) ? h.split('.').map(Number) : null;
171
+ if (ipv4 && ipv4.every((n) => n <= 255)) {
172
+ const [a, b] = ipv4;
173
+ return a === 127 || a === 10 || (a === 192 && b === 168) || (a === 169 && b === 254) || (a === 172 && b >= 16 && b <= 31);
174
+ }
175
+ if (/^(fc|fd)[0-9a-f]{2}:/.test(h) || /^fe80:/.test(h)) return true;
176
+ return false;
177
+ }
178
+
179
+ // The base URL as the adapter will use it ("…/v1", no trailing slash), or a thrown reason.
180
+ export function checkEndpointUrl(raw) {
181
+ const text = String(raw || '').trim();
182
+ if (!text) throw new Error('An API seat needs its endpoint: the base URL, for example http://localhost:11434/v1.');
183
+ let u;
184
+ try { u = new URL(text); } catch { throw new Error(`Endpoint "${text}" is not a URL.`); }
185
+ if (u.protocol !== 'https:' && u.protocol !== 'http:') throw new Error(`Endpoint ${text}: http or https only.`);
186
+ if (u.protocol === 'http:' && !privateHost(u.hostname)) throw new Error(`Endpoint ${u.host}: plain http only to this machine or a private network; a hosted API must be https.`);
187
+ if (u.username || u.password) throw new Error('Endpoint: credentials belong in an environment variable, not in the URL.');
188
+ if (u.search || u.hash) throw new Error('Endpoint: a base URL, without a query or a fragment.');
189
+ return u.origin + u.pathname.replace(/\/+$/, '');
190
+ }
191
+
192
+ // The pool identity of an endpoint: its host, as a profile-shaped name (poolKey, PROFILE_RE):
193
+ // api.deepseek.com, localhost-11434. One pool per host, so a host that fails benches only itself.
194
+ export function hostKey(url) {
195
+ let u; try { u = new URL(String(url || '').trim()); } catch { return ''; }
196
+ const name = `${u.hostname.replace(/^\[|\]$/g, '')}${u.port ? `-${u.port}` : ''}`.toLowerCase().replace(/[^a-z0-9_.-]+/g, '-').replace(/^[^a-z0-9]+/, '');
197
+ return name.slice(0, 40);
198
+ }
199
+
200
+ // What the card and the record say about where a seat runs: the CLI on a subscription, an API
201
+ // on this machine, or an API that bills per token.
202
+ export function seatKind(vendorRow, endpoint = null) {
203
+ if (vendorRow?.kind !== 'api') return 'CLI · subscription';
204
+ let u; try { u = new URL(String(endpoint || '')); } catch { return 'API'; }
205
+ return privateHost(u.hostname) ? 'API · local' : 'API · pay-per-token';
206
+ }
207
+
208
+ function normaliseEndpoint(raw, label) {
209
+ const endpoint = checkEndpointUrl(raw.endpoint);
210
+ const keyEnv = String(raw.keyEnv || '').trim();
211
+ if (keyEnv && !KEY_ENV_RE.test(keyEnv)) throw new Error(`${label}: the key variable must be the NAME of an environment variable (letters, digits, underscores), not the key itself.`);
212
+ if (!keyEnv && !privateHost(new URL(endpoint).hostname)) throw new Error(`${label}: a hosted API needs the name of the environment variable that holds its key.`);
213
+ const model = raw.model ? String(raw.model).trim() : '';
214
+ if (!model) throw new Error(`${label}: an API seat needs a model id (the endpoint has no default).`);
215
+ return { endpoint, keyEnv: keyEnv || undefined, model, account: hostKey(endpoint) };
216
+ }
217
+
218
+ // vendors: { [vendor]: { label, effortLevels } } (the adapter table). efforts may widen a
219
+ // vendor's levels from the catalogue (agy's come from its model ids); models is the
220
+ // catalogue's model list per vendor, for the lightest model a hidden mood runs on.
221
+ export function normaliseSeats(input, { vendors, efforts = {}, models = {}, hidden = false }) {
222
+ const issue = seatSetupIssue((input || []).map((raw) => raw && vendors[raw.vendor] ? raw : null));
223
+ if (issue) throw Object.assign(new Error(issue.message), issue, { status: 400 });
224
+ const seats = [];
225
+ const perVendor = {};
226
+ for (const raw of input || []) {
227
+ if (!raw || !vendors[raw.vendor]) continue;
228
+ const lens = String(raw.lens || '').trim();
229
+ const allowed = modelEfforts(raw.vendor, raw.model, efforts[raw.vendor] || vendors[raw.vendor].effortLevels || []);
230
+ const effort = raw.effort ? String(raw.effort).trim() : null;
231
+ if (effort && !allowed.includes(effort)) throw new Error(`${vendors[raw.vendor].label}${raw.model ? ` ${String(raw.model).trim()}` : ''} does not accept effort "${effort}" (allowed: ${allowed.join(', ')}).`);
232
+ perVendor[raw.vendor] = (perVendor[raw.vendor] || 0) + 1;
233
+ // an endpoint seat: URL, model and key variable checked; its account is its host
234
+ const ep = vendors[raw.vendor].kind === 'api' ? normaliseEndpoint(raw, `Seat "${lens}"`) : null;
235
+ const mood = normaliseMood(raw.mood, { hidden });
236
+ // a hidden mood always runs as cheaply as the seat's vendor allows
237
+ const locked = mood.hidden ? { model: ep ? ep.model : lightestModel(raw.vendor, models[raw.vendor]) || (raw.model ? String(raw.model).trim() : undefined), effort: allowed[0] || undefined } : null;
238
+ seats.push({
239
+ id: `seat-${seats.length}`,
240
+ vendor: raw.vendor,
241
+ n: perVendor[raw.vendor],
242
+ lens,
243
+ mood,
244
+ model: locked ? locked.model : ep ? ep.model : raw.model ? String(raw.model).trim() : undefined,
245
+ effort: locked ? locked.effort : effort || undefined,
246
+ account: ep ? ep.account : normaliseAccount(raw.account),
247
+ ...(ep ? { endpoint: ep.endpoint, keyEnv: ep.keyEnv } : {}),
248
+ // the vendor's colour (adapters config) unless the board sent one; positions as a fallback
249
+ colour: COLOURS.includes(raw.colour) ? raw.colour : (vendors[raw.vendor].colour || COLOURS[seats.length % COLOURS.length]),
250
+ });
251
+ }
252
+ if (seats.length < 2) throw new Error('Seat at least two instances, each with a lens.');
253
+ // Human-side label only: "Codex 2" when a vendor is seated more than once; an endpoint seat
254
+ // is named by its host ("API localhost-11434"). Never in a prompt.
255
+ const perBase = {};
256
+ for (const s of seats) { s.base = s.endpoint ? `API ${s.account}` : vendors[s.vendor].label; perBase[s.base] = (perBase[s.base] || 0) + 1; }
257
+ const seen2 = {};
258
+ for (const s of seats) { seen2[s.base] = (seen2[s.base] || 0) + 1; s.label = perBase[s.base] > 1 ? `${s.base} ${seen2[s.base]}` : s.base; delete s.base; }
259
+ return seats;
260
+ }
261
+
262
+ // role: who the error names; the searcher's settings are checked here too
263
+ export function normaliseChairman(raw, { vendors, efforts = {}, role = 'Chairman' }) {
264
+ const vendor = raw?.vendor && vendors[raw.vendor] ? raw.vendor : null;
265
+ if (!vendor) return null;
266
+ const allowed = modelEfforts(vendor, raw.model, efforts[vendor] || vendors[vendor].effortLevels || []);
267
+ const effort = raw.effort ? String(raw.effort).trim() : null;
268
+ if (effort && !allowed.includes(effort)) throw new Error(`${role}: ${vendors[vendor].label}${raw.model ? ` ${String(raw.model).trim()}` : ''} does not accept effort "${effort}".`);
269
+ const ep = vendors[vendor].kind === 'api' ? normaliseEndpoint(raw, role) : null;
270
+ return { vendor, colour: vendors[vendor].colour || undefined, model: ep ? ep.model : raw.model ? String(raw.model).trim() : undefined, effort: effort || undefined,
271
+ account: ep ? ep.account : normaliseAccount(raw.account), ...(ep ? { endpoint: ep.endpoint, keyEnv: ep.keyEnv } : {}) };
272
+ }