@prplkane/roundtable 0.0.0-stage → 0.6.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 +513 -0
- package/LICENSE +21 -0
- package/README.md +188 -3
- package/SECURITY.md +299 -0
- package/docs/workflow.md +1325 -0
- package/package.json +52 -6
- package/scripts/agent.mjs +40 -0
- package/scripts/export.mjs +20 -0
- package/scripts/mcp.mjs +175 -0
- package/scripts/run-debate.mjs +70 -0
- package/scripts/server.mjs +28 -0
- package/scripts/setup.mjs +182 -0
- package/skills/roundtable/SKILL.md +55 -0
- package/src/adapters/agy.js +7 -0
- package/src/adapters/catalogue.js +49 -0
- package/src/adapters/claude.js +6 -0
- package/src/adapters/codex.js +6 -0
- package/src/adapters/common.js +304 -0
- package/src/adapters/config.js +432 -0
- package/src/adapters/endpoint.js +178 -0
- package/src/adapters/errors.js +69 -0
- package/src/adapters/index.js +11 -0
- package/src/adapters/models.js +106 -0
- package/src/adapters/quota.js +305 -0
- package/src/adapters/spawn.js +178 -0
- package/src/agent-client.js +108 -0
- package/src/cli.js +44 -0
- package/src/contract.js +256 -0
- package/src/diagnostics.js +133 -0
- package/src/doctor.js +96 -0
- package/src/engine.js +902 -0
- package/src/estimate.js +331 -0
- package/src/export.js +307 -0
- package/src/fake.js +75 -0
- package/src/folder-picker.js +46 -0
- package/src/history.js +220 -0
- package/src/local-http.js +39 -0
- package/src/local-server.js +82 -0
- package/src/notes.js +16 -0
- package/src/personas.js +49 -0
- package/src/preflight.js +126 -0
- package/src/progress.js +30 -0
- package/src/prompts.js +278 -0
- package/src/research.js +113 -0
- package/src/review-view.js +157 -0
- package/src/seats.js +272 -0
- package/src/server.js +913 -0
- package/src/settings.js +27 -0
- package/src/setup-toml.js +124 -0
- package/src/snapshot.js +93 -0
- package/src/store.js +303 -0
- package/src/terminal.js +53 -0
- package/src/terms.js +52 -0
- package/src/ui/app.js +1967 -0
- package/src/ui/avatars.js +132 -0
- package/src/ui/ceremony.js +87 -0
- package/src/ui/design/app.css +714 -0
- package/src/ui/design/preset-dusk.css +91 -0
- package/src/ui/design/tokens.css +129 -0
- package/src/ui/diagnostics.js +61 -0
- package/src/ui/help.js +122 -0
- package/src/ui/icons.js +64 -0
- package/src/ui/index.html +272 -0
- package/src/ui/loader.js +73 -0
- package/src/ui/logo.svg +4 -0
- package/src/ui/scan.js +57 -0
- package/src/ui/scribe.js +64 -0
- package/src/ui/sound.js +80 -0
- package/src/validate.js +112 -0
- package/src/version.js +5 -0
package/src/settings.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Preserve unreadable settings until the user repairs them and restarts the server.
|
|
2
|
+
import { readFileSync, writeFileSync, renameSync, existsSync, unlinkSync, statSync } from 'node:fs';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
|
|
6
|
+
export function createSettingsStore(file, { onError = () => {} } = {}) {
|
|
7
|
+
let value = {}, unreadable = false;
|
|
8
|
+
try {
|
|
9
|
+
value = JSON.parse(readFileSync(file, 'utf8'));
|
|
10
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('Expected a settings object.');
|
|
11
|
+
} catch (error) {
|
|
12
|
+
value = {};
|
|
13
|
+
if (error.code !== 'ENOENT') { unreadable = true; onError(error); }
|
|
14
|
+
}
|
|
15
|
+
return {
|
|
16
|
+
value,
|
|
17
|
+
save(next) {
|
|
18
|
+
if (unreadable) throw Object.assign(new Error(`${path.basename(file)} was unreadable at startup. Restore it and restart before saving; the original has not been changed.`), { code: 'SETTINGS_UNREADABLE', status: 409 });
|
|
19
|
+
if (existsSync(file) && statSync(file).isDirectory()) throw Object.assign(new Error('The settings target is a directory.'), { code: 'EISDIR' });
|
|
20
|
+
const temporary = `${file}.${randomUUID()}.tmp`;
|
|
21
|
+
try {
|
|
22
|
+
writeFileSync(temporary, JSON.stringify(next, null, 2), { flag: 'wx', mode: 0o600, flush: true });
|
|
23
|
+
renameSync(temporary, file);
|
|
24
|
+
} finally { if (existsSync(temporary)) unlinkSync(temporary); }
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// A lossless TOML declaration scanner, not a value deserializer. Values remain untouched;
|
|
2
|
+
// quoted/multiline strings, comments and containers cannot impersonate table/key declarations.
|
|
3
|
+
// We only add a fresh table after proving that no existing declaration owns that namespace.
|
|
4
|
+
export const MCP_BEGIN = '# BEGIN Roundtable managed MCP';
|
|
5
|
+
export const MCP_END = '# END Roundtable managed MCP';
|
|
6
|
+
const fail = () => { throw new Error('Unsupported or malformed TOML configuration; it was not changed.'); };
|
|
7
|
+
|
|
8
|
+
function declarations(text) {
|
|
9
|
+
let i = 0, context = [];
|
|
10
|
+
const entries = [], markers = [];
|
|
11
|
+
const spaces = () => { while (text[i] === ' ' || text[i] === '\t' || text[i] === '\r') i++; };
|
|
12
|
+
function comment(top = false) {
|
|
13
|
+
const start = i;
|
|
14
|
+
while (i < text.length && text[i] !== '\n') i++;
|
|
15
|
+
const content = text.slice(start, i).trimEnd();
|
|
16
|
+
if (top && [MCP_BEGIN, MCP_END].includes(content)) markers.push({ text: content, start, end: start + content.length });
|
|
17
|
+
}
|
|
18
|
+
function quoted(key = false) {
|
|
19
|
+
const start = i, quote = text[i++], triple = text.slice(i, i + 2) === quote.repeat(2);
|
|
20
|
+
if (triple && key) fail();
|
|
21
|
+
if (triple) i += 2;
|
|
22
|
+
let value = '';
|
|
23
|
+
while (i < text.length) {
|
|
24
|
+
const c = text[i++];
|
|
25
|
+
if (!triple && (c === '\n' || c === '\r')) fail();
|
|
26
|
+
if (c === quote) {
|
|
27
|
+
if (!triple) return value;
|
|
28
|
+
if (text.slice(i, i + 2) === quote.repeat(2)) {
|
|
29
|
+
i += 2;
|
|
30
|
+
for (let n = 0; n < 2 && text[i] === quote; n++) i++;
|
|
31
|
+
return text.slice(start, i);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
if (quote === '"' && c === '\\') {
|
|
35
|
+
const escaped = text[i++];
|
|
36
|
+
if (key) {
|
|
37
|
+
if (escaped === 'u' || escaped === 'U') {
|
|
38
|
+
const n = escaped === 'u' ? 4 : 8, hex = text.slice(i, i + n);
|
|
39
|
+
if (hex.length !== n || !/^[0-9a-f]+$/i.test(hex)) fail();
|
|
40
|
+
const point = parseInt(hex, 16);
|
|
41
|
+
if (point > 0x10ffff || point >= 0xd800 && point <= 0xdfff) fail();
|
|
42
|
+
value += String.fromCodePoint(point); i += n;
|
|
43
|
+
} else {
|
|
44
|
+
const escapes = { b: '\b', t: '\t', n: '\n', f: '\f', r: '\r', '"': '"', '\\': '\\' };
|
|
45
|
+
if (!Object.hasOwn(escapes, escaped)) fail();
|
|
46
|
+
value += escapes[escaped];
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
} else value += c;
|
|
50
|
+
}
|
|
51
|
+
fail();
|
|
52
|
+
}
|
|
53
|
+
function keyPath() {
|
|
54
|
+
const keys = [];
|
|
55
|
+
while (true) {
|
|
56
|
+
spaces();
|
|
57
|
+
if (text[i] === '"' || text[i] === "'") keys.push(quoted(true));
|
|
58
|
+
else {
|
|
59
|
+
const start = i;
|
|
60
|
+
while (i < text.length && /[A-Za-z0-9_-]/.test(text[i])) i++;
|
|
61
|
+
if (i === start) fail();
|
|
62
|
+
keys.push(text.slice(start, i));
|
|
63
|
+
}
|
|
64
|
+
spaces();
|
|
65
|
+
if (text[i] !== '.') return keys;
|
|
66
|
+
i++;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
function value() {
|
|
70
|
+
spaces();
|
|
71
|
+
if (i >= text.length || text[i] === '#' || text[i] === '\n') fail();
|
|
72
|
+
const stack = [];
|
|
73
|
+
while (i < text.length) {
|
|
74
|
+
const c = text[i];
|
|
75
|
+
if (c === '\n' && !stack.length) return;
|
|
76
|
+
if (c === '#' && !stack.length) { comment(); return; }
|
|
77
|
+
if (c === '#') { comment(); continue; }
|
|
78
|
+
if (c === '"' || c === "'") { quoted(); continue; }
|
|
79
|
+
if (c === '[' || c === '{') stack.push(c);
|
|
80
|
+
if (c === ']' || c === '}') { if (stack.pop() !== (c === ']' ? '[' : '{')) fail(); }
|
|
81
|
+
i++;
|
|
82
|
+
}
|
|
83
|
+
if (stack.length) fail();
|
|
84
|
+
}
|
|
85
|
+
while (i < text.length) {
|
|
86
|
+
spaces();
|
|
87
|
+
if (text[i] === '\n') { i++; continue; }
|
|
88
|
+
if (i >= text.length) break;
|
|
89
|
+
if (text[i] === '#') { comment(true); continue; }
|
|
90
|
+
const start = i;
|
|
91
|
+
if (text[i] === '[') {
|
|
92
|
+
i++; const array = text[i] === '['; if (array) i++;
|
|
93
|
+
context = keyPath();
|
|
94
|
+
if (text[i++] !== ']' || array && text[i++] !== ']') fail();
|
|
95
|
+
entries.push({ start, path: context, table: true, array });
|
|
96
|
+
spaces(); if (text[i] === '#') comment();
|
|
97
|
+
if (i < text.length && text[i] !== '\n') fail();
|
|
98
|
+
} else {
|
|
99
|
+
const keys = keyPath();
|
|
100
|
+
if (text[i++] !== '=') fail();
|
|
101
|
+
entries.push({ start, path: [...context, ...keys], table: false });
|
|
102
|
+
value();
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return { entries, markers };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function tomlConnection(text) {
|
|
109
|
+
const { entries, markers } = declarations(text.replace(/^\uFEFF/, ' '));
|
|
110
|
+
if (markers.length && (markers.length !== 2 || markers[0].text !== MCP_BEGIN || markers[1].text !== MCP_END)) throw new Error('Roundtable configuration markers are damaged; repair them before setup.');
|
|
111
|
+
const start = markers[0]?.start, end = markers[1]?.end;
|
|
112
|
+
for (const d of entries) {
|
|
113
|
+
if (start != null && d.start > start && d.start < end) continue;
|
|
114
|
+
if (d.path[0] !== 'mcp_servers') continue;
|
|
115
|
+
if (d.path[1] === 'roundtable') throw new Error('An existing unmanaged Roundtable MCP entry was found. Keep it or remove it explicitly before using setup.');
|
|
116
|
+
if (d.path.length === 1 && (!d.table || d.array)) throw new Error('Inline or non-table MCP configuration must use TOML tables before setup; it was preserved.');
|
|
117
|
+
}
|
|
118
|
+
return { text, entry: start == null ? null : text.slice(start, end), start, end };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function replaceTomlConnection(current, entry) {
|
|
122
|
+
if (current.entry) return current.text.slice(0, current.start) + (entry || '') + current.text.slice(current.end);
|
|
123
|
+
return `${current.text}${current.text.endsWith('\n') || !current.text ? '' : '\n'}${current.text ? '\n' : ''}${entry}\n`;
|
|
124
|
+
}
|
package/src/snapshot.js
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { rollup, hasUsableRound, DEFAULTS } from './engine.js';
|
|
2
|
+
import { groupQuestions, stopExplanation, cancellationReason } from './review-view.js';
|
|
3
|
+
import { optionLabel, verdictSections } from './contract.js';
|
|
4
|
+
import { safeProgress } from './progress.js';
|
|
5
|
+
import { researchSources } from './research.js';
|
|
6
|
+
|
|
7
|
+
const RESEARCH_FAILURES = {
|
|
8
|
+
timeout: 'Research did not finish before its time limit.',
|
|
9
|
+
network: 'Research could not reach the provider.',
|
|
10
|
+
quota: 'The research provider reported a usage limit.',
|
|
11
|
+
auth: 'The research provider requires login.',
|
|
12
|
+
missing: 'The research provider CLI was not found.',
|
|
13
|
+
compatibility: 'The research provider does not support a required option.',
|
|
14
|
+
budget: 'Research was blocked by a budget or operation limit.',
|
|
15
|
+
policy: 'Research stopped after a provider-policy failure.',
|
|
16
|
+
format: 'Research did not produce a valid source bundle.',
|
|
17
|
+
killed: 'Research was cancelled.',
|
|
18
|
+
interrupted: 'Research was interrupted and was not rerun.',
|
|
19
|
+
other: 'Research failed. Open the board for details.',
|
|
20
|
+
};
|
|
21
|
+
const integerWithin = (value, min, max) => Number.isSafeInteger(value) && value >= min && value <= max;
|
|
22
|
+
|
|
23
|
+
// Project both live and saved research: failed transcript rows can contain arbitrary CLI text.
|
|
24
|
+
function researchSnapshot(research) {
|
|
25
|
+
if (research.status === 'running') return { status: 'running',
|
|
26
|
+
...(integerWithin(research.maxSearches, 1, 8) ? { maxSearches: research.maxSearches } : {}),
|
|
27
|
+
...(integerWithin(research.timeoutMs, 30000, 600000) ? { timeoutMs: research.timeoutMs } : {}) };
|
|
28
|
+
if (research.status === 'done') {
|
|
29
|
+
try {
|
|
30
|
+
const gatheredAt = new Date(research.gatheredAt).toISOString();
|
|
31
|
+
if (gatheredAt !== research.gatheredAt || !integerWithin(research.searches, 1, 8)) throw new Error('invalid metadata');
|
|
32
|
+
return { status: 'done', gatheredAt, searches: research.searches,
|
|
33
|
+
sources: researchSources(JSON.stringify({ sources: research.sources }), gatheredAt) };
|
|
34
|
+
} catch { return { status: 'failed', errorKind: 'format', error: RESEARCH_FAILURES.format }; }
|
|
35
|
+
}
|
|
36
|
+
const errorKind = research.status === 'interrupted' ? 'interrupted'
|
|
37
|
+
: Object.hasOwn(RESEARCH_FAILURES, research.errorKind) ? research.errorKind : 'other';
|
|
38
|
+
return { status: research.status === 'interrupted' ? 'interrupted' : 'failed', errorKind,
|
|
39
|
+
error: RESEARCH_FAILURES[errorKind], progress: safeProgress(research.progress),
|
|
40
|
+
...(integerWithin(research.searches, 0, 9) ? { searches: research.searches } : {}) };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// One compact, read-only view for local clients. Raw provider output and launch tokens stay out.
|
|
44
|
+
export function reviewSnapshot(d) {
|
|
45
|
+
const state = d.debate?.state || d.rebuilt;
|
|
46
|
+
const rounds = state.rounds || [];
|
|
47
|
+
const lastRound = rounds.length - 1;
|
|
48
|
+
const verdict = state.verdict;
|
|
49
|
+
const status = d.interrupted ? 'interrupted' : d.finished ? 'done' : state.status === 'error' ? 'error' : d.pending ? 'waiting'
|
|
50
|
+
: d.synthesizing || d.events.some((e) => e.type === 'stopped') && !d.finished ? 'verdict' : state.status || 'running';
|
|
51
|
+
const gate = d.pending?.info;
|
|
52
|
+
const sections = verdict && !verdict.failed && !verdict.skipped ? verdictSections(verdict.text) : null;
|
|
53
|
+
const usage = rollup(d.events).total;
|
|
54
|
+
return {
|
|
55
|
+
schemaVersion: 1, id: d.slug, revision: `${d.gen}:${d.events.length - 1}`, status,
|
|
56
|
+
decision: state.fieldNotes?.decision, options: state.fieldNotes?.options, round: state.round ?? lastRound,
|
|
57
|
+
depth: { plannedCritiqueRounds: state.options?.critiqueRounds ?? DEFAULTS.critiqueRounds,
|
|
58
|
+
attemptedRounds: rounds.filter((r) => r?.length === state.seats.length).length,
|
|
59
|
+
attemptedCritiqueRounds: rounds.slice(1).filter((r) => r?.length === state.seats.length).length,
|
|
60
|
+
completedRounds: rounds.filter((r) => hasUsableRound([r], state.seats.length)).length,
|
|
61
|
+
completedCritiqueRounds: rounds.slice(1).filter((r) => hasUsableRound([r], state.seats.length)).length },
|
|
62
|
+
deadlineAt: state.deadlineAt, stopReason: state.stopReason || null,
|
|
63
|
+
...(state.research ? { research: researchSnapshot(state.research) } : {}),
|
|
64
|
+
...(state.stopReason ? { explanation: stopExplanation(state.stopReason, state.stopContext) } : {}),
|
|
65
|
+
actions: d.finished ? (!verdict || verdict.failed || verdict.skipped) && hasUsableRound(rounds, state.seats.length) ? ['verdict'] : []
|
|
66
|
+
: d.interrupted ? ['resume'] : status === 'error' ? [] : [...(gate ? ['answer', 'hold'] : ['wait']), ...(d.debate?.canFinish() ? ['finish'] : []), 'stop'],
|
|
67
|
+
// reopenCalls: the seats already agree, so empty answers end the discussion; any answer text
|
|
68
|
+
// or message runs one more round at this many calls. Absent when answering cannot reopen.
|
|
69
|
+
gate: gate ? { round: gate.round, mode: gate.mode, reason: gate.reason, stop: gate.stop, questions: groupQuestions(gate.questions),
|
|
70
|
+
autoContinueAt: d.pending.autoContinueAt ?? null, timeoutBehavior: 'continue_without_answers',
|
|
71
|
+
...(gate.reopenable ? { reopenCalls: (state.seats || []).length } : {}) } : null,
|
|
72
|
+
nextAction: gate?.questions.length ? {
|
|
73
|
+
type: 'ask_user',
|
|
74
|
+
instruction: 'Ask gate.questions using your available user-input tool. If there is no form, ask in chat. Group overlapping questions without losing their IDs. Do not invent answers. Tell the user when the response window closes; unanswered questions remain unknown. Read status before submitting a reply because the review may have continued.',
|
|
75
|
+
answerTemplate: { round: gate.round, answers: groupQuestions(gate.questions).map((q) => ({ id: q.id, text: '' })) },
|
|
76
|
+
} : { type: gate || ['researching', 'running', 'verdict'].includes(status) ? 'wait'
|
|
77
|
+
: status === 'done' && sections ? 'report_result' : 'report_incomplete',
|
|
78
|
+
...(!gate && !['researching', 'running', 'verdict'].includes(status) && !(status === 'done' && sections)
|
|
79
|
+
? { instruction: 'The review has no completed chairman recommendation. Report its actual status and available actions; do not invent a verdict or automatically start another model call.' } : {}) },
|
|
80
|
+
replies: (rounds[lastRound] || []).map((r) => ({ lens: r.seat.lens, vendor: r.seat.vendor,
|
|
81
|
+
status: cancellationReason(r, state.stopReason, lastRound) ? 'cancelled' : r.failed ? 'failed' : r.valid ? 'replied' : 'invalid',
|
|
82
|
+
// a recovered stall on a complete reply: the provider connection was started again this many times
|
|
83
|
+
...(safeProgress(r.progress).reconnects ? { reconnects: safeProgress(r.progress).reconnects } : {}),
|
|
84
|
+
...(r.failed ? { reason: cancellationReason(r, state.stopReason, lastRound) || r.errorKind, progress: safeProgress(r.progress) } : r.valid ? {
|
|
85
|
+
position: optionLabel(r.stanceKey, state.fieldNotes?.options), agreesWith: r.parsed.agreeWith,
|
|
86
|
+
disagrees: r.parsed.disagree, newPoints: r.parsed.new, assumptions: r.parsed.assumptions,
|
|
87
|
+
} : {}) })),
|
|
88
|
+
verdict: verdict ? { status: verdict.skipped ? 'skipped' : verdict.failed ? 'failed' : 'ready',
|
|
89
|
+
...(sections ? { sections: Object.fromEntries(Object.entries(sections).map(([key, lines]) => [key, lines.filter((x) => x.trim()).join('\n')])) } : { reason: verdict.reason || verdict.errorKind || 'format' }) } : null,
|
|
90
|
+
usage: { input: usage.input, cacheRead: usage.cacheRead, cacheWrite: usage.cacheWrite, output: usage.output,
|
|
91
|
+
cost: usage.cost, tokenCoverage: usage.tokenCoverage, costCoverage: usage.costCoverage },
|
|
92
|
+
};
|
|
93
|
+
}
|
package/src/store.js
ADDED
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
import { mkdirSync, existsSync, appendFileSync, writeFileSync, readFileSync, readdirSync, renameSync, unlinkSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { readFile } from 'node:fs/promises';
|
|
4
|
+
import { fieldNotesText } from './prompts.js';
|
|
5
|
+
import { positions, ledgerEntries, synthesisOf } from './engine.js';
|
|
6
|
+
import { ADAPTERS } from './adapters/config.js';
|
|
7
|
+
import { optionId, humanQuestion } from './contract.js';
|
|
8
|
+
import { noteRecord, storedNote } from './notes.js';
|
|
9
|
+
import { researchSources } from './research.js';
|
|
10
|
+
|
|
11
|
+
// Usage rows written before usageVersion 3 carry the vendor's own input figure. Bring them to
|
|
12
|
+
// the one meaning (input = uncached) by the vendor's cache semantics. Idempotent: a row already
|
|
13
|
+
// at version 3 is left alone, so a legacy transcript with new rows appended reads correctly.
|
|
14
|
+
export function normaliseRows(rows) {
|
|
15
|
+
return rows.map((r) => {
|
|
16
|
+
if (!r?.usage || r.usage.usageVersion >= 3 || !r.seat?.vendor) return r;
|
|
17
|
+
const semantics = ADAPTERS[r.seat.vendor]?.cacheSemantics || 'unknown';
|
|
18
|
+
const input = semantics === 'subset' ? Math.max(0, (r.usage.input || 0) - (r.usage.cacheRead || 0)) : r.usage.input;
|
|
19
|
+
return { ...r, usage: { ...r.usage, input, usageVersion: 3, cacheSemantics: semantics, normalised: true } };
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// A transcript from scripted seats: marked fake on its head row (since 0.3.0), or, for older
|
|
24
|
+
// ones, any reply carrying a session id the fake runner made.
|
|
25
|
+
export function isSynthetic(rows) {
|
|
26
|
+
return rows.some((r) => (r.type === 'field-notes' && r.fake === true) || (typeof r.sessionId === 'string' && r.sessionId.startsWith('fake-')));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const reportTranscriptWarning = (warning) => console.warn(`[transcript] ${warning.message}`);
|
|
30
|
+
|
|
31
|
+
// Parse bytes so a torn UTF-8 character and a torn JSON value have the same recovery boundary.
|
|
32
|
+
// Only an unreadable, unterminated final line with a valid prefix is recoverable. A completed
|
|
33
|
+
// bad line or a non-record JSON value is corruption; never skip it and trust the rest.
|
|
34
|
+
function inspectTranscript(file, data = existsSync(file) ? readFileSync(file) : Buffer.alloc(0)) {
|
|
35
|
+
const rows = [];
|
|
36
|
+
const name = `${path.basename(path.dirname(file))}/transcript.jsonl`;
|
|
37
|
+
const decoder = new TextDecoder('utf-8', { fatal: true });
|
|
38
|
+
const corrupt = (line) => Object.assign(new Error(`${name}: corrupt record at line ${line}. Restore the transcript from a backup before resuming or exporting; the original file has not been changed.`), { code: 'TRANSCRIPT_CORRUPT', line });
|
|
39
|
+
let start = 0, line = 0;
|
|
40
|
+
while (start < data.length) {
|
|
41
|
+
line++;
|
|
42
|
+
const newline = data.indexOf(10, start);
|
|
43
|
+
const end = newline < 0 ? data.length : newline;
|
|
44
|
+
let row;
|
|
45
|
+
try {
|
|
46
|
+
const text = decoder.decode(data.subarray(start, end));
|
|
47
|
+
if (text.trim()) row = JSON.parse(text);
|
|
48
|
+
} catch {
|
|
49
|
+
if (newline < 0 && rows.some((r) => r.type === 'field-notes')) {
|
|
50
|
+
const warning = { code: 'TRANSCRIPT_TAIL', line, message: `${name}: incomplete final line ${line} was ignored. Complete records are available; the original bytes will be backed up before the next write.` };
|
|
51
|
+
return { rows, data, tail: start, warning };
|
|
52
|
+
}
|
|
53
|
+
throw corrupt(line);
|
|
54
|
+
}
|
|
55
|
+
if (row !== undefined) {
|
|
56
|
+
if (!row || typeof row !== 'object' || Array.isArray(row) || typeof row.type !== 'string' || !row.type) throw corrupt(line);
|
|
57
|
+
rows.push(row);
|
|
58
|
+
}
|
|
59
|
+
start = end + 1;
|
|
60
|
+
}
|
|
61
|
+
return { rows, data, tail: null, warning: null };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Every readable transcript's valid prefix, oldest folder first. Corrupt records are excluded
|
|
65
|
+
// as a whole with a warning, so one damaged archive does not prevent server startup.
|
|
66
|
+
// excludeSynthetic: leave out whole transcripts from scripted seats (estimates must never learn
|
|
67
|
+
// from them).
|
|
68
|
+
export function readAllTranscripts(root, { excludeSynthetic = false, onWarning = reportTranscriptWarning } = {}) {
|
|
69
|
+
const rows = [];
|
|
70
|
+
let dirs = [];
|
|
71
|
+
try { dirs = readdirSync(root, { withFileTypes: true }).filter((d) => d.isDirectory() && !d.name.startsWith('_')).map((d) => d.name).sort(); } catch { return rows; }
|
|
72
|
+
for (const name of dirs) {
|
|
73
|
+
const f = path.join(root, name, 'transcript.jsonl');
|
|
74
|
+
if (!existsSync(f)) continue;
|
|
75
|
+
let own;
|
|
76
|
+
try {
|
|
77
|
+
const parsed = inspectTranscript(f);
|
|
78
|
+
own = parsed.rows;
|
|
79
|
+
if (parsed.warning) onWarning(parsed.warning);
|
|
80
|
+
} catch (error) { onWarning(error); continue; }
|
|
81
|
+
if (excludeSynthetic && isSynthetic(own)) continue;
|
|
82
|
+
rows.push(...normaliseRows(own));
|
|
83
|
+
}
|
|
84
|
+
return rows;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function slugFor(decision, root) {
|
|
88
|
+
const base = (decision || 'debate').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 40) || 'debate';
|
|
89
|
+
const day = new Date().toISOString().slice(0, 10);
|
|
90
|
+
let slug;
|
|
91
|
+
do { slug = `${day}-${base}-${globalThis.crypto.randomUUID().replaceAll('-', '')}`; } while (existsSync(path.join(root, slug)));
|
|
92
|
+
return slug;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// One folder per debate: transcript.jsonl (every message, with usage), field-notes.md, verdict.md.
|
|
96
|
+
export function openDebateDir(root, slug, { create = true } = {}) {
|
|
97
|
+
const dir = path.join(root, slug);
|
|
98
|
+
const transcript = path.join(dir, 'transcript.jsonl');
|
|
99
|
+
if (!create && !existsSync(transcript)) return null;
|
|
100
|
+
mkdirSync(dir, { recursive: true });
|
|
101
|
+
let prepared = false;
|
|
102
|
+
function prepareAppend() {
|
|
103
|
+
if (prepared) return null;
|
|
104
|
+
const parsed = inspectTranscript(transcript);
|
|
105
|
+
let recovery = null;
|
|
106
|
+
if (parsed.tail != null) {
|
|
107
|
+
const id = globalThis.crypto.randomUUID();
|
|
108
|
+
const backup = `transcript.recovery-${id}.jsonl`;
|
|
109
|
+
const temporary = path.join(dir, `transcript.repair-${id}.tmp`);
|
|
110
|
+
// Save the exact original first, without replacing an existing file. Stage the repaired
|
|
111
|
+
// prefix separately; a failed backup or replacement leaves the original unchanged.
|
|
112
|
+
writeFileSync(path.join(dir, backup), parsed.data, { flag: 'wx', mode: 0o600 });
|
|
113
|
+
recovery = { t: new Date().toISOString(), type: 'transcript-recovered', line: parsed.warning.line, backup,
|
|
114
|
+
message: `Recovered complete records before incomplete line ${parsed.warning.line}. The exact original is saved beside the transcript as ${backup}.` };
|
|
115
|
+
writeFileSync(temporary, Buffer.concat([parsed.data.subarray(0, parsed.tail), Buffer.from(JSON.stringify(recovery) + '\n')]), { flag: 'wx', mode: 0o600 });
|
|
116
|
+
try { renameSync(temporary, transcript); } finally { if (existsSync(temporary)) unlinkSync(temporary); }
|
|
117
|
+
} else if (parsed.data.length && parsed.data.at(-1) !== 10) {
|
|
118
|
+
// A fully written final JSON value is valid even without its newline. Separate the next
|
|
119
|
+
// append so it cannot turn two valid records into one corrupt line.
|
|
120
|
+
appendFileSync(transcript, '\n');
|
|
121
|
+
}
|
|
122
|
+
prepared = true;
|
|
123
|
+
return recovery;
|
|
124
|
+
}
|
|
125
|
+
return {
|
|
126
|
+
dir,
|
|
127
|
+
prepareAppend,
|
|
128
|
+
// One empty directory per instance: the CLIs read project files, project memory and
|
|
129
|
+
// project settings from the working directory, so none of that is shared between seats.
|
|
130
|
+
seatDir(id) {
|
|
131
|
+
const d = path.join(dir, 'seats', String(id).replace(/[^a-z0-9_-]/gi, '_'));
|
|
132
|
+
mkdirSync(d, { recursive: true });
|
|
133
|
+
return d;
|
|
134
|
+
},
|
|
135
|
+
append(row) {
|
|
136
|
+
try {
|
|
137
|
+
prepareAppend();
|
|
138
|
+
appendFileSync(transcript, JSON.stringify({ t: new Date().toISOString(), ...row }) + '\n', { flush: row.type === 'attempt-start' });
|
|
139
|
+
} catch (error) {
|
|
140
|
+
// A failed append can have written a torn tail. Even this same writer must inspect
|
|
141
|
+
// and preserve it before a later turn or a repaired review writes anything else.
|
|
142
|
+
prepared = false;
|
|
143
|
+
throw error;
|
|
144
|
+
}
|
|
145
|
+
},
|
|
146
|
+
writeFieldNotes(fn, seats) {
|
|
147
|
+
const md = [
|
|
148
|
+
`# Field notes: ${fn.decision}`, '',
|
|
149
|
+
'```', fieldNotesText(fn), '```', '',
|
|
150
|
+
'## Seats', ...seats.map((s) => `- ${s.vendor}${s.endpoint ? ` at ${s.endpoint}` : ''} as "${s.lens}"`), '',
|
|
151
|
+
].join('\n');
|
|
152
|
+
writeFileSync(path.join(dir, 'field-notes.md'), md);
|
|
153
|
+
},
|
|
154
|
+
writeVerdict(text, meta) {
|
|
155
|
+
const md = [
|
|
156
|
+
`# Verdict: ${meta.decision}`, '',
|
|
157
|
+
`Chairman: ${meta.chairman}. Rounds: ${meta.rounds}. Stopped because: ${meta.stopReason}.`, '',
|
|
158
|
+
text.trim(), '',
|
|
159
|
+
].join('\n');
|
|
160
|
+
writeFileSync(path.join(dir, 'verdict.md'), md);
|
|
161
|
+
},
|
|
162
|
+
readTranscript({ onWarning = reportTranscriptWarning } = {}) {
|
|
163
|
+
const parsed = inspectTranscript(transcript);
|
|
164
|
+
if (parsed.warning) onWarning(parsed.warning);
|
|
165
|
+
return parsed.rows;
|
|
166
|
+
},
|
|
167
|
+
async readTranscriptAsync({ onWarning = reportTranscriptWarning } = {}) {
|
|
168
|
+
const parsed = inspectTranscript(transcript, await readFile(transcript));
|
|
169
|
+
if (parsed.warning) onWarning(parsed.warning);
|
|
170
|
+
return parsed.rows;
|
|
171
|
+
},
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Rebuild a debate from its transcript rows: what the board needs to replay it, and what the
|
|
176
|
+
// engine needs to continue it. Pure. A round is complete when every seat in the field-notes row
|
|
177
|
+
// has a reply at that index; a partial round is dropped and re-run from scratch on resume. When
|
|
178
|
+
// a seat has several reply rows for one round (a re-run after an interruption), the last wins.
|
|
179
|
+
// Returns null when there is no field-notes row.
|
|
180
|
+
export function rebuildDebate(rows) {
|
|
181
|
+
rows = normaliseRows(rows);
|
|
182
|
+
const head = rows.find((r) => r.type === 'field-notes');
|
|
183
|
+
if (!head) return null;
|
|
184
|
+
let research = rows.findLast((r) => r.type === 'research') || (rows.some((r) => r.type === 'research-start') ? { status: 'interrupted' } : null);
|
|
185
|
+
const fieldNotes = { ...head.fieldNotes };
|
|
186
|
+
delete fieldNotes.research;
|
|
187
|
+
if (research?.status === 'done') {
|
|
188
|
+
try {
|
|
189
|
+
const gatheredAt = new Date(research.gatheredAt).toISOString();
|
|
190
|
+
if (!Number.isInteger(research.searches) || research.searches < 1 || research.searches > 8) throw new Error('invalid count');
|
|
191
|
+
research = { status: 'done', gatheredAt, searches: research.searches, attemptId: research.attemptId,
|
|
192
|
+
sources: researchSources(JSON.stringify({ sources: research.sources }), gatheredAt) };
|
|
193
|
+
fieldNotes.research = research.sources;
|
|
194
|
+
} catch { research = { status: 'failed', errorKind: 'format', error: 'The saved research bundle is invalid. It was not refetched.' }; }
|
|
195
|
+
}
|
|
196
|
+
const seats = head.seats || [];
|
|
197
|
+
const sid = (s) => s?.id ?? `${s?.vendor}:${s?.lens}`; // schema 1 rows had no seat id
|
|
198
|
+
const byRound = new Map(); // round -> Map(seatId -> reply)
|
|
199
|
+
const humanNotes = [], initialNotes = [], reopens = [];
|
|
200
|
+
let stop = null, verdict = null, error = null, resumedRows = 0, accepted = null;
|
|
201
|
+
const downs = [];
|
|
202
|
+
let fallback = null;
|
|
203
|
+
const originalOf = new Map(); // a normalized reply -> the row as written (for usage identity)
|
|
204
|
+
for (const r of rows) {
|
|
205
|
+
if (r.type === 'reply') {
|
|
206
|
+
if (!byRound.has(r.round)) byRound.set(r.round, new Map());
|
|
207
|
+
// before 0.3.0 a stance key was the option's label; since, its id. A resumed old debate
|
|
208
|
+
// compares both, so old keys are read as ids (PASS and "other: …" are left as they are);
|
|
209
|
+
// otherwise a stance that never moved could not count as stable.
|
|
210
|
+
const old = !(head.schemaVersion >= 3) && r.stanceKey && r.stanceKey !== 'PASS' && !r.stanceKey.startsWith('other: ');
|
|
211
|
+
let row = old ? { ...r, stanceKey: optionId(r.stanceKey, head.fieldNotes?.options) ?? r.stanceKey } : r;
|
|
212
|
+
const question = humanQuestion(r.question);
|
|
213
|
+
if (question !== (r.question ?? null) || (r.parsed?.question && humanQuestion(r.parsed.question) !== r.parsed.question))
|
|
214
|
+
row = { ...row, question, ...(row.parsed ? { parsed: { ...row.parsed, question: humanQuestion(row.parsed.question) } } : {}) };
|
|
215
|
+
if (row !== r) originalOf.set(row, r);
|
|
216
|
+
byRound.get(r.round).set(sid(r.seat), row);
|
|
217
|
+
} else if (r.type === 'human') (r.round === -1 ? initialNotes : (humanNotes[r.round] ??= [])).push(storedNote(r));
|
|
218
|
+
else if (r.type === 'reopen') reopens.push(r);
|
|
219
|
+
else if (r.type === 'stop') stop = r;
|
|
220
|
+
else if (r.type === 'verdict') verdict = r;
|
|
221
|
+
else if (r.type === 'synthesis-cancelled') verdict = { text: 'Verdict not written: you stopped the synthesis.', failed: true, skipped: true, reason: r.reason, seat: null };
|
|
222
|
+
else if (r.type === 'error') error = r;
|
|
223
|
+
else if (r.type === 'resumed') resumedRows++;
|
|
224
|
+
else if (r.type === 'accepted') accepted = r;
|
|
225
|
+
else if (r.type === 'pool-down') downs.push(r);
|
|
226
|
+
else if (r.type === 'chairman-fallback') fallback = r;
|
|
227
|
+
}
|
|
228
|
+
const rounds = [];
|
|
229
|
+
for (let i = 0; byRound.has(i); i++) {
|
|
230
|
+
const m = byRound.get(i);
|
|
231
|
+
if (seats.length && !seats.every((s) => m.has(sid(s)))) break; // partial: stop here
|
|
232
|
+
rounds.push(seats.length ? seats.map((s) => m.get(sid(s))) : [...m.values()]);
|
|
233
|
+
}
|
|
234
|
+
const partialRound = byRound.has(rounds.length) ? rounds.length : null;
|
|
235
|
+
// finished: the rounds are closed. synthesis: whether a verdict got written. They are separate,
|
|
236
|
+
// so a debate whose verdict was skipped or failed is complete and can still be given one.
|
|
237
|
+
// before 0.3.0 a failed chairman was a verdict whose text began "Chairman failed:"
|
|
238
|
+
if (verdict && !verdict.failed && !verdict.skipped && /^Chairman failed:/.test(verdict.text || '')) verdict = { ...verdict, failed: true };
|
|
239
|
+
const finished = !!(stop || verdict);
|
|
240
|
+
const synthesis = synthesisOf(verdict);
|
|
241
|
+
const deadlineAt = [...rows].reverse().find((r) => (r.type === 'resumed' || r.type === 'field-notes') && r.deadlineAt != null)?.deadlineAt ?? null;
|
|
242
|
+
// session ids: the last reply row of each seat (resume mode continues the thread)
|
|
243
|
+
const sessions = {};
|
|
244
|
+
for (const r of rows) if (r.type === 'reply' && r.sessionId) sessions[sid(r.seat)] = r.sessionId;
|
|
245
|
+
// The spend ledger, partial rounds included (they were paid for), and each seat's last
|
|
246
|
+
// cumulative cost reading, so a resumed session is charged only its own part.
|
|
247
|
+
const ledger = ledgerEntries(rows);
|
|
248
|
+
// Older versions recorded tool activity on a repair/reply row after the successful
|
|
249
|
+
// attempt. Keep the policy state even if the process stopped before a complete round.
|
|
250
|
+
const policyFailures = rows.filter((r) => r.seat && (r.usedTools || r.toolFail || r.errorKind === 'policy')).map((r) => r.seat);
|
|
251
|
+
const lastRawCost = {};
|
|
252
|
+
for (const e of ledger) {
|
|
253
|
+
const cost = e.usage?.raw?.cost ?? e.rawCost;
|
|
254
|
+
if (typeof cost === 'number') lastRawCost[sid(e.seat)] = cost;
|
|
255
|
+
}
|
|
256
|
+
const seatsWithSessions = seats.map((s) => ({ ...s, sessionId: sessions[sid(s)] || null, ...(lastRawCost[sid(s)] != null ? { lastRawCost: lastRawCost[sid(s)] } : {}) }));
|
|
257
|
+
// Ledger entries the board must see as attempts: real attempt rows, plus legacy usage that no
|
|
258
|
+
// replayed reply carries (a re-prompt whose turn never got a reply, and, since only the last
|
|
259
|
+
// replay, a reply superseded by a re-run, a reply in a partial round).
|
|
260
|
+
const shownReplies = new Set(rounds.flat().map((x) => originalOf.get(x) || x));
|
|
261
|
+
const attemptEvents = [
|
|
262
|
+
...ledger.filter((e) => e.type === 'attempt' || e.type === 'reprompt'),
|
|
263
|
+
...rows.filter((r) => r.type === 'reply' && !r.attemptId && r.usage && !shownReplies.has(r)).map((r) => ({ ...r, kind: 'reply' })),
|
|
264
|
+
].map((e) => ({ ...e, type: 'attempt' }));
|
|
265
|
+
const shown = new Set();
|
|
266
|
+
const attemptsFor = (pred) => attemptEvents.filter((e, i) => !shown.has(i) && pred(e) && shown.add(i));
|
|
267
|
+
|
|
268
|
+
// The event list the board replays. Same shapes as the live engine emits.
|
|
269
|
+
const t = (row) => ({ type: row.type });
|
|
270
|
+
const events = [{ type: 'started', seats, fieldNotes, options: head.options, chairman: head.chairman, deadlineAt, replayed: true }];
|
|
271
|
+
if (research) events.push({ type: 'research', ...research, replayed: true });
|
|
272
|
+
const recoveryWarnings = rows.filter((r) => r.type === 'transcript-recovered').map((r) => ({ ...r, type: 'transcript-warning' }));
|
|
273
|
+
events.push(...recoveryWarnings);
|
|
274
|
+
events.push(...initialNotes.map((note) => ({ type: 'human', round: -1, ...noteRecord(note) })));
|
|
275
|
+
rounds.forEach((replies, i) => {
|
|
276
|
+
events.push({ type: 'round-start', round: i });
|
|
277
|
+
events.push(...attemptsFor((e) => e.round === i && e.kind !== 'verdict'));
|
|
278
|
+
for (const r of replies) {
|
|
279
|
+
events.push({ ...r, type: 'reply' });
|
|
280
|
+
events.push({ type: 'seat-state', round: i, seat: r.seat, state: r.state || (r.failed ? (r.timedOut ? 'timed-out' : 'failed') : (r.reprompted ? 'reprompted' : 'replied')) });
|
|
281
|
+
}
|
|
282
|
+
events.push({ type: 'round-done', round: i, replies, positions: positions(replies) });
|
|
283
|
+
for (const timeout of rows.filter((r) => r.type === 'gate-timeout' && r.round === i)) events.push({ type: 'gate-timeout', round: i, unanswered: timeout.unanswered });
|
|
284
|
+
for (const note of humanNotes[i] || []) events.push({ type: 'human', round: i, ...noteRecord(note) });
|
|
285
|
+
for (const ro of reopens.filter((x) => x.round === i)) events.push({ type: 'reopen', round: ro.round, stop: ro.stop });
|
|
286
|
+
});
|
|
287
|
+
events.push(...attemptsFor(() => true)); // a partial round's calls and the chairman's
|
|
288
|
+
events.push(...rows.filter((r) => r.type === 'budget'));
|
|
289
|
+
for (const x of downs) events.push({ ...x, type: 'pool-down' });
|
|
290
|
+
if (fallback) events.push({ ...fallback, type: 'chairman-fallback' });
|
|
291
|
+
for (const expired of rows.filter((r) => r.type === 'gate-expired')) events.push({ type: 'gate-expired', round: expired.round });
|
|
292
|
+
if (stop) events.push({ type: 'stopped', reason: stop.reason, rounds: stop.rounds, context: stop.context });
|
|
293
|
+
if (verdict && verdict.skipped) events.push({ type: 'verdict', seat: null, text: verdict.text, usage: null, failed: true, skipped: true, reason: verdict.reason });
|
|
294
|
+
else if (verdict) events.push({ type: 'chairman-start', seat: verdict.seat }, { type: 'verdict', seat: verdict.seat, text: verdict.text, usage: verdict.usage, attemptId: verdict.attemptId, progress: verdict.progress, ...(verdict.failed ? { failed: true, errors: verdict.errors, errorKind: verdict.errorKind, cancelReason: verdict.cancelReason } : {}) });
|
|
295
|
+
if (error && !finished) events.push({ type: 'error', message: error.message });
|
|
296
|
+
if (finished) events.push({ type: 'done', reason: stop?.reason ?? null, replayed: true, synthesis });
|
|
297
|
+
else events.push({ type: 'interrupted', round: rounds.length, partialRound, resumedBefore: resumedRows });
|
|
298
|
+
if (accepted) events.push({ ...accepted, type: 'accepted' });
|
|
299
|
+
return {
|
|
300
|
+
fieldNotes, research, seats: seatsWithSessions, chairman: head.chairman, options: head.options,
|
|
301
|
+
rounds, humanNotes, initialNotes, partialRound, finished, synthesis, deadlineAt, accepted, stopReason: stop?.reason ?? null, stopContext: stop?.context ?? null, verdict, nextRound: rounds.length, events, ledger, policyFailures, recoveryWarnings,
|
|
302
|
+
};
|
|
303
|
+
}
|
package/src/terminal.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { VERSION } from './version.js';
|
|
2
|
+
|
|
3
|
+
// The app's logo tile, pixel for pixel (src/ui/logo.svg): the round table on its 16x16 tile,
|
|
4
|
+
// orchid with aubergine ink like the board header. Half blocks put two pixel rows in one
|
|
5
|
+
// terminal cell, so the tile keeps its square shape at 16 columns by 8 rows.
|
|
6
|
+
const TILE = [
|
|
7
|
+
'................', '................',
|
|
8
|
+
'......####......', '....##....##....', '...#........#...', '...#..####..#...',
|
|
9
|
+
'..#..######..#..', '..#..######..#..', '..#..######..#..', '...#..####..#...',
|
|
10
|
+
'...#........#...', '....##....##....', '......####......',
|
|
11
|
+
'................', '................', '................',
|
|
12
|
+
];
|
|
13
|
+
const TAGLINE = 'Local decision reviews';
|
|
14
|
+
const RESET = '\x1b[0m';
|
|
15
|
+
|
|
16
|
+
// Half blocks need a UTF-8 terminal. Windows consoles take UTF-16 from Node whatever the code
|
|
17
|
+
// page; elsewhere the locale has to say so. ROUNDTABLE_ASCII=1 forces the plain form.
|
|
18
|
+
const unicode = (env, platform) => env.ROUNDTABLE_ASCII !== '1'
|
|
19
|
+
&& (platform === 'win32' || /utf-?8/i.test(env.LC_ALL || env.LC_CTYPE || env.LANG || ''));
|
|
20
|
+
// Tile and ink colours: true colour where the terminal says so, the nearest 256-colour or
|
|
21
|
+
// basic pair otherwise. Returned as [tile background, ink foreground, orchid foreground].
|
|
22
|
+
function palette(env, platform) {
|
|
23
|
+
if (/truecolor|24bit/i.test(env.COLORTERM || '') || env.WT_SESSION || platform === 'win32') return ['\x1b[48;2;199;154;208m', '\x1b[38;2;43;29;56m', '\x1b[38;2;199;154;208m'];
|
|
24
|
+
if (env.TERM?.includes('256color')) return ['\x1b[48;5;182m', '\x1b[38;5;234m', '\x1b[38;5;182m'];
|
|
25
|
+
return ['\x1b[45m', '\x1b[30m', '\x1b[35m'];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function tileRows(colour) {
|
|
29
|
+
const rows = [];
|
|
30
|
+
for (let y = 0; y < TILE.length; y += 2) {
|
|
31
|
+
let line = '';
|
|
32
|
+
for (let x = 0; x < 16; x++) {
|
|
33
|
+
const top = TILE[y][x] === '#', bottom = TILE[y + 1][x] === '#';
|
|
34
|
+
line += top && bottom ? '█' : top ? '▀' : bottom ? '▄' : ' ';
|
|
35
|
+
}
|
|
36
|
+
rows.push(colour(line));
|
|
37
|
+
}
|
|
38
|
+
return rows;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function terminalBanner({ isTTY = process.stdout.isTTY, columns = process.stdout.columns ?? 80, env = process.env, platform = process.platform } = {}) {
|
|
42
|
+
if (!isTTY || env.CI || env.TERM === 'dumb') return '';
|
|
43
|
+
const paint = (s, code) => env.NO_COLOR ? s : `${code}${s}${RESET}`;
|
|
44
|
+
const [tile, ink, orchid] = palette(env, platform);
|
|
45
|
+
const version = `v${VERSION}`;
|
|
46
|
+
const name = paint('ROUNDTABLE', '\x1b[1m');
|
|
47
|
+
const compact = `${name}${columns >= 12 + version.length ? ` ${paint(version, '\x1b[2m')}` : ''}`;
|
|
48
|
+
// 2 indent + 16 tile + 3 gap + the tagline, the widest line beside the tile
|
|
49
|
+
if (!unicode(env, platform) || columns < 21 + TAGLINE.length) return `\n${compact}\n\n`;
|
|
50
|
+
const beside = { 2: `${name} ${paint('▌', orchid)}`, 3: paint(version, '\x1b[2m'), 5: paint(TAGLINE, '\x1b[2m') };
|
|
51
|
+
const lines = tileRows((row) => paint(row, tile + ink)).map((row, y) => ` ${row}${beside[y] ? ` ${beside[y]}` : ''}`);
|
|
52
|
+
return `\n${lines.join('\n')}\n\n`;
|
|
53
|
+
}
|