@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.
- package/CHANGELOG.md +529 -0
- package/LICENSE +21 -0
- package/README.md +189 -3
- package/SECURITY.md +299 -0
- package/docs/workflow.md +1344 -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 +57 -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 +101 -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 +110 -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 +928 -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 +77 -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/fake.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// A scripted runner for trying the board without spending tokens: `node src/server.js --fake`.
|
|
2
|
+
// With `--fake-consensus` every seat takes the first option in rounds 0 and 1, so the board shows
|
|
3
|
+
// the unanimous round-0 gate and, after a note, the reopened round 1 stopping on consensus.
|
|
4
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
5
|
+
const sleep = (ms, signal) => delay(ms, undefined, { signal }).catch((error) => {
|
|
6
|
+
if (signal?.aborted) throw Object.assign(new Error('scripted call cancelled'), { code: 'KILLED', kind: 'killed' });
|
|
7
|
+
throw error;
|
|
8
|
+
});
|
|
9
|
+
const pick = (arr, i) => arr[i % arr.length];
|
|
10
|
+
|
|
11
|
+
// A plausible quota snapshot so the strip can be seen without real seats.
|
|
12
|
+
// Reset times are fixed at load so snapshots compare as the same window (a moved reset time
|
|
13
|
+
// marks a rolled-over window and voids the delta; see estimate.js windowDeltas).
|
|
14
|
+
const BOOT = Math.floor(Date.now() / 1000);
|
|
15
|
+
let codexCalls = 0, agyCalls = 0;
|
|
16
|
+
// agy's comes in groups of windows per model family, as `agy -p /usage` reports it (quota.js).
|
|
17
|
+
const AGY_GROUPS = [['Gemini Models', 'Gemini', ['Gemini Flash', 'Gemini Pro'], [0.03, 0.02]], ['Claude and GPT models', 'Claude and GPT', ['Claude Opus', 'Claude Sonnet', 'GPT-OSS'], [0.01, 0]], ['Gemini 4 Argon', 'Gemini 4 Argon', [], [0.41]]];
|
|
18
|
+
export function fakeQuota(vendor, n = vendor === 'codex' ? codexCalls++ : vendor === 'agy' ? agyCalls++ : 0) {
|
|
19
|
+
if (vendor === 'agy') return {
|
|
20
|
+
windows: AGY_GROUPS.flatMap(([group, tag, , used]) => used.map((u, i) => ({ label: `${tag} ${i ? '5h' : '7d'}`, used: Math.min(0.95, u + n * 0.01 * (i + 1)), resetsAt: BOOT + (i ? 4 * 3600 : 3 * 86400), group }))),
|
|
21
|
+
groups: AGY_GROUPS.map(([name, , models]) => ({ name, models })),
|
|
22
|
+
credits: { remaining: 1200, at: Date.now() }, // as `agy -p /credits` reports them
|
|
23
|
+
plan: null,
|
|
24
|
+
at: Date.now(),
|
|
25
|
+
};
|
|
26
|
+
return {
|
|
27
|
+
windows: [
|
|
28
|
+
{ label: '5h', used: Math.min(0.95, 0.12 + n * 0.07), resetsAt: BOOT + 3 * 3600 + 600 },
|
|
29
|
+
{ label: '7d', used: Math.min(0.95, 0.31 + n * 0.02), resetsAt: BOOT + 2 * 86400 + 5 * 3600 },
|
|
30
|
+
],
|
|
31
|
+
plan: vendor === 'codex' ? 'fake' : null,
|
|
32
|
+
at: Date.now(),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function fakeRunner({ consensus = false } = {}) {
|
|
37
|
+
let calls = 0;
|
|
38
|
+
return async (vendor, prompt, sessionId, opts = {}) => {
|
|
39
|
+
const n = calls++;
|
|
40
|
+
// Behaviour follows the seat's position at the table, so two seats of one vendor still differ.
|
|
41
|
+
const idx = opts.seat?.index ?? (/chairman/i.test(prompt) ? -1 : n % 3);
|
|
42
|
+
const who = idx === 0 ? 'claude' : idx === 1 ? 'codex' : 'agy'; // the v1 script's three roles
|
|
43
|
+
await sleep(800 + Math.random() * 1500, opts.signal);
|
|
44
|
+
const round = Number((prompt.match(/^Round (\d+) of/m) || [])[1] ?? (/chairman/i.test(prompt) ? -1 : 0));
|
|
45
|
+
// OPTIONS: opt-1 = Ship now | opt-2 = Wait a sprint (the chairman sees the same line)
|
|
46
|
+
const options = (prompt.match(/^OPTIONS: (.+)$/m)?.[1] || '').split(' | ').map((o) => o.replace(/^opt-\d+ = /, '')).slice(0, 4);
|
|
47
|
+
const usage = { input: 9000 + n * 1200, cacheRead: n * 400, cacheWrite: 0, output: 180 + (n % 5) * 40, thinking: 60, cost: vendor === 'claude' ? 0.11 + n * 0.02 : null, ms: 900, usageVersion: 3 };
|
|
48
|
+
if (opts.research) return { text: JSON.stringify({ sources: [{ url: 'https://example.com/research-demo', title: 'Scripted demo source',
|
|
49
|
+
excerpt: 'Synthetic evidence for exercising the shared research flow. No website was searched.', publishedAt: null }] }),
|
|
50
|
+
usage, sessionId: 'fake-research', research: { searches: 1 } };
|
|
51
|
+
let text;
|
|
52
|
+
if (round === -1) {
|
|
53
|
+
text = `RECOMMENDATION: opt-1\nChoose ${pick(options, 0)} if the rollout can be monitored and reversed.\n\nIt offers the better balance of risk and cost. The adoption concern remains worth watching.\nDISSENT: The customer lens argued that ${pick(options, 1)} protects adoption; that risk is real and should be watched.\nKILL_CRITERIA:\n- Support tickets about the change exceed 5% of weekly volume\n- The migration takes more than two sprints\nOPEN_QUESTIONS:\n- OPTIONAL: Is the deadline movable by a week?`;
|
|
54
|
+
} else if (!consensus && round >= 2 && who === 'codex') {
|
|
55
|
+
text = 'PASS';
|
|
56
|
+
} else if (!consensus && round === 0 && who === 'agy' && !/did not follow the contract/.test(prompt)) {
|
|
57
|
+
text = `I think ${pick(options, 1)} is right because customers hate surprises.`; // off contract, forces a re-prompt
|
|
58
|
+
} else {
|
|
59
|
+
// claude answers with the id, the others with the exact label: the contract accepts both
|
|
60
|
+
const n2 = !consensus && (who === 'agy' || idx >= 3) ? 1 : 0;
|
|
61
|
+
const stance = who === 'claude' ? `opt-${n2 + 1}` : pick(options, n2);
|
|
62
|
+
text = [
|
|
63
|
+
`STANCE: ${stance}`,
|
|
64
|
+
`AGREE_WITH: [${round > 0 && who === 'claude' ? 'cost and effort' : ''}]`,
|
|
65
|
+
'DISAGREE:', `- The ${who === 'agy' ? 'cost' : 'customer'} argument assumes a traffic pattern we have not measured`,
|
|
66
|
+
'NEW:', round === 1 ? '- A staged rollout to 10% of accounts gives both sides their evidence' : '- none',
|
|
67
|
+
'ASSUMPTIONS:', '- The team stays at two developers', '- No regulatory change before the deadline',
|
|
68
|
+
!consensus && round === 0 ? `QUESTION_FOR_HUMAN: ${who === 'agy' ? 'What evidence would make you stop the trial?' : 'Is the deadline movable by one week?'}`
|
|
69
|
+
: !consensus && round === 1 && who === 'agy' && !/Answer:/.test(prompt) ? 'QUESTION_FOR_HUMAN: Is the deadline movable by one week?' : null,
|
|
70
|
+
].filter(Boolean).join('\n');
|
|
71
|
+
}
|
|
72
|
+
const quota = vendor === 'claude' ? fakeQuota(vendor, n) : null; // claude's rides on each reply
|
|
73
|
+
return { text, usage, sessionId: sessionId || `fake-${opts.seat?.id || vendor}-${n}`, quota, raw: {} };
|
|
74
|
+
};
|
|
75
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// The operating system's own folder dialog, opened by the local server so the board can offer a
|
|
2
|
+
// "Choose…" button: a page cannot learn a real folder path from the browser's own picker. Nothing
|
|
3
|
+
// from a request reaches the command line; the commands below are fixed.
|
|
4
|
+
import { execFile } from 'node:child_process';
|
|
5
|
+
|
|
6
|
+
const PROMPT = 'Choose a folder for the decision record';
|
|
7
|
+
const WINDOWS = [
|
|
8
|
+
"$ErrorActionPreference = 'Stop'",
|
|
9
|
+
'Add-Type -AssemblyName System.Windows.Forms',
|
|
10
|
+
// an invisible topmost owner keeps the dialog in front of the browser instead of behind it
|
|
11
|
+
'$owner = New-Object System.Windows.Forms.Form -Property @{ TopMost = $true; ShowInTaskbar = $false }',
|
|
12
|
+
'$dialog = New-Object System.Windows.Forms.FolderBrowserDialog',
|
|
13
|
+
`$dialog.Description = '${PROMPT}'`,
|
|
14
|
+
'$dialog.ShowNewFolderButton = $true',
|
|
15
|
+
"if ($dialog.ShowDialog($owner) -eq 'OK') { [Console]::Out.Write($dialog.SelectedPath) }",
|
|
16
|
+
].join('; ');
|
|
17
|
+
|
|
18
|
+
// The candidates for one platform, tried in order until one exists.
|
|
19
|
+
export function pickerCommands(platform = process.platform) {
|
|
20
|
+
if (platform === 'win32') return [['powershell.exe', ['-NoProfile', '-STA', '-NonInteractive', '-Command', WINDOWS]]];
|
|
21
|
+
if (platform === 'darwin') return [['osascript', ['-e', `try\n return POSIX path of (choose folder with prompt "${PROMPT}")\non error message number code\n if code is -128 then return ""\n error message number code\nend try`]]];
|
|
22
|
+
return [['zenity', ['--file-selection', '--directory', `--title=${PROMPT}`]], ['kdialog', ['--getexistingdirectory', '.', '--title', PROMPT]]];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const execute = (file, args) => new Promise((resolve) => {
|
|
26
|
+
execFile(file, args, { timeout: 10 * 60_000, windowsHide: true, encoding: 'utf8' }, (error, stdout, stderr) => resolve({ error, stdout: String(stdout || ''), stderr: String(stderr || '') }));
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
// The chosen folder, or null when the person cancels. Throws when this machine has no dialog to
|
|
30
|
+
// offer, with what to do instead.
|
|
31
|
+
export async function pickFolder({ platform = process.platform, run = execute } = {}) {
|
|
32
|
+
for (const [file, args] of pickerCommands(platform)) {
|
|
33
|
+
const { error, stdout = '', stderr = '' } = await run(file, args);
|
|
34
|
+
if (error?.code === 'ENOENT') continue; // not installed: try the next one
|
|
35
|
+
if (error?.killed) throw Object.assign(new Error('The folder dialog timed out. Type the folder path instead.'), { status: 504 });
|
|
36
|
+
// Windows cancels with empty successful output; AppleScript handles -128 above. The Linux
|
|
37
|
+
// pickers use status 1 for cancellation. Display or execution failures carry stderr.
|
|
38
|
+
const cancelled = ['zenity', 'kdialog'].includes(file) && error?.code === 1 && !stderr.trim() && !stdout.trim();
|
|
39
|
+
if (error && !cancelled) throw Object.assign(new Error('The folder dialog could not open or finish. Type the folder path instead.'), { status: 503 });
|
|
40
|
+
const out = stdout.trim();
|
|
41
|
+
if (!out) return null;
|
|
42
|
+
// drop a trailing separator (macOS adds one), except on a drive or filesystem root
|
|
43
|
+
return /^([A-Za-z]:)?[\\/]$/.test(out) ? out : out.replace(/[\\/]+$/, '');
|
|
44
|
+
}
|
|
45
|
+
throw Object.assign(new Error('No folder dialog is available on this machine. Type the folder path instead.'), { status: 501 });
|
|
46
|
+
}
|
package/src/history.js
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
// Local history and numeric usage records. Neither is model memory.
|
|
2
|
+
import { mkdirSync, existsSync, readFileSync, writeFileSync, renameSync, unlinkSync, readdirSync, lstatSync, realpathSync, rmSync } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
6
|
+
import { openDebateDir, normaliseRows, isSynthetic } from './store.js';
|
|
7
|
+
import { ledgerEntries, rollup } from './engine.js';
|
|
8
|
+
import { baselinesFrom } from './estimate.js';
|
|
9
|
+
import { readdir, stat } from 'node:fs/promises';
|
|
10
|
+
import { decisionRecord } from './export.js';
|
|
11
|
+
|
|
12
|
+
// Cache small summaries, never transcript bodies. Changed records are read asynchronously;
|
|
13
|
+
// a cold history scan yields between files so active reviews and Stop remain responsive.
|
|
14
|
+
export function createHistoryIndex(root, { onWarning = () => {}, limit = 1000 } = {}) {
|
|
15
|
+
const summaries = new Map();
|
|
16
|
+
let pending = null;
|
|
17
|
+
const signature = (s) => `${s.mtimeMs}:${s.ctimeMs}:${s.size}:${s.ino}`;
|
|
18
|
+
async function scan() {
|
|
19
|
+
// Misses must not evict hits that this same enumeration has yet to visit. Otherwise a
|
|
20
|
+
// history just above the cache limit rereads every file on every request.
|
|
21
|
+
const previous = new Map(summaries);
|
|
22
|
+
const dirs = await readdir(root, { withFileTypes: true });
|
|
23
|
+
const ids = dirs.filter((d) => d.isDirectory() && /^[a-z0-9-]+$/.test(d.name)).map((d) => d.name);
|
|
24
|
+
const present = new Set(ids), result = [];
|
|
25
|
+
for (const id of summaries.keys()) if (!present.has(id)) summaries.delete(id);
|
|
26
|
+
for (const slug of ids) {
|
|
27
|
+
const file = path.join(root, slug, 'transcript.jsonl');
|
|
28
|
+
let before;
|
|
29
|
+
try {
|
|
30
|
+
before = await stat(file);
|
|
31
|
+
const key = signature(before), cached = previous.get(slug);
|
|
32
|
+
if (cached?.key === key) {
|
|
33
|
+
summaries.delete(slug); summaries.set(slug, cached);
|
|
34
|
+
while (summaries.size > limit) summaries.delete(summaries.keys().next().value);
|
|
35
|
+
result.push(cached.value); continue;
|
|
36
|
+
}
|
|
37
|
+
const warnings = [];
|
|
38
|
+
const store = openDebateDir(root, slug, { create: false });
|
|
39
|
+
if (!store) continue;
|
|
40
|
+
const rows = await store.readTranscriptAsync({ onWarning: (w) => warnings.push(w) });
|
|
41
|
+
if (!rows.some((r) => r.type === 'field-notes')) continue;
|
|
42
|
+
const { meta, summary } = decisionRecord(rows, slug, { warnings, metadataOnly: true });
|
|
43
|
+
const value = { slug, ...summary, date: slug.slice(0, 10), updated: before.mtimeMs,
|
|
44
|
+
status: meta.status, chosen: meta.chosen, recommended: meta.recommended, accepted: meta.accepted };
|
|
45
|
+
const after = await stat(file);
|
|
46
|
+
if (signature(after) === key) {
|
|
47
|
+
summaries.delete(slug); summaries.set(slug, { key, value });
|
|
48
|
+
while (summaries.size > limit) summaries.delete(summaries.keys().next().value);
|
|
49
|
+
}
|
|
50
|
+
result.push(value);
|
|
51
|
+
} catch (error) {
|
|
52
|
+
if (error.code === 'ENOENT') continue;
|
|
53
|
+
summaries.delete(slug); onWarning(error, slug);
|
|
54
|
+
result.push({ slug, decision: slug, date: slug.slice(0, 10), updated: before?.mtimeMs || 0,
|
|
55
|
+
state: 'corrupt', status: 'corrupt', warning: error.message });
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return result.sort((a, b) => b.updated - a.updated);
|
|
59
|
+
}
|
|
60
|
+
return {
|
|
61
|
+
list() { return pending ||= scan().finally(() => { pending = null; }); },
|
|
62
|
+
invalidate(id) { summaries.delete(id); },
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Windows keeps history in %USERPROFILE%\.roundtable, not AppData: Windows redirects AppData
|
|
67
|
+
// writes of a program started from a packaged app (the Claude and Codex desktop apps, and every
|
|
68
|
+
// terminal and agent shell they open) into that app's private copy, so each app saw its own
|
|
69
|
+
// history and its own server lock. The user profile folder is the same for every program.
|
|
70
|
+
export function dataDirectory({ fake = false, env = process.env, platform = process.platform, home = os.homedir() } = {}) {
|
|
71
|
+
if (env.ROUNDTABLE_DEBATES_DIR) return path.resolve(env.ROUNDTABLE_DEBATES_DIR);
|
|
72
|
+
const base = platform === 'win32' ? path.join(home, '.roundtable')
|
|
73
|
+
: platform === 'darwin' ? path.join(home, 'Library', 'Application Support', 'Roundtable')
|
|
74
|
+
: path.join(env.XDG_DATA_HOME || path.join(home, '.local', 'share'), 'roundtable');
|
|
75
|
+
return path.resolve(base, fake ? 'demo' : 'live');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Folders that may still hold reviews this data folder lacks: a checkout's `debates/` from before
|
|
79
|
+
// the user-data folder, and on Windows the earlier %LOCALAPPDATA%\Roundtable default with the
|
|
80
|
+
// private copies packaged apps kept of it (%LOCALAPPDATA%\Packages\<app>\LocalCache\Local). Only
|
|
81
|
+
// a folder with a review the data folder does not have is reported, so a copy left behind after
|
|
82
|
+
// its reviews were moved stays quiet. Read only: nothing is moved or merged.
|
|
83
|
+
// -> [{ dir, missing }] missing: how many of its reviews the data folder lacks
|
|
84
|
+
export function legacyHistory(dataDir, { root, fake = false, env = process.env, platform = process.platform } = {}) {
|
|
85
|
+
const mode = fake ? 'demo' : 'live';
|
|
86
|
+
const candidates = root ? [path.join(root, fake ? 'debates-fake' : 'debates')] : [];
|
|
87
|
+
if (platform === 'win32' && env.LOCALAPPDATA) {
|
|
88
|
+
candidates.push(path.join(env.LOCALAPPDATA, 'Roundtable', mode));
|
|
89
|
+
let apps = [];
|
|
90
|
+
try { apps = readdirSync(path.join(env.LOCALAPPDATA, 'Packages')); } catch { /* no packaged apps */ }
|
|
91
|
+
for (const app of apps) candidates.push(path.join(env.LOCALAPPDATA, 'Packages', app, 'LocalCache', 'Local', 'Roundtable', mode));
|
|
92
|
+
}
|
|
93
|
+
const same = (a, b) => (platform === 'win32' ? path.resolve(a).toLowerCase() === path.resolve(b).toLowerCase() : path.resolve(a) === path.resolve(b));
|
|
94
|
+
// a folder that cannot be listed is not reported, and never stops the server starting
|
|
95
|
+
const ids = (dir) => { try { return historyIds(dir); } catch { return []; } };
|
|
96
|
+
const have = new Set(ids(dataDir));
|
|
97
|
+
const found = [];
|
|
98
|
+
for (const dir of candidates) {
|
|
99
|
+
if (same(dir, dataDir)) continue;
|
|
100
|
+
const missing = ids(dir).filter((id) => !have.has(id));
|
|
101
|
+
if (!missing.length) continue;
|
|
102
|
+
found.push({ dir, missing: missing.length });
|
|
103
|
+
// counted once: a packaged app's server sees its own copy both through %LOCALAPPDATA% and at
|
|
104
|
+
// its Packages path, and would otherwise name the same reviews twice
|
|
105
|
+
for (const id of missing) have.add(id);
|
|
106
|
+
}
|
|
107
|
+
return found;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// A single server owns a data folder. This also protects sessions opened on another port.
|
|
111
|
+
// An abruptly terminated server leaves a lock whose dead PID can be reclaimed at startup.
|
|
112
|
+
export function acquireDataLock(root) {
|
|
113
|
+
mkdirSync(root, { recursive: true });
|
|
114
|
+
const file = path.join(root, '_server.lock'), owner = JSON.stringify({ pid: process.pid, nonce: randomUUID() });
|
|
115
|
+
const starting = path.join(root, '_server.starting');
|
|
116
|
+
try { writeFileSync(starting, owner, { flag: 'wx', mode: 0o600 }); }
|
|
117
|
+
catch (error) {
|
|
118
|
+
if (error.code !== 'EEXIST') throw error;
|
|
119
|
+
throw new Error('Another server is starting with this data folder. Retry once it has started. If startup was interrupted, check that no server uses the folder before removing _server.starting.');
|
|
120
|
+
}
|
|
121
|
+
try {
|
|
122
|
+
if (existsSync(file)) {
|
|
123
|
+
if (lstatSync(file).isSymbolicLink()) throw new Error('The data-folder lock must not be a link.');
|
|
124
|
+
const prior = readFileSync(file, 'utf8');
|
|
125
|
+
let pid; try { pid = JSON.parse(prior).pid; } catch { /* fail closed */ }
|
|
126
|
+
if (!Number.isSafeInteger(pid) || pid <= 0) throw new Error('The data-folder lock is unreadable. Check that its server has stopped before removing _server.lock.');
|
|
127
|
+
let running = true;
|
|
128
|
+
try { process.kill(pid, 0); } catch (e) { if (e.code === 'ESRCH') running = false; }
|
|
129
|
+
if (running) throw new Error('This data folder is already open in another Roundtable server. Use that server, or stop it before starting another.');
|
|
130
|
+
if (readFileSync(file, 'utf8') !== prior) throw new Error('The data-folder lock changed. Retry startup.');
|
|
131
|
+
unlinkSync(file);
|
|
132
|
+
}
|
|
133
|
+
writeFileSync(file, owner, { flag: 'wx', mode: 0o600 });
|
|
134
|
+
} finally { unlinkSync(starting); }
|
|
135
|
+
return () => { if (existsSync(file) && readFileSync(file, 'utf8') === owner) unlinkSync(file); };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function historyIds(root) {
|
|
139
|
+
if (!existsSync(root)) return [];
|
|
140
|
+
return readdirSync(root, { withFileTypes: true }).filter((d) => d.isDirectory() && /^[a-z0-9-]+$/.test(d.name)
|
|
141
|
+
&& existsSync(path.join(root, d.name, 'transcript.jsonl'))).map((d) => d.name);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Resolve and check the exact directory before any recursive removal. Refuse linked records
|
|
145
|
+
// and nested junctions rather than traversing a path outside the selected data folder.
|
|
146
|
+
export function checkedHistoryPath(root, id) {
|
|
147
|
+
if (typeof id !== 'string' || !/^[a-z0-9][a-z0-9-]{0,119}$/.test(id)) throw new Error('Invalid session ID.');
|
|
148
|
+
const base = realpathSync(root), dir = path.resolve(base, id);
|
|
149
|
+
if (path.dirname(dir) !== base || lstatSync(dir).isSymbolicLink() || !lstatSync(dir).isDirectory() || realpathSync(dir) !== dir)
|
|
150
|
+
throw new Error('The session must be a real directory directly inside the data folder.');
|
|
151
|
+
const walk = (p) => {
|
|
152
|
+
for (const item of readdirSync(p, { withFileTypes: true })) {
|
|
153
|
+
const child = path.join(p, item.name), st = lstatSync(child);
|
|
154
|
+
if (st.isSymbolicLink()) throw new Error('This session contains a link. Remove the link before deleting the session.');
|
|
155
|
+
if (st.isDirectory()) walk(child);
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
walk(dir);
|
|
159
|
+
if (!existsSync(path.join(dir, 'transcript.jsonl'))) throw new Error('This directory is not a saved session.');
|
|
160
|
+
return dir;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function removeHistory(root, id) {
|
|
164
|
+
const dir = checkedHistoryPath(root, id);
|
|
165
|
+
rmSync(dir, { recursive: true, maxRetries: 2, retryDelay: 30 });
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const number = (n) => Number.isFinite(n) && n >= 0 ? n : 0;
|
|
169
|
+
const cost = (n) => Number.isFinite(n) && n >= 0 ? n : null;
|
|
170
|
+
// Keep a strict allowlist: no titles, replies, prompts, notes, paths, account IDs or raw output.
|
|
171
|
+
function numericCall(row) {
|
|
172
|
+
const usage = row.usage ? Object.fromEntries(['input', 'cacheRead', 'cacheWrite', 'output', 'thinking', 'ms'].map((k) => [k, number(row.usage[k])])) : null;
|
|
173
|
+
if (usage) { usage.cost = cost(row.usage.cost); usage.usageVersion = 3; }
|
|
174
|
+
return { type: 'attempt', seat: { vendor: /^(claude|codex|agy|api)$/.test(row.seat?.vendor) ? row.seat.vendor : 'unknown', ...(row.seat?.model ? { model: String(row.seat.model).slice(0, 200) } : {}) },
|
|
175
|
+
kind: row.kind === 'verdict' || row.type === 'verdict' ? 'verdict' : 'reply', round: number(row.round), fresh: row.fresh === true,
|
|
176
|
+
usage, cost: cost(row.cost), ms: number(row.ms) };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function createUsageStore(root) {
|
|
180
|
+
mkdirSync(root, { recursive: true });
|
|
181
|
+
const file = path.join(root, '_usage.json');
|
|
182
|
+
let reviews = {}, dirty = false;
|
|
183
|
+
if (existsSync(file)) {
|
|
184
|
+
if (lstatSync(file).isSymbolicLink()) throw new Error('The usage statistics file must not be a link.');
|
|
185
|
+
const saved = JSON.parse(readFileSync(file, 'utf8'));
|
|
186
|
+
if (saved.version !== 1 || !saved.reviews || typeof saved.reviews !== 'object' || Array.isArray(saved.reviews)) throw new Error('The usage statistics file is unreadable. Restore _usage.json before cleaning up history.');
|
|
187
|
+
for (const [id, value] of Object.entries(saved.reviews)) {
|
|
188
|
+
if (!/^[a-f0-9]{64}$/.test(id) || !Array.isArray(value?.calls)) throw new Error('The usage statistics file has an invalid record.');
|
|
189
|
+
reviews[id] = { synthetic: value.synthetic === true, calls: value.calls.map(numericCall) };
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
const rows = (includeSynthetic = false) => Object.values(reviews).filter((r) => includeSynthetic || !r.synthetic).flatMap((r) => r.calls);
|
|
193
|
+
return {
|
|
194
|
+
record(id, transcript) {
|
|
195
|
+
const key = createHash('sha256').update(id).digest('hex');
|
|
196
|
+
const value = { synthetic: isSynthetic(transcript), calls: ledgerEntries(normaliseRows(transcript)).map(numericCall) };
|
|
197
|
+
if (JSON.stringify(reviews[key]) !== JSON.stringify(value)) { reviews[key] = value; dirty = true; }
|
|
198
|
+
},
|
|
199
|
+
sync(onWarning = () => {}) {
|
|
200
|
+
for (const id of historyIds(root)) {
|
|
201
|
+
try { checkedHistoryPath(root, id); this.record(id, openDebateDir(root, id, { create: false }).readTranscript({ onWarning })); }
|
|
202
|
+
catch (error) { onWarning(error); }
|
|
203
|
+
}
|
|
204
|
+
this.save();
|
|
205
|
+
},
|
|
206
|
+
save(force = false) {
|
|
207
|
+
if (!dirty && !force && existsSync(file)) return;
|
|
208
|
+
const temporary = path.join(root, `_usage-${randomUUID()}.tmp`);
|
|
209
|
+
try {
|
|
210
|
+
writeFileSync(temporary, JSON.stringify({ version: 1, reviews }), { flag: 'wx', mode: 0o600, flush: true });
|
|
211
|
+
renameSync(temporary, file); dirty = false;
|
|
212
|
+
} finally { if (existsSync(temporary)) unlinkSync(temporary); }
|
|
213
|
+
},
|
|
214
|
+
baselines: () => baselinesFrom(rows()),
|
|
215
|
+
summary(includeSynthetic = false) {
|
|
216
|
+
const entries = Object.values(reviews).filter((r) => includeSynthetic || !r.synthetic);
|
|
217
|
+
return { reviews: entries.length, ...rollup(rows(includeSynthetic)).total };
|
|
218
|
+
},
|
|
219
|
+
};
|
|
220
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// Shared loopback transport for the CLI and MCP. Never retries a write.
|
|
2
|
+
export function localClient(port, signal) {
|
|
3
|
+
const base = `http://127.0.0.1:${port}`;
|
|
4
|
+
let token = process.env.ROUNDTABLE_TOKEN || '';
|
|
5
|
+
const deadline = (ms) => signal ? AbortSignal.any([signal, AbortSignal.timeout(ms)]) : AbortSignal.timeout(ms);
|
|
6
|
+
async function request(route, body) {
|
|
7
|
+
signal?.throwIfAborted();
|
|
8
|
+
if (body !== undefined && !token) {
|
|
9
|
+
const page = await fetch(base, { redirect: 'error', signal: deadline(8000) });
|
|
10
|
+
if (!page.ok) throw new Error('Could not read the local Roundtable page. Check the port.');
|
|
11
|
+
token = /<meta name="roundtable-token" content="([a-f0-9]{32})">/.exec(await page.text())?.[1] || '';
|
|
12
|
+
if (!token) throw new Error('This port did not serve a Roundtable launch token.');
|
|
13
|
+
}
|
|
14
|
+
const response = await fetch(`${base}${route}`, { method: body === undefined ? 'GET' : 'POST', redirect: 'error', signal: deadline(15000),
|
|
15
|
+
...(body === undefined ? {} : { headers: { 'content-type': 'application/json', 'x-roundtable-token': token }, body: JSON.stringify(body) }) });
|
|
16
|
+
let result;
|
|
17
|
+
try {
|
|
18
|
+
let size = 0; const chunks = [];
|
|
19
|
+
for await (const chunk of response.body) {
|
|
20
|
+
size += chunk.length;
|
|
21
|
+
if (size > 8 * 1024 * 1024) throw new Error('Response exceeds 8 MiB.');
|
|
22
|
+
chunks.push(chunk);
|
|
23
|
+
}
|
|
24
|
+
result = JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
25
|
+
} catch (e) {
|
|
26
|
+
signal?.throwIfAborted();
|
|
27
|
+
throw new Error(`The local server returned an unreadable or oversized response. Read the current state before retrying.`, { cause: e });
|
|
28
|
+
}
|
|
29
|
+
if (!response.ok) throw Object.assign(new Error(result.error || 'The local action failed. Read the current state before retrying.'), { status: response.status, diagnostic: result.diagnostic?.id });
|
|
30
|
+
return result;
|
|
31
|
+
}
|
|
32
|
+
return { base, request };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function clientError(error) {
|
|
36
|
+
const message = error.message === 'fetch failed' ? 'Could not reach Roundtable. Start the local server with --no-open and check --port. No write is automatically retried.'
|
|
37
|
+
: ['AbortError', 'TimeoutError'].includes(error.name) ? 'Client request cancelled or timed out. The review may still be running; read status before retrying a write.' : error.message;
|
|
38
|
+
return { ok: false, error: message, ...(error.status ? { status: error.status } : {}), ...(error.diagnostic ? { diagnostic: error.diagnostic } : {}) };
|
|
39
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// A cheap, versioned health check; provider catalogues are not server readiness checks.
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import { mkdtempSync, openSync, closeSync, readFileSync, rmSync } from 'node:fs';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import { realpath } from 'node:fs/promises';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
9
|
+
import { VERSION } from './version.js';
|
|
10
|
+
import { localClient } from './local-http.js';
|
|
11
|
+
|
|
12
|
+
export const LOCAL_API_VERSION = 1;
|
|
13
|
+
const SERVER = fileURLToPath(new URL('./server.js', import.meta.url));
|
|
14
|
+
const transient = (message) => Object.assign(new Error(message), { transient: true });
|
|
15
|
+
async function canonical(dir) {
|
|
16
|
+
const resolved = await realpath(path.resolve(dir)).catch((e) => { if (e.code === 'ENOENT') return path.resolve(dir); throw e; });
|
|
17
|
+
return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function inspectServer(config, { signal, allowOlder = false } = {}) {
|
|
21
|
+
signal?.throwIfAborted();
|
|
22
|
+
let response, body;
|
|
23
|
+
const deadline = AbortSignal.timeout(2000);
|
|
24
|
+
try {
|
|
25
|
+
response = await fetch(`http://127.0.0.1:${config.port}/api/health`, { redirect: 'error', signal: signal ? AbortSignal.any([signal, deadline]) : deadline });
|
|
26
|
+
if ([502, 503, 504].includes(response.status)) { await response.body?.cancel(); throw transient('The local board is still becoming ready.'); }
|
|
27
|
+
try { body = await response.json(); } catch (e) { if (!(e instanceof SyntaxError)) throw e; }
|
|
28
|
+
} catch (e) {
|
|
29
|
+
signal?.throwIfAborted();
|
|
30
|
+
if (e.cause?.code === 'ECONNREFUSED') return null;
|
|
31
|
+
if (e.transient || deadline.aborted || ['ECONNRESET', 'EPIPE', 'ETIMEDOUT', 'UND_ERR_SOCKET', 'UND_ERR_CONNECT_TIMEOUT', 'UND_ERR_HEADERS_TIMEOUT'].includes(e.cause?.code)) throw transient('The local board did not answer its health check yet.');
|
|
32
|
+
throw new Error('Could not verify the local board. Check the port; no review was started.', { cause: e });
|
|
33
|
+
}
|
|
34
|
+
if (!response.ok || body?.service !== 'roundtable' || body.apiVersion !== LOCAL_API_VERSION || !/^[a-f0-9]{32}$/.test(body.instanceId || '') || typeof body.dataDir !== 'string' || !path.isAbsolute(body.dataDir)) {
|
|
35
|
+
throw new Error('The selected port has an incompatible or older server. Stop that board from its original terminal or process, then retry. No review was started.');
|
|
36
|
+
}
|
|
37
|
+
if (body.mode !== (config.fake ? 'demo' : 'live')) throw new Error('The selected port is not a Roundtable server in the expected mode. Check --port and --fake.');
|
|
38
|
+
const expectedDir = config.dataDir ?? process.env.ROUNDTABLE_DEBATES_DIR;
|
|
39
|
+
if (expectedDir && await canonical(body.dataDir) !== await canonical(expectedDir)) throw new Error('The selected port uses a different data directory than ROUNDTABLE_DEBATES_DIR. Select its port or stop that board before using this directory. No review was started.');
|
|
40
|
+
if (!allowOlder && body.version !== VERSION) throw new Error(`The running board is version ${body.version}; this installation is ${VERSION}. When reviews are finished, run roundtable server restart --port ${config.port}${config.fake ? ' --fake' : ''}, then retry.`);
|
|
41
|
+
if (body.shuttingDown) throw transient('The local board is shutting down.');
|
|
42
|
+
return body;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Readiness reads may be retried. A review write is never part of this loop.
|
|
46
|
+
export async function ensureServer(config, { signal, timeoutMs = 35000 } = {}) {
|
|
47
|
+
const end = Date.now() + timeoutMs;
|
|
48
|
+
let spawned = false, spawnFailed = false, exited = null, ready = false, last;
|
|
49
|
+
// The board's stderr goes to a file, never a pipe: it outlives this process, and a pipe to a
|
|
50
|
+
// gone host would break its logging. The file sits in a fresh private temp directory (no
|
|
51
|
+
// predictable path for another user to plant), is read only when the board exits early, and
|
|
52
|
+
// goes once the board is ready (at once where an open file can be unlinked, else when the
|
|
53
|
+
// board exits while this process is still around).
|
|
54
|
+
let log = null;
|
|
55
|
+
const discard = () => { try { if (log) rmSync(path.dirname(log), { recursive: true, force: true }); } catch {} };
|
|
56
|
+
while (Date.now() < end) {
|
|
57
|
+
signal?.throwIfAborted();
|
|
58
|
+
try {
|
|
59
|
+
const status = await inspectServer(config, { signal });
|
|
60
|
+
if (status) { if (spawned) { ready = true; discard(); } return status; }
|
|
61
|
+
if (config.start === false) throw new Error('Roundtable is not running; start the local board or remove --no-start.');
|
|
62
|
+
if (!spawned) {
|
|
63
|
+
spawned = true;
|
|
64
|
+
// Survives the host connection, so closing chat cannot cancel a review or its timer.
|
|
65
|
+
let fd = null;
|
|
66
|
+
try { log = path.join(mkdtempSync(path.join(os.tmpdir(), 'roundtable-start-')), 'stderr.log'); fd = openSync(log, 'wx'); } catch {}
|
|
67
|
+
const child = spawn(process.execPath, [SERVER, '--no-open', ...(config.fake ? ['--fake'] : [])], {
|
|
68
|
+
env: { ...process.env, PORT: String(config.port), ...(config.dataDir ? { ROUNDTABLE_DEBATES_DIR: config.dataDir } : {}) },
|
|
69
|
+
detached: true, windowsHide: true, stdio: ['ignore', 'ignore', fd ?? 'ignore'],
|
|
70
|
+
});
|
|
71
|
+
if (fd != null) closeSync(fd);
|
|
72
|
+
child.once('error', () => { spawnFailed = true; });
|
|
73
|
+
child.once('exit', (code, sig) => { exited = { code, sig, at: Date.now() }; if (ready) discard(); });
|
|
74
|
+
child.unref();
|
|
75
|
+
}
|
|
76
|
+
} catch (e) { if (!e.transient) throw e; last = e; }
|
|
77
|
+
// A board that exits early may have lost the port or the data folder to one that another
|
|
78
|
+
// process started at the same moment (two hosts asking at once), so the port is still
|
|
79
|
+
// watched for a few seconds before the exit counts as the answer.
|
|
80
|
+
if (exited && Date.now() - exited.at > 5000) {
|
|
81
|
+
let tail = '';
|
|
82
|
+
// the board's own last word where it had one, else the message line of a crash, else the last line
|
|
83
|
+
try {
|
|
84
|
+
const lines = readFileSync(log, 'utf8').split(/\r?\n/).filter((l) => l.trim());
|
|
85
|
+
tail = (lines.findLast((l) => l.startsWith('[server]')) || lines.find((l) => /^\w*Error: /.test(l)) || lines.at(-1) || '').trim().slice(0, 200);
|
|
86
|
+
} catch {}
|
|
87
|
+
discard();
|
|
88
|
+
const sandboxed = /cannot listen.*\b(EPERM|EACCES)\b/.test(tail);
|
|
89
|
+
throw new Error(`The local board exited before it was ready${tail ? ` (${tail})` : ''}. ${sandboxed ? 'This environment blocks local servers: start the board from a normal terminal with roundtable server start, then retry.' : 'Run roundtable --no-open in a terminal for startup details.'} No review was started.`);
|
|
90
|
+
}
|
|
91
|
+
if (spawnFailed) break;
|
|
92
|
+
await delay(100, undefined, { signal });
|
|
93
|
+
}
|
|
94
|
+
throw new Error(`The local board could not become ready. Check whether another port owns its data folder, or run roundtable --no-open for startup details. No review was started.${last ? ` ${last.message}` : ''}`);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export async function stopServer(config, { signal } = {}) {
|
|
98
|
+
const status = await inspectServer(config, { signal, allowOlder: true });
|
|
99
|
+
if (!status) return { ok: true, stopped: false };
|
|
100
|
+
await localClient(config.port, signal).request('/api/shutdown', { instanceId: status.instanceId });
|
|
101
|
+
for (let n = 0; n < 50; n++) {
|
|
102
|
+
await delay(100, undefined, { signal });
|
|
103
|
+
try {
|
|
104
|
+
const current = await inspectServer(config, { signal, allowOlder: true });
|
|
105
|
+
if (!current) return { ok: true, stopped: true };
|
|
106
|
+
if (current.instanceId !== status.instanceId) throw new Error('Another board took this port during shutdown. Read its status before retrying.');
|
|
107
|
+
} catch (e) { if (!e.transient) throw e; }
|
|
108
|
+
}
|
|
109
|
+
throw new Error('The board has not stopped yet. Read server status before retrying.');
|
|
110
|
+
}
|
package/src/notes.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// Note sources are declared by the client, not authenticated identities. Keep legacy human
|
|
2
|
+
// strings readable while retaining attribution on notes submitted by the agent client.
|
|
3
|
+
export function noteRecord(note) {
|
|
4
|
+
return {
|
|
5
|
+
text: typeof note === 'string' ? note : String(note?.text || ''),
|
|
6
|
+
source: note?.source === 'agent' ? 'agent' : 'human',
|
|
7
|
+
};
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function storedNote(note) {
|
|
11
|
+
const record = noteRecord(note);
|
|
12
|
+
return record.source === 'human' ? record.text : record;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const noteAuthor = (note) => noteRecord(note).source === 'agent' ? 'Agent coordinator' : 'Human';
|
|
16
|
+
export const noteLabel = (note) => noteRecord(note).source === 'agent' ? 'Agent note' : 'You said';
|
package/src/personas.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Optional board personas. Keep prompt text and artwork together.
|
|
2
|
+
export const PERSONA_EDITION = 'public';
|
|
3
|
+
export const HIDDEN_MOODS = [
|
|
4
|
+
{
|
|
5
|
+
"codes": [
|
|
6
|
+
"cat",
|
|
7
|
+
"meow"
|
|
8
|
+
],
|
|
9
|
+
"art": "h1",
|
|
10
|
+
"text": "You are a cat and cannot speak any human language. Keep every reply header exactly as asked and give STANCE as a real option id, but write every bullet, assumption and question only in cat sounds (meow, mrrp, prrrt, mew, nyaa, a short hiss), varied every time. Never add a translation, an explanation or anything in brackets: nobody gets to know what the cat means.",
|
|
11
|
+
"stripParentheticals": true,
|
|
12
|
+
"sprite": [
|
|
13
|
+
".X........X.",
|
|
14
|
+
".XX......XX.",
|
|
15
|
+
".XXX....XXX.",
|
|
16
|
+
".XXXXXXXXXX.",
|
|
17
|
+
"XXXXXXXXXXXX",
|
|
18
|
+
"XX..XXXX..XX",
|
|
19
|
+
"XX..XXXX..XX",
|
|
20
|
+
"XXXXXXXXXXXX",
|
|
21
|
+
"XXXXX..XXXXX",
|
|
22
|
+
".XXX.XX.XXX.",
|
|
23
|
+
"..XXXXXXXX..",
|
|
24
|
+
"....XXXX...."
|
|
25
|
+
]
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
"codes": [
|
|
29
|
+
"ufo",
|
|
30
|
+
"alien"
|
|
31
|
+
],
|
|
32
|
+
"art": "h2",
|
|
33
|
+
"text": "You are an alien from a planet nobody has heard of, and you are very sure of yourself. Keep the reply headers exactly as asked and put one of the option ids after STANCE, picked however you like. Everything else is pure character: the answer does not matter, mistakes are fine, being funny is the whole point. Talk in confident, sentence-shaped alien babble, like a strange alien in a cartoon. The only real words you know are tiny glue words (when, your, would, just, the, is, not, will, and; or the same in the reply language) and a few odd vivid ones (green, matter, juice, wobble, blackhole, soup). Every other word is made up on the spot: glorp, zagorp, zabalorp, glorpix, badorg, florbix, quazzle, blarnth, snorvak, and many more of your own. Examples of the shape: \"when your zabalorp becomes a green matter, glorpix would just blackhole your badorg\"; \"not the florbix, never the florbix, the snorvak is already soup\"; \"zagorp will wobble, and your blarnth just goes glorp\". Never name the real things in the question (no products, devices, brands, money, batteries, offices, years, numbers) and never explain or translate: the humans should feel they almost understand, and then they do not. Invent new words in every line, bring a favourite one back when excited, let some grow (glorp, glorpix, megaglorpinax), and now and then drop a beep, click or glyph (bzzt, tk-tk, ⟟⍜⏃⌰). Never copy another seat's sounds or words.",
|
|
34
|
+
"sprite": [
|
|
35
|
+
"...XXXXXX...",
|
|
36
|
+
".XXXXXXXXXX.",
|
|
37
|
+
"XXXXXXXXXXXX",
|
|
38
|
+
"X...XXXX...X",
|
|
39
|
+
"X....XX....X",
|
|
40
|
+
"XX...XX...XX",
|
|
41
|
+
".XX.XXXX.XX.",
|
|
42
|
+
"..XXXXXXXX..",
|
|
43
|
+
"...XXXXXX...",
|
|
44
|
+
"....XXXX....",
|
|
45
|
+
".....XX.....",
|
|
46
|
+
"............"
|
|
47
|
+
]
|
|
48
|
+
}
|
|
49
|
+
];
|