agentgui 1.0.882 → 1.0.884
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/README.md +11 -5
- package/docs/screenshots/conversation-dark.png +0 -0
- package/docs/screenshots/conversation-light.png +0 -0
- package/docs/screenshots/home-dark.png +0 -0
- package/docs/screenshots/home-light.png +0 -0
- package/docs/screenshots/tools-manager.png +0 -0
- package/fixtures/data.db +0 -0
- package/lib/server-startup.js +10 -6
- package/lib/server-startup2.js +1 -0
- package/package.json +4 -2
- package/scripts/build-rippleui.mjs +84 -0
- package/scripts/capture-screenshots.mjs +191 -0
- package/scripts/harvest-fixtures.mjs +211 -0
- package/static/css/gmail-skin.css +378 -0
- package/static/css/main.css +777 -0
- package/static/index.html +1 -4
- package/static/vendor/rippleui.css +35 -1
- package/static/css/ui-fixes-2.css +0 -179
- package/static/css/ui-fixes-3.css +0 -235
- package/static/css/ui-fixes-4.css +0 -156
- package/static/css/ui-fixes.css +0 -193
package/README.md
CHANGED
|
@@ -29,7 +29,7 @@ Multi-agent GUI client for AI coding agents with real-time streaming, WebSocket
|
|
|
29
29
|
| Kiro CLI | ACP | - |
|
|
30
30
|
| fast-agent | ACP | - |
|
|
31
31
|
|
|
32
|
-

|
|
33
33
|
|
|
34
34
|
## Why AgentGUI?
|
|
35
35
|
|
|
@@ -65,11 +65,17 @@ Modern AI coding requires juggling multiple agents, each in their own terminal.
|
|
|
65
65
|
|
|
66
66
|
| Light Mode | Dark Mode |
|
|
67
67
|
|------------|-----------|
|
|
68
|
-
|  |  |
|
|
69
69
|
|
|
70
|
-
| Active Conversation |
|
|
71
|
-
|
|
72
|
-
|  | Active Conversation (dark) |
|
|
71
|
+
|-----------------------------|----------------------------|
|
|
72
|
+
|  |  |
|
|
73
|
+
|
|
74
|
+
| Tools Manager |
|
|
75
|
+
|---------------|
|
|
76
|
+
|  |
|
|
77
|
+
|
|
78
|
+
> Screenshots are regenerated on every push to `main` by `.github/workflows/gh-pages.yml` using the committed fixture DB (`fixtures/data.db`) so the gallery stays in sync with the UI.
|
|
73
79
|
|
|
74
80
|
## Quick Start
|
|
75
81
|
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/fixtures/data.db
ADDED
|
Binary file
|
package/lib/server-startup.js
CHANGED
|
@@ -22,12 +22,16 @@ export function createOnServerReady({ queries, broadcastSync, warmAssetCache, st
|
|
|
22
22
|
try { queries.cleanup(); console.log('[cleanup] Scheduled DB cleanup complete'); } catch (e) { console.error('[cleanup] Error:', e.message); }
|
|
23
23
|
}, 6 * 60 * 60 * 1000);
|
|
24
24
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
25
|
+
if (process.env.AGENTGUI_SKIP_AUTO_IMPORT === '1') {
|
|
26
|
+
console.log('[JSONL] Watcher skipped (AGENTGUI_SKIP_AUTO_IMPORT=1)');
|
|
27
|
+
} else {
|
|
28
|
+
try {
|
|
29
|
+
jsonlWatcher = new JsonlWatcher({ broadcastSync, queries });
|
|
30
|
+
jsonlWatcher.start();
|
|
31
|
+
if (setWatcher) setWatcher(jsonlWatcher);
|
|
32
|
+
console.log('[JSONL] Watcher started');
|
|
33
|
+
} catch (err) { console.error('[JSONL] Watcher failed to start:', err.message); }
|
|
34
|
+
}
|
|
31
35
|
|
|
32
36
|
resumeInterruptedStreams().catch(err => console.error('[RESUME] Startup error:', err.message));
|
|
33
37
|
|
package/lib/server-startup2.js
CHANGED
|
@@ -26,6 +26,7 @@ export function createAutoImport({ queries, broadcastSync }) {
|
|
|
26
26
|
|
|
27
27
|
function performAutoImport() {
|
|
28
28
|
try {
|
|
29
|
+
if (process.env.AGENTGUI_SKIP_AUTO_IMPORT === '1') return;
|
|
29
30
|
if (!hasIndexFilesChanged()) return;
|
|
30
31
|
const imported = queries.importClaudeCodeConversations();
|
|
31
32
|
if (imported.length > 0) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agentgui",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.884",
|
|
4
4
|
"description": "Multi-agent ACP client with real-time communication",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "electron/main.js",
|
|
@@ -61,6 +61,8 @@
|
|
|
61
61
|
"onnxruntime-node": "1.21.0"
|
|
62
62
|
},
|
|
63
63
|
"devDependencies": {
|
|
64
|
-
"electron": "^35.0.0"
|
|
64
|
+
"electron": "^35.0.0",
|
|
65
|
+
"playwright": "^1.59.1",
|
|
66
|
+
"rippleui": "^1.12.1"
|
|
65
67
|
}
|
|
66
68
|
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// One-time build: produces static/vendor/rippleui.css from the rippleui npm package.
|
|
3
|
+
// Buildless at runtime — commit the output so `npm install` is enough to serve.
|
|
4
|
+
//
|
|
5
|
+
// Source files (from node_modules/rippleui/dist/css/):
|
|
6
|
+
// base.css - Tailwind preflight/reset (~2KB)
|
|
7
|
+
// components.css - RippleUI components (.btn etc.) (~143KB)
|
|
8
|
+
// utilities.css - RippleUI extra utilities (~0.05KB)
|
|
9
|
+
// styles.css - full TW + RippleUI monolith (~4.7MB) — we only mine token defs
|
|
10
|
+
//
|
|
11
|
+
// Output: static/vendor/rippleui.css containing
|
|
12
|
+
// 1. :root / html.dark token blocks extracted from styles.css (so components.css works)
|
|
13
|
+
// 2. base.css
|
|
14
|
+
// 3. components.css
|
|
15
|
+
// 4. utilities.css
|
|
16
|
+
|
|
17
|
+
import fs from 'fs';
|
|
18
|
+
import path from 'path';
|
|
19
|
+
import { fileURLToPath } from 'url';
|
|
20
|
+
|
|
21
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
22
|
+
const ROOT = path.resolve(__dirname, '..');
|
|
23
|
+
const RUI = path.join(ROOT, 'node_modules/rippleui/dist/css');
|
|
24
|
+
const OUT = path.join(ROOT, 'static/vendor/rippleui.css');
|
|
25
|
+
|
|
26
|
+
if (!fs.existsSync(RUI)) {
|
|
27
|
+
console.error('rippleui not installed. Run: bun add --dev rippleui');
|
|
28
|
+
process.exit(1);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const base = fs.readFileSync(path.join(RUI, 'base.css'), 'utf8');
|
|
32
|
+
const components = fs.readFileSync(path.join(RUI, 'components.css'), 'utf8');
|
|
33
|
+
const utilities = fs.readFileSync(path.join(RUI, 'utilities.css'), 'utf8');
|
|
34
|
+
const styles = fs.readFileSync(path.join(RUI, 'styles.css'), 'utf8');
|
|
35
|
+
|
|
36
|
+
// Extract token blocks. RippleUI defines its design tokens in :root { ... } and html.dark { ... }
|
|
37
|
+
// Find every block that contains a RippleUI-signature token like --backgroundPrimary or --gray-3.
|
|
38
|
+
const tokenBlocks = [];
|
|
39
|
+
const blockRe = /([:a-z.][^{}]*\{[^{}]*\})/gi;
|
|
40
|
+
let m;
|
|
41
|
+
while ((m = blockRe.exec(styles))) {
|
|
42
|
+
const block = m[1];
|
|
43
|
+
if (/--backgroundPrimary|--gray-\d|--content1|--primary:\s*\d/.test(block)) {
|
|
44
|
+
tokenBlocks.push(block);
|
|
45
|
+
if (tokenBlocks.length > 20) break;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (tokenBlocks.length === 0) {
|
|
50
|
+
console.error('Could not find token blocks in styles.css — rippleui shape changed?');
|
|
51
|
+
process.exit(1);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const header = `/* RippleUI v${readPkgVersion()} — localized for AgentGUI.\n` +
|
|
55
|
+
` * Generated by scripts/build-rippleui.mjs — do not edit by hand.\n` +
|
|
56
|
+
` * Regenerate: bun run scripts/build-rippleui.mjs\n` +
|
|
57
|
+
` */\n`;
|
|
58
|
+
|
|
59
|
+
// RippleUI targets [data-theme=dark] for dark mode; AgentGUI uses `html.dark`.
|
|
60
|
+
// Duplicate every `[data-theme=dark]` selector with `html.dark` so both work.
|
|
61
|
+
function mirrorDarkSelector(css) {
|
|
62
|
+
return css.replace(/\[data-theme=dark\]/g, 'html.dark, [data-theme=dark]');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const bundled = header +
|
|
66
|
+
'/* --- rippleui design tokens (extracted from styles.css) --- */\n' +
|
|
67
|
+
mirrorDarkSelector(tokenBlocks.join('\n')) + '\n\n' +
|
|
68
|
+
'/* --- base.css (tailwind preflight) --- */\n' +
|
|
69
|
+
base + '\n\n' +
|
|
70
|
+
'/* --- components.css --- */\n' +
|
|
71
|
+
mirrorDarkSelector(components) + '\n\n' +
|
|
72
|
+
'/* --- utilities.css --- */\n' +
|
|
73
|
+
mirrorDarkSelector(utilities) + '\n';
|
|
74
|
+
|
|
75
|
+
fs.mkdirSync(path.dirname(OUT), { recursive: true });
|
|
76
|
+
fs.writeFileSync(OUT, bundled);
|
|
77
|
+
const kb = (bundled.length / 1024).toFixed(1);
|
|
78
|
+
console.log(`wrote ${OUT} (${kb}KB, ${tokenBlocks.length} token blocks)`);
|
|
79
|
+
|
|
80
|
+
function readPkgVersion() {
|
|
81
|
+
try {
|
|
82
|
+
return JSON.parse(fs.readFileSync(path.join(ROOT, 'node_modules/rippleui/package.json'), 'utf8')).version;
|
|
83
|
+
} catch { return 'unknown'; }
|
|
84
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Boot agentgui with a fixture DB on an ephemeral port and capture a set of PNGs.
|
|
3
|
+
// Uses puppeteer-core with a system-provided chromium (set CHROME or it autodetects).
|
|
4
|
+
//
|
|
5
|
+
// Usage: node scripts/capture-screenshots.mjs [--fixtures=./fixtures] [--out=./docs/screenshots]
|
|
6
|
+
|
|
7
|
+
import fs from 'fs';
|
|
8
|
+
import path from 'path';
|
|
9
|
+
import net from 'net';
|
|
10
|
+
import { spawn } from 'child_process';
|
|
11
|
+
import { fileURLToPath } from 'url';
|
|
12
|
+
|
|
13
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
14
|
+
const ROOT = path.resolve(__dirname, '..');
|
|
15
|
+
|
|
16
|
+
const argMap = Object.fromEntries(process.argv.slice(2).map(a => {
|
|
17
|
+
const [k, v] = a.replace(/^--/, '').split('=');
|
|
18
|
+
return [k, v ?? true];
|
|
19
|
+
}));
|
|
20
|
+
const FIXTURES = path.resolve(argMap.fixtures || path.join(ROOT, 'fixtures'));
|
|
21
|
+
const OUT = path.resolve(argMap.out || path.join(ROOT, 'docs/screenshots'));
|
|
22
|
+
|
|
23
|
+
function pickChrome() {
|
|
24
|
+
if (process.env.CHROME && fs.existsSync(process.env.CHROME)) return process.env.CHROME;
|
|
25
|
+
const candidates = [
|
|
26
|
+
'/usr/bin/chromium',
|
|
27
|
+
'/usr/bin/chromium-browser',
|
|
28
|
+
'/usr/bin/google-chrome',
|
|
29
|
+
'/usr/bin/google-chrome-stable',
|
|
30
|
+
'/snap/bin/chromium',
|
|
31
|
+
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
|
32
|
+
];
|
|
33
|
+
for (const c of candidates) if (fs.existsSync(c)) return c;
|
|
34
|
+
throw new Error('No chromium binary found. Set CHROME env var or apt install chromium.');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function findFreePort() {
|
|
38
|
+
return await new Promise((resolve, reject) => {
|
|
39
|
+
const s = net.createServer();
|
|
40
|
+
s.unref();
|
|
41
|
+
s.on('error', reject);
|
|
42
|
+
s.listen(0, () => {
|
|
43
|
+
const p = s.address().port;
|
|
44
|
+
s.close(() => resolve(p));
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function waitForHealthy(url, timeoutMs = 20000) {
|
|
50
|
+
const start = Date.now();
|
|
51
|
+
while (Date.now() - start < timeoutMs) {
|
|
52
|
+
try {
|
|
53
|
+
const r = await fetch(url);
|
|
54
|
+
if (r.ok) return true;
|
|
55
|
+
} catch {}
|
|
56
|
+
await new Promise(r => setTimeout(r, 250));
|
|
57
|
+
}
|
|
58
|
+
throw new Error(`Server did not become healthy at ${url} within ${timeoutMs}ms`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function main() {
|
|
62
|
+
fs.mkdirSync(OUT, { recursive: true });
|
|
63
|
+
if (!fs.existsSync(FIXTURES)) {
|
|
64
|
+
console.warn(`[capture] fixture dir does not exist: ${FIXTURES} — using empty data dir`);
|
|
65
|
+
fs.mkdirSync(FIXTURES, { recursive: true });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const port = await findFreePort();
|
|
69
|
+
const BASE = `http://localhost:${port}/gm`;
|
|
70
|
+
|
|
71
|
+
console.log(`[capture] booting server on :${port} with data=${FIXTURES}`);
|
|
72
|
+
const serverEnv = {
|
|
73
|
+
...process.env,
|
|
74
|
+
PASSWORD: '',
|
|
75
|
+
PORT: String(port),
|
|
76
|
+
HOT_RELOAD: 'false',
|
|
77
|
+
STARTUP_CWD: FIXTURES,
|
|
78
|
+
PORTABLE_DATA_DIR: FIXTURES,
|
|
79
|
+
BASE_URL: '/gm',
|
|
80
|
+
AGENTGUI_SKIP_AUTO_IMPORT: '1', // do not merge user's ~/.claude/projects into the fixture DB
|
|
81
|
+
};
|
|
82
|
+
const server = spawn(process.execPath, [path.join(ROOT, 'server.js')], {
|
|
83
|
+
env: serverEnv,
|
|
84
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
85
|
+
cwd: ROOT,
|
|
86
|
+
});
|
|
87
|
+
const srvLog = [];
|
|
88
|
+
server.stdout.on('data', d => srvLog.push(d));
|
|
89
|
+
server.stderr.on('data', d => srvLog.push(d));
|
|
90
|
+
|
|
91
|
+
const cleanup = () => {
|
|
92
|
+
try { server.kill('SIGTERM'); } catch {}
|
|
93
|
+
setTimeout(() => { try { server.kill('SIGKILL'); } catch {} }, 1500);
|
|
94
|
+
};
|
|
95
|
+
process.on('exit', cleanup);
|
|
96
|
+
process.on('SIGINT', () => { cleanup(); process.exit(130); });
|
|
97
|
+
|
|
98
|
+
try {
|
|
99
|
+
await waitForHealthy(`${BASE}/api/health`);
|
|
100
|
+
console.log('[capture] server healthy — launching browser');
|
|
101
|
+
|
|
102
|
+
const { default: puppeteer } = await import('puppeteer-core');
|
|
103
|
+
const browser = await puppeteer.launch({
|
|
104
|
+
executablePath: pickChrome(),
|
|
105
|
+
headless: 'new',
|
|
106
|
+
args: ['--no-sandbox', '--disable-dev-shm-usage', '--disable-gpu', '--hide-scrollbars'],
|
|
107
|
+
defaultViewport: { width: 1440, height: 900, deviceScaleFactor: 1 },
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
const shots = [
|
|
111
|
+
{ name: 'home-light', theme: 'light', url: `${BASE}/` },
|
|
112
|
+
{ name: 'home-dark', theme: 'dark', url: `${BASE}/` },
|
|
113
|
+
{ name: 'conversation-light', theme: 'light', url: `${BASE}/`, firstConv: true },
|
|
114
|
+
{ name: 'conversation-dark', theme: 'dark', url: `${BASE}/`, firstConv: true },
|
|
115
|
+
{ name: 'tools-manager', theme: 'light', url: `${BASE}/`, openTools: true },
|
|
116
|
+
];
|
|
117
|
+
|
|
118
|
+
for (const s of shots) {
|
|
119
|
+
const page = await browser.newPage();
|
|
120
|
+
page.setDefaultNavigationTimeout(15000);
|
|
121
|
+
await page.emulateMediaFeatures([{ name: 'prefers-reduced-motion', value: 'reduce' }]);
|
|
122
|
+
await page.evaluateOnNewDocument((theme) => {
|
|
123
|
+
try { localStorage.setItem('theme', theme); } catch {}
|
|
124
|
+
if (theme === 'dark') {
|
|
125
|
+
document.documentElement.classList.add('dark');
|
|
126
|
+
document.documentElement.setAttribute('data-theme', 'dark');
|
|
127
|
+
}
|
|
128
|
+
// Determinism: disable transitions/animations so repeated shots match byte-for-byte
|
|
129
|
+
const style = document.createElement('style');
|
|
130
|
+
style.textContent = `*,*::before,*::after{transition:none!important;animation:none!important;caret-color:transparent!important}`;
|
|
131
|
+
if (document.head) document.head.appendChild(style);
|
|
132
|
+
else document.addEventListener('DOMContentLoaded', () => document.head.appendChild(style));
|
|
133
|
+
}, s.theme);
|
|
134
|
+
|
|
135
|
+
console.log(`[capture] -> ${s.name} (${s.theme})`);
|
|
136
|
+
await page.goto(s.url, { waitUntil: 'networkidle2' });
|
|
137
|
+
// Determinism: wait until the sidebar has transitioned out of "Loading..."
|
|
138
|
+
// and rendered at least one conversation row.
|
|
139
|
+
await page.waitForFunction(() => {
|
|
140
|
+
const ul = document.querySelector('.sidebar-list');
|
|
141
|
+
if (!ul) return false;
|
|
142
|
+
const rows = ul.querySelectorAll('li:not(.sidebar-empty)');
|
|
143
|
+
return rows.length > 0;
|
|
144
|
+
}, { timeout: 8000 }).catch(() => {});
|
|
145
|
+
await new Promise(r => setTimeout(r, 600));
|
|
146
|
+
|
|
147
|
+
if (s.firstConv) {
|
|
148
|
+
const clicked = await page.evaluate(() => {
|
|
149
|
+
const row = document.querySelector('.sidebar-list li:not(.sidebar-empty), .conversation-item:not(.sidebar-empty)');
|
|
150
|
+
if (row) { row.click(); return true; }
|
|
151
|
+
return false;
|
|
152
|
+
});
|
|
153
|
+
if (clicked) {
|
|
154
|
+
await page.waitForFunction(() => {
|
|
155
|
+
const out = document.querySelector('#output');
|
|
156
|
+
return out && out.children.length > 0;
|
|
157
|
+
}, { timeout: 5000 }).catch(() => {});
|
|
158
|
+
await new Promise(r => setTimeout(r, 600));
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
if (s.openTools) {
|
|
162
|
+
const clicked = await page.evaluate(() => {
|
|
163
|
+
const btn = document.getElementById('toolsManagerBtn');
|
|
164
|
+
if (btn) { btn.click(); return true; }
|
|
165
|
+
return false;
|
|
166
|
+
});
|
|
167
|
+
if (clicked) await new Promise(r => setTimeout(r, 400));
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const outPath = path.join(OUT, `${s.name}.png`);
|
|
171
|
+
await page.screenshot({ path: outPath, type: 'png', fullPage: false });
|
|
172
|
+
const sz = fs.statSync(outPath).size;
|
|
173
|
+
console.log(` wrote ${outPath} (${(sz/1024).toFixed(1)}KB)`);
|
|
174
|
+
await page.close();
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
await browser.close();
|
|
178
|
+
} finally {
|
|
179
|
+
cleanup();
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Final summary
|
|
183
|
+
const produced = fs.readdirSync(OUT).filter(f => f.endsWith('.png')).sort();
|
|
184
|
+
console.log(`\n[capture] done. ${produced.length} png(s) in ${OUT}`);
|
|
185
|
+
for (const f of produced) {
|
|
186
|
+
const p = path.join(OUT, f);
|
|
187
|
+
console.log(` ${f} ${(fs.statSync(p).size/1024).toFixed(1)}KB`);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
main().catch(e => { console.error(e); process.exit(1); });
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Build fixtures/demo.sqlite from ~/.claude/projects/*.jsonl.
|
|
3
|
+
// Output is deterministic (seeded timestamps, sorted inserts) so the same source
|
|
4
|
+
// JSONL produces byte-identical DB output.
|
|
5
|
+
//
|
|
6
|
+
// Anonymisation: absolute paths -> /home/demo/..., emails redacted, API-key-looking
|
|
7
|
+
// strings redacted, real session IDs replaced with deterministic uuids per-conv.
|
|
8
|
+
//
|
|
9
|
+
// If ~/.claude/projects is missing or has no usable JSONL, the script still produces
|
|
10
|
+
// a valid DB using six synthesized reference conversations.
|
|
11
|
+
|
|
12
|
+
import fs from 'fs';
|
|
13
|
+
import path from 'path';
|
|
14
|
+
import os from 'os';
|
|
15
|
+
import crypto from 'crypto';
|
|
16
|
+
import { fileURLToPath } from 'url';
|
|
17
|
+
import { createRequire } from 'module';
|
|
18
|
+
|
|
19
|
+
const require = createRequire(import.meta.url);
|
|
20
|
+
const Database = require('better-sqlite3');
|
|
21
|
+
|
|
22
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
23
|
+
const ROOT = path.resolve(__dirname, '..');
|
|
24
|
+
const OUT_DIR = path.join(ROOT, 'fixtures');
|
|
25
|
+
const OUT_DB = path.join(OUT_DIR, 'data.db');
|
|
26
|
+
const SRC_DIR = path.join(os.homedir(), '.claude', 'projects');
|
|
27
|
+
|
|
28
|
+
// Fixed base timestamp so the DB is reproducible
|
|
29
|
+
const BASE_TS = Date.parse('2026-03-15T10:00:00Z');
|
|
30
|
+
|
|
31
|
+
// Titles we want in the demo sidebar
|
|
32
|
+
const DEMO_TITLES = [
|
|
33
|
+
'Fix failing tests in db-queries',
|
|
34
|
+
'Refactor auth middleware',
|
|
35
|
+
'Add dark mode toggle',
|
|
36
|
+
'Write migration for user schema',
|
|
37
|
+
'Debug WebSocket reconnect loop',
|
|
38
|
+
'Generate API docs from handlers',
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
function det(prefix, ...seeds) {
|
|
42
|
+
const h = crypto.createHash('sha256').update(prefix + '::' + seeds.join('|')).digest('hex');
|
|
43
|
+
return `${prefix}-${h.slice(0, 16)}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Anonymise — returns cleaned text
|
|
47
|
+
function scrub(text) {
|
|
48
|
+
if (typeof text !== 'string') return text;
|
|
49
|
+
return text
|
|
50
|
+
.replace(/\/(?:config|home|root|Users)\/[A-Za-z0-9_\-./]+/g, '/home/demo/workspace')
|
|
51
|
+
.replace(/[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g, 'dev@example.com')
|
|
52
|
+
.replace(/sk-[A-Za-z0-9]{20,}/g, 'sk-REDACTED')
|
|
53
|
+
.replace(/ghp_[A-Za-z0-9]{20,}/g, 'ghp_REDACTED')
|
|
54
|
+
.replace(/Bearer\s+[A-Za-z0-9._-]{20,}/gi, 'Bearer REDACTED');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Synthesize a realistic assistant reply given a user prompt theme
|
|
58
|
+
function synthAssistant(title) {
|
|
59
|
+
const lookup = {
|
|
60
|
+
'Fix failing tests in db-queries':
|
|
61
|
+
`I'll run the test suite first to see which specs are failing, then trace the errors.\n\n` +
|
|
62
|
+
`After running \`bun test\`, I can see three assertions in \`createConversation\` are failing ` +
|
|
63
|
+
`because the \`agentId\` column was renamed. I'll update the query builder in ` +
|
|
64
|
+
`\`lib/db-queries.js\` to use the new column and patch the associated tests.\n\n` +
|
|
65
|
+
`The fix is a one-line change to the INSERT statement. All 19 tests pass now.`,
|
|
66
|
+
'Refactor auth middleware':
|
|
67
|
+
`The current auth middleware has three responsibilities — rate limiting, basic-auth check, ` +
|
|
68
|
+
`and CORS — packed into a single 80-line function. I'll split them into three small ` +
|
|
69
|
+
`middlewares in \`lib/http-middlewares/\` and compose them in \`http-handler.js\`. ` +
|
|
70
|
+
`No behaviour change, just readability.`,
|
|
71
|
+
'Add dark mode toggle':
|
|
72
|
+
`Dark mode is already wired through CSS custom properties in \`main.css\` (\`:root\` + \`html.dark\`). ` +
|
|
73
|
+
`We just need a toggle button in the header and a listener that flips the class on \`<html>\` ` +
|
|
74
|
+
`and persists the choice in \`localStorage\`. I'll also respect \`prefers-color-scheme\` for the ` +
|
|
75
|
+
`first visit.`,
|
|
76
|
+
'Write migration for user schema':
|
|
77
|
+
`Adding \`last_login_at INTEGER\` and \`preferences JSON\` to the users table. I'll write the ` +
|
|
78
|
+
`migration as \`database-migrations-user.js\`, gate it on a schema-version row, and backfill ` +
|
|
79
|
+
`\`last_login_at\` from the \`sessions\` table's most-recent entry per user. Safe under concurrent writes.`,
|
|
80
|
+
'Debug WebSocket reconnect loop':
|
|
81
|
+
`Found it. The ws-machine transitions to \`reconnecting\` on \`close\`, but the timer was never ` +
|
|
82
|
+
`cleared when a message arrived mid-wait, so two sockets were briefly open. I added a guard in ` +
|
|
83
|
+
`\`static/js/ws-machine.js\` and the duplicate connection disappears.`,
|
|
84
|
+
'Generate API docs from handlers':
|
|
85
|
+
`Scanning \`lib/routes-*.js\` for \`router.handle\` / \`app.<method>\` declarations and producing a ` +
|
|
86
|
+
`markdown doc grouped by module. Output goes to \`docs/api.md\`. I'll add a JSDoc-style comment ` +
|
|
87
|
+
`parser so handler descriptions can live inline with the route.`,
|
|
88
|
+
};
|
|
89
|
+
return lookup[title] || 'Done — see the diff for details.';
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function synthUser(title) {
|
|
93
|
+
const lookup = {
|
|
94
|
+
'Fix failing tests in db-queries':
|
|
95
|
+
'Hey — the test suite has been flaky since yesterday. Can you figure out which specs are failing and fix them? Start with `bun test` and work from there.',
|
|
96
|
+
'Refactor auth middleware':
|
|
97
|
+
`The auth middleware in lib/http-handler.js has grown into a monster. Please split it into single-responsibility pieces.`,
|
|
98
|
+
'Add dark mode toggle':
|
|
99
|
+
`Can we add a theme toggle button in the header? It should remember the user's choice and default to the system preference.`,
|
|
100
|
+
'Write migration for user schema':
|
|
101
|
+
`Add last_login_at and a preferences JSON column to the users table. Include a backfill for existing rows.`,
|
|
102
|
+
'Debug WebSocket reconnect loop':
|
|
103
|
+
`Clients are getting stuck in a reconnect loop — the network tab shows two sockets opening before one closes. Can you trace it in ws-machine and fix?`,
|
|
104
|
+
'Generate API docs from handlers':
|
|
105
|
+
`We need API docs. Generate them from the route handler declarations and dump to docs/api.md.`,
|
|
106
|
+
};
|
|
107
|
+
return lookup[title] || 'Please take a look.';
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// If we can harvest a user message that looks natural from real JSONL, use it,
|
|
111
|
+
// otherwise fall back to the synthesized one.
|
|
112
|
+
function harvestPrompts() {
|
|
113
|
+
const pool = [];
|
|
114
|
+
if (!fs.existsSync(SRC_DIR)) return pool;
|
|
115
|
+
try {
|
|
116
|
+
for (const projDir of fs.readdirSync(SRC_DIR)) {
|
|
117
|
+
const full = path.join(SRC_DIR, projDir);
|
|
118
|
+
if (!fs.statSync(full).isDirectory()) continue;
|
|
119
|
+
for (const f of fs.readdirSync(full)) {
|
|
120
|
+
if (!f.endsWith('.jsonl')) continue;
|
|
121
|
+
const lines = fs.readFileSync(path.join(full, f), 'utf8').split('\n').filter(Boolean);
|
|
122
|
+
for (const l of lines) {
|
|
123
|
+
try {
|
|
124
|
+
const e = JSON.parse(l);
|
|
125
|
+
if (e.type !== 'user') continue;
|
|
126
|
+
const msg = e.message?.content;
|
|
127
|
+
if (!msg) continue;
|
|
128
|
+
const text = Array.isArray(msg)
|
|
129
|
+
? msg.find(c => c.type === 'text')?.text
|
|
130
|
+
: typeof msg === 'string' ? msg : null;
|
|
131
|
+
if (!text) continue;
|
|
132
|
+
// Keep only naturally-phrased (no tool_use_id etc.) prompts, 40-400 chars
|
|
133
|
+
if (text.length < 40 || text.length > 400) continue;
|
|
134
|
+
if (/tool_use|tool_result|<function_calls/.test(text)) continue;
|
|
135
|
+
pool.push(scrub(text));
|
|
136
|
+
if (pool.length > 50) return pool;
|
|
137
|
+
} catch {}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
} catch {}
|
|
142
|
+
return pool;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function build() {
|
|
146
|
+
fs.mkdirSync(OUT_DIR, { recursive: true });
|
|
147
|
+
if (fs.existsSync(OUT_DB)) fs.unlinkSync(OUT_DB);
|
|
148
|
+
|
|
149
|
+
const db = new Database(OUT_DB);
|
|
150
|
+
db.pragma('journal_mode = WAL');
|
|
151
|
+
db.pragma('foreign_keys = ON');
|
|
152
|
+
|
|
153
|
+
// Inline the subset of the schema needed to render conversations.
|
|
154
|
+
db.exec(`
|
|
155
|
+
CREATE TABLE IF NOT EXISTS conversations (
|
|
156
|
+
id TEXT PRIMARY KEY, agentId TEXT NOT NULL, title TEXT,
|
|
157
|
+
created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL,
|
|
158
|
+
status TEXT DEFAULT 'active'
|
|
159
|
+
);
|
|
160
|
+
CREATE INDEX IF NOT EXISTS idx_conversations_agent ON conversations(agentId);
|
|
161
|
+
CREATE INDEX IF NOT EXISTS idx_conversations_updated ON conversations(updated_at DESC);
|
|
162
|
+
|
|
163
|
+
CREATE TABLE IF NOT EXISTS messages (
|
|
164
|
+
id TEXT PRIMARY KEY, conversationId TEXT NOT NULL, role TEXT NOT NULL,
|
|
165
|
+
content TEXT NOT NULL, created_at INTEGER NOT NULL
|
|
166
|
+
);
|
|
167
|
+
CREATE INDEX IF NOT EXISTS idx_messages_conversation ON messages(conversationId);
|
|
168
|
+
|
|
169
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
170
|
+
id TEXT PRIMARY KEY, conversationId TEXT NOT NULL, status TEXT NOT NULL,
|
|
171
|
+
started_at INTEGER NOT NULL, completed_at INTEGER, response TEXT, error TEXT
|
|
172
|
+
);
|
|
173
|
+
CREATE INDEX IF NOT EXISTS idx_sessions_conversation ON sessions(conversationId);
|
|
174
|
+
`);
|
|
175
|
+
|
|
176
|
+
const insConv = db.prepare(`INSERT INTO conversations (id, agentId, title, created_at, updated_at, status)
|
|
177
|
+
VALUES (?, ?, ?, ?, ?, ?)`);
|
|
178
|
+
const insMsg = db.prepare(`INSERT INTO messages (id, conversationId, role, content, created_at)
|
|
179
|
+
VALUES (?, ?, ?, ?, ?)`);
|
|
180
|
+
const insSess = db.prepare(`INSERT INTO sessions (id, conversationId, status, started_at, completed_at, response, error)
|
|
181
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`);
|
|
182
|
+
|
|
183
|
+
const realPrompts = harvestPrompts();
|
|
184
|
+
const tx = db.transaction(() => {
|
|
185
|
+
DEMO_TITLES.forEach((title, idx) => {
|
|
186
|
+
const convId = det('conv', title);
|
|
187
|
+
const ts = BASE_TS - idx * 3600 * 1000; // newest first
|
|
188
|
+
insConv.run(convId, 'claude-code', title, ts, ts, 'active');
|
|
189
|
+
|
|
190
|
+
const userText = realPrompts[idx] || synthUser(title);
|
|
191
|
+
const assistantText = synthAssistant(title);
|
|
192
|
+
const userMsgId = det('msg', convId, 'user');
|
|
193
|
+
const asstMsgId = det('msg', convId, 'asst');
|
|
194
|
+
const sessId = det('sess', convId);
|
|
195
|
+
|
|
196
|
+
insMsg.run(userMsgId, convId, 'user', userText, ts);
|
|
197
|
+
insSess.run(sessId, convId, 'completed', ts + 1000, ts + 12000, assistantText, null);
|
|
198
|
+
insMsg.run(asstMsgId, convId, 'assistant', assistantText, ts + 12000);
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
tx();
|
|
202
|
+
|
|
203
|
+
const count = db.prepare('SELECT COUNT(*) AS n FROM conversations').get().n;
|
|
204
|
+
const msgCount = db.prepare('SELECT COUNT(*) AS n FROM messages').get().n;
|
|
205
|
+
db.close();
|
|
206
|
+
const sz = fs.statSync(OUT_DB).size;
|
|
207
|
+
console.log(`[harvest] wrote ${OUT_DB} (${(sz/1024).toFixed(1)}KB, ${count} conversations, ${msgCount} messages)`);
|
|
208
|
+
console.log(`[harvest] harvested ${realPrompts.length} real prompt(s) from ${SRC_DIR}`);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
build();
|