@sanqianx/ai-coding-event-bridge-console 0.7.1 → 0.7.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.
Files changed (37) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +81 -74
  3. package/console/index.html +953 -948
  4. package/package.json +26 -26
  5. package/src/agent-detect.js +128 -128
  6. package/src/bin.js +80 -80
  7. package/src/bridge/bin/commit-boundary.js +64 -64
  8. package/src/bridge/connectors/capture-guard.js +24 -24
  9. package/src/bridge/connectors/claude-code/hook-entry.js +45 -45
  10. package/src/bridge/connectors/claude-code/notify.js +57 -57
  11. package/src/bridge/connectors/codex/cursor-store.js +83 -83
  12. package/src/bridge/connectors/codex/hook-entry.js +215 -215
  13. package/src/bridge/connectors/codex/session-parser.js +100 -100
  14. package/src/bridge/connectors/opencode/hook-entry.js +55 -55
  15. package/src/bridge/core/bridge.js +249 -246
  16. package/src/bridge/core/commit-projection.js +288 -288
  17. package/src/bridge/core/compaction.js +23 -23
  18. package/src/bridge/core/consumer-registry.js +111 -111
  19. package/src/bridge/core/event-schema.js +127 -127
  20. package/src/bridge/core/fs-utils.js +81 -81
  21. package/src/bridge/core/journal-router.js +39 -39
  22. package/src/bridge/core/journal.js +507 -507
  23. package/src/bridge/core/lock.js +96 -96
  24. package/src/bridge/core/normalizer.js +114 -114
  25. package/src/bridge/core/paths.js +12 -12
  26. package/src/bridge/core/project-registry.js +176 -176
  27. package/src/bridge/core/repo-context.js +191 -191
  28. package/src/bridge/core/runtime-home.js +189 -188
  29. package/src/bridge/core/semver.js +23 -23
  30. package/src/bridge/core/turn-identity.js +127 -127
  31. package/src/bridge/index.js +86 -86
  32. package/src/bridge/installers/claude-code/installer.js +181 -181
  33. package/src/bridge/installers/codex/installer.js +114 -114
  34. package/src/bridge/installers/opencode/installer.js +96 -96
  35. package/src/bridge/installers/zcode/installer.js +200 -188
  36. package/src/bridge/query/conversation-query.js +194 -194
  37. package/src/server.js +825 -825
package/src/server.js CHANGED
@@ -1,825 +1,825 @@
1
- 'use strict';
2
-
3
- const fs = require('fs');
4
- const path = require('path');
5
- const crypto = require('crypto');
6
- const {
7
- Journal,
8
- ConversationQuery,
9
- ConsumerRegistry,
10
- projectRegistry,
11
- commitProjection,
12
- ensureRuntimeHome,
13
- semver,
14
- globalJournalDir,
15
- projectJournalDir,
16
- repoIdentityKey
17
- } = require('./bridge');
18
- const { createBridge } = require('./bridge');
19
- const { pickFolder } = require('./pick-folder');
20
- const { detectAgents } = require('./agent-detect');
21
- const corePackage = require('../package.json');
22
-
23
- const BOUNDARY_SCHEMA = 'git-commit-boundary/v1';
24
- const EVENT_SCHEMA = 'ai-coding-event/v1';
25
- const MAX_BODY_BYTES = 1024 * 1024;
26
- const COMMIT_DOC_PATTERN = /^(?:[0-9a-f]{7,64}|[0-9]{4}-[0-9]{2}-[0-9]{2}-uncommitted-\d+)\.md$/i;
27
-
28
- /**
29
- * Local console server for the AI coding event bridge. Serves the explorer
30
- * page plus a JSON API over the bridge journals:
31
- *
32
- * - registered projects (imported via POST /api/projects) own a journal at
33
- * <store>/journal; the journal is a conveyor that only keeps the current
34
- * (uncommitted) conversation, and commits/<sha>.md files are the archive;
35
- * - auto-discovered projects (captured but never imported) read from the
36
- * global journal only;
37
- * - besides importing projects, the console maintains each project journal:
38
- * at startup (and before current-conversation reads) it reconciles the
39
- * trim watermark against sealed files on disk and fires the age fuse for
40
- * never-committed tails. Maintenance is best-effort and never fails a
41
- * request.
42
- */
43
-
44
- function localDay(iso) {
45
- if (typeof iso !== 'string' || !iso) return null;
46
- const d = new Date(iso);
47
- if (Number.isNaN(d.getTime())) return null;
48
- return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
49
- }
50
-
51
- function encodeCursor(payload) {
52
- return Buffer.from(JSON.stringify(payload)).toString('base64url');
53
- }
54
-
55
- function decodeCursor(raw) {
56
- try {
57
- const decoded = JSON.parse(Buffer.from(String(raw), 'base64url').toString('utf8'));
58
- if (decoded && decoded.v === 1 && typeof decoded.key === 'string') return decoded;
59
- } catch (_) {
60
- // fall through
61
- }
62
- return null;
63
- }
64
-
65
- function decodeSeqCursor(raw) {
66
- try {
67
- const decoded = JSON.parse(Buffer.from(String(raw), 'base64url').toString('utf8'));
68
- if (decoded && decoded.v === 1 && typeof decoded.seq === 'number') return decoded;
69
- } catch (_) {
70
- // fall through
71
- }
72
- return null;
73
- }
74
-
75
- function pickFolderEnabled() {
76
- return process.env.BRIDGE_CONSOLE_PICK_FOLDER !== '0';
77
- }
78
-
79
- function createConsoleServer({ home, runtimeToken }) {
80
- if (typeof home !== 'string' || !home) {
81
- throw new Error('createConsoleServer requires an explicit home directory');
82
- }
83
- const globalDir = globalJournalDir(home);
84
- const bridge = createBridge({ homeDir: home });
85
- const consolePageFile = path.join(__dirname, '..', 'console', 'index.html');
86
-
87
- const journals = new Map();
88
- const queries = new Map();
89
-
90
- function journalAt(dir) {
91
- let journal = journals.get(dir);
92
- if (!journal) {
93
- journal = new Journal(dir);
94
- journals.set(dir, journal);
95
- }
96
- return journal;
97
- }
98
-
99
- function queryAt(dir) {
100
- let query = queries.get(dir);
101
- if (!query) {
102
- query = new ConversationQuery({ journal: journalAt(dir) });
103
- queries.set(dir, query);
104
- }
105
- return query;
106
- }
107
-
108
- function existingJournal(dir) {
109
- return fs.existsSync(dir) ? journalAt(dir) : null;
110
- }
111
-
112
- function sourcesForProject(project) {
113
- if (project.auto) {
114
- return [{ tag: 'global', dir: globalDir }];
115
- }
116
- return [
117
- { tag: 'project', dir: projectJournalDir(project) },
118
- { tag: 'global', dir: globalDir }
119
- ];
120
- }
121
-
122
- function isConversationEvent(record) {
123
- return record.schema === EVENT_SCHEMA && (record.eventType === 'user_prompt' || record.eventType === 'assistant_response');
124
- }
125
-
126
- async function allTurnsFrom(query, { project, date }) {
127
- const turns = [];
128
- let cursor = null;
129
- for (;;) {
130
- const page = await query.turns({ project, date, cursor, limit: 200 });
131
- turns.push(...page.turns);
132
- if (!page.nextCursor) break;
133
- cursor = page.nextCursor;
134
- }
135
- return turns;
136
- }
137
-
138
- function turnSortKey(turn) {
139
- const at = (turn.userEvents && turn.userEvents[0] && turn.userEvents[0].capturedAt) || '';
140
- return `${at}|${turn._src || ''}|${turn.turnId || ''}`;
141
- }
142
-
143
- async function mergedTurns(project, { date }) {
144
- const merged = [];
145
- for (const source of sourcesForProject(project)) {
146
- if (!fs.existsSync(source.dir)) continue;
147
- const turns = await allTurnsFrom(queryAt(source.dir), { project: project.id, date });
148
- for (const turn of turns) merged.push({ ...turn, _src: source.tag });
149
- }
150
- merged.sort((a, b) => (turnSortKey(a) < turnSortKey(b) ? -1 : turnSortKey(a) > turnSortKey(b) ? 1 : 0));
151
- return merged;
152
- }
153
-
154
- async function datesForProject(project) {
155
- const byDay = new Map();
156
- for (const source of sourcesForProject(project)) {
157
- const journal = existingJournal(source.dir);
158
- if (!journal) continue;
159
- const records = await journal.readEvents({
160
- filter: (record) =>
161
- isConversationEvent(record) && record.eventType === 'user_prompt' && record.turnId && repoIdentityKey(record.repoIdentity) === project.id
162
- });
163
- for (const record of records) {
164
- const day = localDay(record.capturedAt);
165
- if (!day) continue;
166
- const turnIds = byDay.get(day) || new Set();
167
- turnIds.add(record.turnId);
168
- byDay.set(day, turnIds);
169
- }
170
- }
171
- return [...byDay.entries()]
172
- .map(([date, turnIds]) => ({ date, turns: turnIds.size }))
173
- .sort((a, b) => (a.date < b.date ? 1 : -1));
174
- }
175
-
176
- function findProject(id) {
177
- const registered = projectRegistry.listProjects(home).find((project) => project.id === id);
178
- if (registered) return { ...registered, auto: false };
179
- return null;
180
- }
181
-
182
- async function findAutoProject(id) {
183
- const journal = existingJournal(globalDir);
184
- if (!journal) return null;
185
- const discovered = await queryAt(globalDir).listProjects();
186
- const match = discovered.find((entry) => repoIdentityKey(entry.repoIdentity) === id);
187
- if (!match) return null;
188
- const key = repoIdentityKey(match.repoIdentity);
189
- return {
190
- id: key,
191
- name: path.basename(match.projectPath || key) || key,
192
- path: match.projectPath || null,
193
- remote: (match.repoIdentity && match.repoIdentity.remote) || null,
194
- store: globalDir,
195
- auto: true,
196
- lastCapturedAt: match.lastCapturedAt || null
197
- };
198
- }
199
-
200
- async function resolveProject(id) {
201
- return (id && (findProject(id) || (await findAutoProject(id)))) || null;
202
- }
203
-
204
- async function listConsoleProjects() {
205
- const out = [];
206
- for (const project of projectRegistry.listProjects(home)) {
207
- const dates = await datesForProject({ ...project, auto: false });
208
- const turns = dates.reduce((sum, entry) => sum + entry.turns, 0);
209
- out.push({
210
- id: project.id,
211
- name: project.name,
212
- path: project.path,
213
- remote: project.remote || null,
214
- store: project.store,
215
- auto: false,
216
- turns,
217
- lastCapturedAt: (dates[0] && dates[0].date) || null
218
- });
219
- }
220
- const journal = existingJournal(globalDir);
221
- if (journal) {
222
- const registeredIds = new Set(out.map((project) => project.id));
223
- const discovered = await queryAt(globalDir).listProjects();
224
- for (const entry of discovered) {
225
- const key = repoIdentityKey(entry.repoIdentity);
226
- if (!key || registeredIds.has(key)) continue;
227
- out.push({
228
- id: key,
229
- name: path.basename(entry.projectPath || key) || key,
230
- path: entry.projectPath || null,
231
- remote: (entry.repoIdentity && entry.repoIdentity.remote) || null,
232
- store: globalDir,
233
- auto: true,
234
- turns: entry.eventCount,
235
- lastCapturedAt: entry.lastCapturedAt || null
236
- });
237
- }
238
- }
239
- return out;
240
- }
241
-
242
- async function annotationsFor(project, turns) {
243
- const shasByTurn = new Map();
244
- const add = (turnId, sha) => {
245
- if (!turnId) return;
246
- const set = shasByTurn.get(turnId) || new Set();
247
- set.add(sha);
248
- shasByTurn.set(turnId, set);
249
- };
250
- for (const source of sourcesForProject(project)) {
251
- const journal = existingJournal(source.dir);
252
- if (!journal) continue;
253
- const boundaries = await journal.readEvents({ filter: (record) => record.schema === BOUNDARY_SCHEMA });
254
- for (const boundary of boundaries) {
255
- if (repoIdentityKey(boundary.repoIdentity) !== project.id) continue;
256
- const sha = typeof boundary.commitSha === 'string' && /^[0-9a-f]{7,40}$/i.test(boundary.commitSha) ? boundary.commitSha : null;
257
- if (!sha) continue;
258
- for (const turnId of boundary.openTurnIdsAtCommit || []) add(turnId, sha);
259
- // Sequence spans only compare within one journal's sequence space.
260
- for (const turn of turns) {
261
- if (turn._src !== source.tag) continue;
262
- if (turn.startSequence <= boundary.sequence && boundary.sequence <= turn.endSequence) add(turn.turnId, sha);
263
- }
264
- }
265
- }
266
- const annotations = {};
267
- for (const [turnId, shas] of shasByTurn) {
268
- annotations[turnId] = { commitShas: [...shas] };
269
- }
270
- return annotations;
271
- }
272
-
273
- function sendJson(res, status, payload) {
274
- const body = JSON.stringify(payload);
275
- res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
276
- res.end(body);
277
- }
278
-
279
- // ---------- project journal maintenance (conveyor semantics) ----------
280
-
281
- function sealDirOf(project) {
282
- return path.join(project.store, 'commits');
283
- }
284
-
285
- async function maintainProject(project) {
286
- const journal = existingJournal(projectJournalDir(project));
287
- if (!journal) return;
288
- const sealDir = sealDirOf(project);
289
- const minAck = await new ConsumerRegistry(home).getMinAck({ journalId: project.id });
290
- const throughSequence = minAck === null ? Infinity : minAck;
291
- try {
292
- await commitProjection.reconcileTrim({ journal, sealDir, throughSequence });
293
- } catch (_) {
294
- // best-effort: the next maintenance pass retries
295
- }
296
- try {
297
- await commitProjection.sealUncommittedTail({ journal, sealDir, throughSequence });
298
- } catch (_) {
299
- // best-effort
300
- }
301
- }
302
-
303
- function maintainAll() {
304
- return Promise.all(projectRegistry.listProjects(home).map((entry) => maintainProject({ ...entry, auto: false }))).catch(() => {});
305
- }
306
-
307
- /**
308
- * The current conversation of a registered project: turns that live in the
309
- * project journal after its last commit boundary. Everything earlier is
310
- * archived under <store>/commits and served by the commit-doc endpoints.
311
- */
312
- async function currentTurns(project, { limit, cursorRaw, order }) {
313
- const dir = projectJournalDir(project);
314
- const journal = existingJournal(dir);
315
- if (!journal) {
316
- return { project: project.id, date: null, turns: [], nextCursor: null, totalTurns: 0, annotations: {} };
317
- }
318
- const boundaries = await journal.readEvents({
319
- filter: (record) => record.schema === BOUNDARY_SCHEMA && repoIdentityKey(record.repoIdentity) === project.id
320
- });
321
- const lastBoundary = boundaries.reduce((max, b) => Math.max(max, b.sequence), 0);
322
- const records = await journal.readEvents({ fromSequence: lastBoundary + 1 });
323
- const turns = queryAt(dir)._projectTurns(records.filter(isConversationEvent));
324
- turns.sort((a, b) => a.startSequence - b.startSequence);
325
- let pageAsc;
326
- let nextCursor = null;
327
- if (order === 'desc') {
328
- // Newest on top: page 1 holds the newest turns; each cursor step pages
329
- // further into the past.
330
- let start;
331
- if (cursorRaw) {
332
- const decoded = decodeSeqCursor(cursorRaw);
333
- if (!decoded || (decoded.o && decoded.o !== 'desc')) return null;
334
- start = turns.findIndex((turn) => turn.startSequence < decoded.seq);
335
- if (start === -1) start = turns.length;
336
- } else {
337
- start = Math.max(0, turns.length - limit);
338
- }
339
- pageAsc = turns.slice(start, start + limit);
340
- if (start > 0) {
341
- nextCursor = encodeCursor({ v: 1, seq: turns[start].startSequence, o: 'desc' });
342
- }
343
- } else {
344
- let start = 0;
345
- if (cursorRaw) {
346
- const decoded = decodeSeqCursor(cursorRaw);
347
- if (!decoded || (decoded.o && decoded.o !== 'asc')) return null;
348
- start = turns.findIndex((turn) => turn.startSequence > decoded.seq);
349
- if (start === -1) start = turns.length;
350
- }
351
- pageAsc = turns.slice(start, start + limit);
352
- if (start + limit < turns.length && pageAsc.length) {
353
- nextCursor = encodeCursor({ v: 1, seq: pageAsc[pageAsc.length - 1].startSequence, o: 'asc' });
354
- }
355
- }
356
- const page = order === 'desc' ? pageAsc.slice().reverse() : pageAsc;
357
- return {
358
- project: project.id,
359
- date: null,
360
- turns: page,
361
- nextCursor,
362
- totalTurns: turns.length,
363
- annotations: {}
364
- };
365
- }
366
-
367
- // ---------- commit documents (the per-commit archive) ----------
368
-
369
- function readDocHead(file, maxBytes = 4096) {
370
- const fd = fs.openSync(file, 'r');
371
- try {
372
- const buf = Buffer.alloc(maxBytes);
373
- const read = fs.readSync(fd, buf, 0, maxBytes, 0);
374
- return buf.toString('utf8', 0, read);
375
- } finally {
376
- fs.closeSync(fd);
377
- }
378
- }
379
-
380
- function parseFrontmatter(text) {
381
- const match = text.match(/^---\r?\n([\s\S]*?)\r?\n---/);
382
- if (!match) return {};
383
- const out = {};
384
- for (const line of match[1].split(/\r?\n/)) {
385
- const kv = line.match(/^([A-Za-z]+):\s*(.*)$/);
386
- if (kv) out[kv[1]] = kv[2].replace(/^'/, '').replace(/'$/, '');
387
- }
388
- return out;
389
- }
390
-
391
- async function listCommitDocs(project) {
392
- if (project.auto) return [];
393
- const dir = sealDirOf(project);
394
- let names;
395
- try {
396
- names = fs.readdirSync(dir);
397
- } catch (_) {
398
- return [];
399
- }
400
- const docs = [];
401
- for (const name of names) {
402
- if (!name.endsWith('.md')) continue;
403
- const fm = parseFrontmatter(readDocHead(path.join(dir, name)));
404
- docs.push({
405
- file: name,
406
- sha: /^[0-9a-f]{7,64}$/.test(String(fm.commitSha || '')) ? fm.commitSha : null,
407
- date: fm.committedAt || fm.sealedAt || null,
408
- subject: fm.subject || (String(fm.uncommitted) === 'true' ? '未提交的对话' : name.replace(/\.md$/, '')),
409
- turnCount: Number(fm.turnCount) || 0,
410
- uncommitted: String(fm.uncommitted) === 'true'
411
- });
412
- }
413
- docs.sort((a, b) => ((a.date || '') < (b.date || '') ? 1 : -1));
414
- return docs;
415
- }
416
-
417
- function sendPage(res) {
418
- let html;
419
- try {
420
- html = fs.readFileSync(consolePageFile);
421
- } catch (_) {
422
- sendJson(res, 500, { error: { code: 'CONSOLE_PAGE_MISSING', message: `console page not found: ${consolePageFile}` } });
423
- return;
424
- }
425
- res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' });
426
- res.end(html);
427
- }
428
-
429
- function readBody(req) {
430
- return new Promise((resolve, reject) => {
431
- let size = 0;
432
- const chunks = [];
433
- req.on('data', (chunk) => {
434
- size += chunk.length;
435
- if (size > MAX_BODY_BYTES) {
436
- reject(Object.assign(new Error('request body too large'), { code: 'BODY_TOO_LARGE' }));
437
- req.destroy();
438
- return;
439
- }
440
- chunks.push(chunk);
441
- });
442
- req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
443
- req.on('error', reject);
444
- });
445
- }
446
-
447
- async function handleApi(req, res, url) {
448
- const route = `${req.method} ${url.pathname}`;
449
- const journalId = url.searchParams.get('journalId') || 'global';
450
-
451
- if (url.pathname.startsWith('/api/internal/')) {
452
- const supplied = String(req.headers.authorization || '').replace(/^Bearer /, '');
453
- const local = ['127.0.0.1', '::1', '::ffff:127.0.0.1'].includes(req.socket.remoteAddress);
454
- if (!local || req.headers.origin || !runtimeToken || Buffer.byteLength(runtimeToken) !== Buffer.byteLength(supplied) || !crypto.timingSafeEqual(Buffer.from(runtimeToken), Buffer.from(supplied))) {
455
- sendJson(res, 403, { error: { code: 'TRUSTED_SERVICE_REQUIRED', message: 'Trusted local service required' } }); return;
456
- }
457
- if (route !== 'POST /api/internal/workbench-event') { sendJson(res, 404, { error: { code: 'NOT_FOUND' } }); return; }
458
- try { sendJson(res, 200, await bridge.captureWorkbenchEvent(JSON.parse(await readBody(req)))); }
459
- catch (error) { sendJson(res, 409, { error: { code: error.code || 'CAPTURE_INVALID', message: '工作台会话尚未写入,请检查项目接线。' } }); }
460
- return;
461
- }
462
-
463
- if (route === 'GET /api/journals') {
464
- sendJson(res, 200, { journals: await bridge.listJournals() });
465
- return;
466
- }
467
- if (route === 'GET /api/journal/high-watermark') {
468
- sendJson(res, 200, { journalId, sequence: await bridge.getHighWatermark({ journalId }) });
469
- return;
470
- }
471
- if (route === 'GET /api/journal/events') {
472
- const from = Number(url.searchParams.get('fromSequence') || 1);
473
- const limit = Number(url.searchParams.get('limit') || 100);
474
- if (!Number.isSafeInteger(from) || from < 1 || !Number.isSafeInteger(limit) || limit < 1 || limit > 1000) {
475
- sendJson(res, 400, { error: { code: 'INVALID_ARGUMENT', message: 'invalid journal range' } });
476
- return;
477
- }
478
- sendJson(res, 200, { journalId, events: await bridge.readEvents({ journalId, fromSequence: from, limit }) });
479
- return;
480
- }
481
- if (route === 'GET /api/consumers') {
482
- sendJson(res, 200, { consumers: await bridge.listConsumers() });
483
- return;
484
- }
485
- if (route === 'POST /api/consumers/register' || route === 'POST /api/consumers/ack') {
486
- const body = JSON.parse(await readBody(req));
487
- const result = route.endsWith('/register')
488
- ? await bridge.registerConsumer(body.name, body.meta || {})
489
- : await bridge.ackConsumerCursor(body.name, body.sequence, { journalId: body.journalId || 'global' });
490
- sendJson(res, 200, result);
491
- return;
492
- }
493
-
494
- if (route === 'GET /api/health') {
495
- const journal = existingJournal(globalDir);
496
- const bounds = journal ? await journal.getBounds() : { firstSequence: 0, lastSequence: 0 };
497
- sendJson(res, 200, {
498
- ok: true,
499
- home,
500
- registeredProjects: projectRegistry.listProjects(home).length,
501
- firstSequence: bounds.firstSequence,
502
- lastSequence: bounds.lastSequence
503
- });
504
- return;
505
- }
506
-
507
- if (route === 'GET /api/system/pick-folder') {
508
- if (!pickFolderEnabled()) {
509
- sendJson(res, 501, {
510
- error: {
511
- code: 'PICK_FOLDER_UNSUPPORTED',
512
- message: 'folder picker disabled (BRIDGE_CONSOLE_PICK_FOLDER=0); type the path instead'
513
- }
514
- });
515
- return;
516
- }
517
- // Optional `win=x,y,w,h` anchors the dialog on the calling browser
518
- // window (unified picker contract).
519
- const parts = (url.searchParams.get('win') || '').split(',').map(Number);
520
- const windowRect = parts.length === 4 && parts.every(Number.isFinite)
521
- ? { x: parts[0], y: parts[1], width: parts[2], height: parts[3] }
522
- : undefined;
523
- try {
524
- const picked = await pickFolder({ title: '选择项目文件夹', windowRect });
525
- sendJson(res, 200, picked);
526
- } catch (err) {
527
- const status = (err && err.status) || 500;
528
- sendJson(res, status, {
529
- error: {
530
- code: status === 501 ? 'PICK_FOLDER_UNSUPPORTED' : 'PICK_FOLDER_FAILED',
531
- message: (err && err.message) || 'failed to open the folder picker'
532
- }
533
- });
534
- }
535
- return;
536
- }
537
-
538
- if (route === 'GET /api/system/agents') {
539
- const agents = await detectAgents();
540
- let activeRuntimeVersion = null;
541
- let runtimeVersions = [];
542
- const runtimeRoot = path.join(home, 'runtime');
543
- try {
544
- const active = JSON.parse(fs.readFileSync(path.join(home, 'active-runtime.json'), 'utf8'));
545
- if (active && typeof active.version === 'string') activeRuntimeVersion = active.version;
546
- } catch (_) {
547
- activeRuntimeVersion = null;
548
- }
549
- try {
550
- runtimeVersions = fs
551
- .readdirSync(runtimeRoot)
552
- .filter((name) => fs.statSync(path.join(runtimeRoot, name)).isDirectory())
553
- .sort();
554
- } catch (_) {
555
- runtimeVersions = [];
556
- }
557
- let consumers = [];
558
- try {
559
- consumers = (await new ConsumerRegistry(home).getConsumers()).map((consumer) => ({
560
- name: consumer.name,
561
- lastSeenAt: consumer.lastSeenAt || null,
562
- notifyUrl: (consumer.meta && consumer.meta.notifyUrl) || null
563
- }));
564
- } catch (_) {
565
- consumers = [];
566
- }
567
- sendJson(res, 200, {
568
- agents,
569
- bridge: {
570
- home,
571
- coreVersion: corePackage.version,
572
- activeRuntimeVersion,
573
- runtimeVersions,
574
- upgradeAvailable: Boolean(activeRuntimeVersion) && semver.compare(activeRuntimeVersion, corePackage.version) === -1
575
- },
576
- consumers
577
- });
578
- return;
579
- }
580
-
581
- if (route === 'POST /api/system/upgrade-runtime') {
582
- try {
583
- const result = await ensureRuntimeHome({ homeDir: home });
584
- if (result && result.conflict) {
585
- sendJson(res, 409, {
586
- error: {
587
- code: 'RUNTIME_MAJOR_CONFLICT',
588
- message: `active runtime ${result.activeVersion} and requested ${result.requestedVersion} differ in major version`
589
- }
590
- });
591
- return;
592
- }
593
- sendJson(res, 200, { ok: true, action: result.action, activeVersion: result.activeVersion });
594
- } catch (err) {
595
- sendJson(res, 500, { error: { code: 'RUNTIME_UPGRADE_FAILED', message: (err && err.message) || 'upgrade failed' } });
596
- }
597
- return;
598
- }
599
-
600
- if (route === 'GET /api/projects') {
601
- sendJson(res, 200, { projects: await listConsoleProjects() });
602
- return;
603
- }
604
-
605
- if (route === 'POST /api/projects') {
606
- let parsed = {};
607
- try {
608
- parsed = JSON.parse(await readBody(req) || '{}');
609
- } catch (_) {
610
- sendJson(res, 400, { error: { code: 'BODY_INVALID', message: 'request body must be JSON' } });
611
- return;
612
- }
613
- try {
614
- const project = await projectRegistry.addProject({ home, path: parsed.path, store: parsed.store });
615
- sendJson(res, 201, { project: { ...project, auto: false } });
616
- } catch (err) {
617
- const statusByCode = {
618
- PROJECT_PATH_REQUIRED: 400,
619
- PROJECT_DIR_INVALID: 400,
620
- PROJECT_STORE_INVALID: 400,
621
- PROJECT_NOT_GIT: 422,
622
- PROJECT_DUPLICATE: 409,
623
- PROJECT_STORE_CONFLICT: 409
624
- };
625
- const status = statusByCode[err && err.code] || 500;
626
- sendJson(res, status, { error: { code: (err && err.code) || 'INTERNAL', message: err && err.message } });
627
- }
628
- return;
629
- }
630
-
631
- if (route === 'DELETE /api/projects') {
632
- const id = url.searchParams.get('id');
633
- try {
634
- const removed = await projectRegistry.removeProject({ home, id });
635
- sendJson(res, 200, { removed });
636
- } catch (err) {
637
- const status = err && err.code === 'PROJECT_NOT_FOUND' ? 404 : err && err.code === 'PROJECT_ID_REQUIRED' ? 400 : 500;
638
- sendJson(res, status, { error: { code: (err && err.code) || 'INTERNAL', message: err && err.message } });
639
- }
640
- return;
641
- }
642
-
643
- // Single commit document, fetched lazily by the UI when a history entry
644
- // is opened. Only names the sealer could have produced are accepted.
645
- if (req.method === 'GET' && url.pathname.startsWith('/api/commits/')) {
646
- let docName;
647
- try {
648
- docName = decodeURIComponent(url.pathname.slice('/api/commits/'.length));
649
- } catch (_) {
650
- docName = '';
651
- }
652
- if (!COMMIT_DOC_PATTERN.test(docName)) {
653
- sendJson(res, 400, { error: { code: 'DOC_NAME_INVALID', message: 'invalid commit document name' } });
654
- return;
655
- }
656
- const project = await resolveProject(url.searchParams.get('project'));
657
- if (!project) {
658
- sendJson(res, 404, { error: { code: 'PROJECT_NOT_FOUND', message: `unknown project: ${url.searchParams.get('project')}` } });
659
- return;
660
- }
661
- if (project.auto) {
662
- sendJson(res, 404, { error: { code: 'COMMITS_UNSUPPORTED', message: 'auto-discovered projects keep no commit archive' } });
663
- return;
664
- }
665
- const file = path.join(sealDirOf(project), docName);
666
- let text;
667
- try {
668
- text = fs.readFileSync(file, 'utf8');
669
- } catch (_) {
670
- sendJson(res, 404, { error: { code: 'DOC_NOT_FOUND', message: `no sealed document: ${docName}` } });
671
- return;
672
- }
673
- res.writeHead(200, { 'content-type': 'text/markdown; charset=utf-8', 'cache-control': 'no-store' });
674
- res.end(text);
675
- return;
676
- }
677
-
678
- const projectId = url.searchParams.get('project');
679
- if (
680
- route === 'GET /api/dates' ||
681
- route === 'GET /api/turns' ||
682
- route === 'GET /api/commits' ||
683
- route === 'GET /api/sessions' ||
684
- route === 'GET /api/search'
685
- ) {
686
- const project = await resolveProject(projectId);
687
- if (!project) {
688
- sendJson(res, 404, { error: { code: 'PROJECT_NOT_FOUND', message: `unknown project: ${projectId}` } });
689
- return;
690
- }
691
-
692
- if (route === 'GET /api/dates') {
693
- sendJson(res, 200, { project: project.id, dates: await datesForProject(project) });
694
- return;
695
- }
696
-
697
- if (route === 'GET /api/turns') {
698
- const limit = Math.max(1, Math.min(Number(url.searchParams.get('limit')) || 50, 200));
699
- const order = url.searchParams.get('order') === 'desc' ? 'desc' : 'asc';
700
- if (!project.auto) {
701
- // Registered projects: the journal is a conveyor — serve only the
702
- // current (uncommitted) conversation and keep it maintained.
703
- await maintainProject(project);
704
- const current = await currentTurns(project, { limit, cursorRaw: url.searchParams.get('cursor'), order });
705
- if (!current) {
706
- sendJson(res, 400, { error: { code: 'CURSOR_INVALID', message: 'cursor is invalid; restart the query' } });
707
- return;
708
- }
709
- sendJson(res, 200, current);
710
- return;
711
- }
712
- const date = url.searchParams.get('date') || null;
713
- const merged = await mergedTurns(project, { date });
714
- const cursor = url.searchParams.get('cursor');
715
- let pageAsc;
716
- let nextCursor = null;
717
- if (order === 'desc') {
718
- // Newest on top: page 1 holds the newest turns; each cursor step
719
- // pages further into the past.
720
- let start;
721
- if (cursor) {
722
- const decoded = decodeCursor(cursor);
723
- if (!decoded || (decoded.o && decoded.o !== 'desc')) {
724
- sendJson(res, 400, { error: { code: 'CURSOR_INVALID', message: 'cursor is invalid; restart the query' } });
725
- return;
726
- }
727
- start = merged.findIndex((turn) => turnSortKey(turn) < decoded.key);
728
- if (start === -1) start = merged.length;
729
- } else {
730
- start = Math.max(0, merged.length - limit);
731
- }
732
- pageAsc = merged.slice(start, start + limit);
733
- if (start > 0) {
734
- nextCursor = encodeCursor({ v: 1, key: turnSortKey(merged[start]), o: 'desc' });
735
- }
736
- } else {
737
- let start = 0;
738
- if (cursor) {
739
- const decoded = decodeCursor(cursor);
740
- if (!decoded || (decoded.o && decoded.o !== 'asc')) {
741
- sendJson(res, 400, { error: { code: 'CURSOR_INVALID', message: 'cursor is invalid; restart the query' } });
742
- return;
743
- }
744
- start = merged.findIndex((turn) => turnSortKey(turn) > decoded.key);
745
- if (start === -1) start = merged.length;
746
- }
747
- pageAsc = merged.slice(start, start + limit);
748
- if (start + limit < merged.length && pageAsc.length) {
749
- nextCursor = encodeCursor({ v: 1, key: turnSortKey(pageAsc[pageAsc.length - 1]), o: 'asc' });
750
- }
751
- }
752
- const page = order === 'desc' ? pageAsc.slice().reverse() : pageAsc;
753
- sendJson(res, 200, {
754
- project: project.id,
755
- date,
756
- turns: page,
757
- nextCursor,
758
- totalTurns: merged.length,
759
- annotations: await annotationsFor(project, page)
760
- });
761
- return;
762
- }
763
-
764
- if (route === 'GET /api/commits') {
765
- sendJson(res, 200, { project: project.id, commits: await listCommitDocs(project) });
766
- return;
767
- }
768
-
769
- if (route === 'GET /api/sessions') {
770
- const sessions = [];
771
- for (const source of sourcesForProject(project)) {
772
- if (!fs.existsSync(source.dir)) continue;
773
- sessions.push(...(await queryAt(source.dir).listSessions({ project: project.id })));
774
- }
775
- sessions.sort((a, b) => b.lastSequence - a.lastSequence);
776
- sendJson(res, 200, { project: project.id, sessions });
777
- return;
778
- }
779
-
780
- if (route === 'GET /api/search') {
781
- const q = url.searchParams.get('q') || '';
782
- const limit = Math.max(1, Math.min(Number(url.searchParams.get('limit')) || 20, 100));
783
- const matches = [];
784
- for (const source of sourcesForProject(project)) {
785
- if (!fs.existsSync(source.dir)) continue;
786
- const result = await queryAt(source.dir).searchConversations({ project: project.id, q, limit });
787
- matches.push(...result.matches.map((match) => ({ ...match, _src: source.tag })));
788
- }
789
- matches.sort((a, b) => (a.capturedAt || '') < (b.capturedAt || '') ? 1 : -1);
790
- sendJson(res, 200, { project: project.id, q, matches: matches.slice(0, limit) });
791
- return;
792
- }
793
- }
794
-
795
- sendJson(res, 404, { error: { code: 'NOT_FOUND', message: route } });
796
- }
797
-
798
- // Startup maintenance: reconcile trim watermarks and fire age fuses. Runs
799
- // in the background; failures never surface to requests.
800
- maintainAll();
801
-
802
- return async function handler(req, res) {
803
- try {
804
- const url = new URL(req.url, 'http://localhost');
805
- if (url.pathname === '/' || url.pathname === '/index.html') {
806
- sendPage(res);
807
- return;
808
- }
809
- if (url.pathname === '/favicon.ico') {
810
- res.writeHead(204);
811
- res.end();
812
- return;
813
- }
814
- if (url.pathname.startsWith('/api/')) {
815
- await handleApi(req, res, url);
816
- return;
817
- }
818
- sendJson(res, 404, { error: { code: 'NOT_FOUND', message: `${req.method} ${url.pathname}` } });
819
- } catch (err) {
820
- sendJson(res, 500, { error: { code: 'INTERNAL', message: (err && err.message) || 'internal error' } });
821
- }
822
- };
823
- }
824
-
825
- module.exports = { createConsoleServer, localDay };
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const crypto = require('crypto');
6
+ const {
7
+ Journal,
8
+ ConversationQuery,
9
+ ConsumerRegistry,
10
+ projectRegistry,
11
+ commitProjection,
12
+ ensureRuntimeHome,
13
+ semver,
14
+ globalJournalDir,
15
+ projectJournalDir,
16
+ repoIdentityKey
17
+ } = require('./bridge');
18
+ const { createBridge } = require('./bridge');
19
+ const { pickFolder } = require('./pick-folder');
20
+ const { detectAgents } = require('./agent-detect');
21
+ const corePackage = require('../package.json');
22
+
23
+ const BOUNDARY_SCHEMA = 'git-commit-boundary/v1';
24
+ const EVENT_SCHEMA = 'ai-coding-event/v1';
25
+ const MAX_BODY_BYTES = 1024 * 1024;
26
+ const COMMIT_DOC_PATTERN = /^(?:[0-9a-f]{7,64}|[0-9]{4}-[0-9]{2}-[0-9]{2}-uncommitted-\d+)\.md$/i;
27
+
28
+ /**
29
+ * Local console server for the AI coding event bridge. Serves the explorer
30
+ * page plus a JSON API over the bridge journals:
31
+ *
32
+ * - registered projects (imported via POST /api/projects) own a journal at
33
+ * <store>/journal; the journal is a conveyor that only keeps the current
34
+ * (uncommitted) conversation, and commits/<sha>.md files are the archive;
35
+ * - auto-discovered projects (captured but never imported) read from the
36
+ * global journal only;
37
+ * - besides importing projects, the console maintains each project journal:
38
+ * at startup (and before current-conversation reads) it reconciles the
39
+ * trim watermark against sealed files on disk and fires the age fuse for
40
+ * never-committed tails. Maintenance is best-effort and never fails a
41
+ * request.
42
+ */
43
+
44
+ function localDay(iso) {
45
+ if (typeof iso !== 'string' || !iso) return null;
46
+ const d = new Date(iso);
47
+ if (Number.isNaN(d.getTime())) return null;
48
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
49
+ }
50
+
51
+ function encodeCursor(payload) {
52
+ return Buffer.from(JSON.stringify(payload)).toString('base64url');
53
+ }
54
+
55
+ function decodeCursor(raw) {
56
+ try {
57
+ const decoded = JSON.parse(Buffer.from(String(raw), 'base64url').toString('utf8'));
58
+ if (decoded && decoded.v === 1 && typeof decoded.key === 'string') return decoded;
59
+ } catch (_) {
60
+ // fall through
61
+ }
62
+ return null;
63
+ }
64
+
65
+ function decodeSeqCursor(raw) {
66
+ try {
67
+ const decoded = JSON.parse(Buffer.from(String(raw), 'base64url').toString('utf8'));
68
+ if (decoded && decoded.v === 1 && typeof decoded.seq === 'number') return decoded;
69
+ } catch (_) {
70
+ // fall through
71
+ }
72
+ return null;
73
+ }
74
+
75
+ function pickFolderEnabled() {
76
+ return process.env.BRIDGE_CONSOLE_PICK_FOLDER !== '0';
77
+ }
78
+
79
+ function createConsoleServer({ home, runtimeToken }) {
80
+ if (typeof home !== 'string' || !home) {
81
+ throw new Error('createConsoleServer requires an explicit home directory');
82
+ }
83
+ const globalDir = globalJournalDir(home);
84
+ const bridge = createBridge({ homeDir: home });
85
+ const consolePageFile = path.join(__dirname, '..', 'console', 'index.html');
86
+
87
+ const journals = new Map();
88
+ const queries = new Map();
89
+
90
+ function journalAt(dir) {
91
+ let journal = journals.get(dir);
92
+ if (!journal) {
93
+ journal = new Journal(dir);
94
+ journals.set(dir, journal);
95
+ }
96
+ return journal;
97
+ }
98
+
99
+ function queryAt(dir) {
100
+ let query = queries.get(dir);
101
+ if (!query) {
102
+ query = new ConversationQuery({ journal: journalAt(dir) });
103
+ queries.set(dir, query);
104
+ }
105
+ return query;
106
+ }
107
+
108
+ function existingJournal(dir) {
109
+ return fs.existsSync(dir) ? journalAt(dir) : null;
110
+ }
111
+
112
+ function sourcesForProject(project) {
113
+ if (project.auto) {
114
+ return [{ tag: 'global', dir: globalDir }];
115
+ }
116
+ return [
117
+ { tag: 'project', dir: projectJournalDir(project) },
118
+ { tag: 'global', dir: globalDir }
119
+ ];
120
+ }
121
+
122
+ function isConversationEvent(record) {
123
+ return record.schema === EVENT_SCHEMA && (record.eventType === 'user_prompt' || record.eventType === 'assistant_response');
124
+ }
125
+
126
+ async function allTurnsFrom(query, { project, date }) {
127
+ const turns = [];
128
+ let cursor = null;
129
+ for (;;) {
130
+ const page = await query.turns({ project, date, cursor, limit: 200 });
131
+ turns.push(...page.turns);
132
+ if (!page.nextCursor) break;
133
+ cursor = page.nextCursor;
134
+ }
135
+ return turns;
136
+ }
137
+
138
+ function turnSortKey(turn) {
139
+ const at = (turn.userEvents && turn.userEvents[0] && turn.userEvents[0].capturedAt) || '';
140
+ return `${at}|${turn._src || ''}|${turn.turnId || ''}`;
141
+ }
142
+
143
+ async function mergedTurns(project, { date }) {
144
+ const merged = [];
145
+ for (const source of sourcesForProject(project)) {
146
+ if (!fs.existsSync(source.dir)) continue;
147
+ const turns = await allTurnsFrom(queryAt(source.dir), { project: project.id, date });
148
+ for (const turn of turns) merged.push({ ...turn, _src: source.tag });
149
+ }
150
+ merged.sort((a, b) => (turnSortKey(a) < turnSortKey(b) ? -1 : turnSortKey(a) > turnSortKey(b) ? 1 : 0));
151
+ return merged;
152
+ }
153
+
154
+ async function datesForProject(project) {
155
+ const byDay = new Map();
156
+ for (const source of sourcesForProject(project)) {
157
+ const journal = existingJournal(source.dir);
158
+ if (!journal) continue;
159
+ const records = await journal.readEvents({
160
+ filter: (record) =>
161
+ isConversationEvent(record) && record.eventType === 'user_prompt' && record.turnId && repoIdentityKey(record.repoIdentity) === project.id
162
+ });
163
+ for (const record of records) {
164
+ const day = localDay(record.capturedAt);
165
+ if (!day) continue;
166
+ const turnIds = byDay.get(day) || new Set();
167
+ turnIds.add(record.turnId);
168
+ byDay.set(day, turnIds);
169
+ }
170
+ }
171
+ return [...byDay.entries()]
172
+ .map(([date, turnIds]) => ({ date, turns: turnIds.size }))
173
+ .sort((a, b) => (a.date < b.date ? 1 : -1));
174
+ }
175
+
176
+ function findProject(id) {
177
+ const registered = projectRegistry.listProjects(home).find((project) => project.id === id);
178
+ if (registered) return { ...registered, auto: false };
179
+ return null;
180
+ }
181
+
182
+ async function findAutoProject(id) {
183
+ const journal = existingJournal(globalDir);
184
+ if (!journal) return null;
185
+ const discovered = await queryAt(globalDir).listProjects();
186
+ const match = discovered.find((entry) => repoIdentityKey(entry.repoIdentity) === id);
187
+ if (!match) return null;
188
+ const key = repoIdentityKey(match.repoIdentity);
189
+ return {
190
+ id: key,
191
+ name: path.basename(match.projectPath || key) || key,
192
+ path: match.projectPath || null,
193
+ remote: (match.repoIdentity && match.repoIdentity.remote) || null,
194
+ store: globalDir,
195
+ auto: true,
196
+ lastCapturedAt: match.lastCapturedAt || null
197
+ };
198
+ }
199
+
200
+ async function resolveProject(id) {
201
+ return (id && (findProject(id) || (await findAutoProject(id)))) || null;
202
+ }
203
+
204
+ async function listConsoleProjects() {
205
+ const out = [];
206
+ for (const project of projectRegistry.listProjects(home)) {
207
+ const dates = await datesForProject({ ...project, auto: false });
208
+ const turns = dates.reduce((sum, entry) => sum + entry.turns, 0);
209
+ out.push({
210
+ id: project.id,
211
+ name: project.name,
212
+ path: project.path,
213
+ remote: project.remote || null,
214
+ store: project.store,
215
+ auto: false,
216
+ turns,
217
+ lastCapturedAt: (dates[0] && dates[0].date) || null
218
+ });
219
+ }
220
+ const journal = existingJournal(globalDir);
221
+ if (journal) {
222
+ const registeredIds = new Set(out.map((project) => project.id));
223
+ const discovered = await queryAt(globalDir).listProjects();
224
+ for (const entry of discovered) {
225
+ const key = repoIdentityKey(entry.repoIdentity);
226
+ if (!key || registeredIds.has(key)) continue;
227
+ out.push({
228
+ id: key,
229
+ name: path.basename(entry.projectPath || key) || key,
230
+ path: entry.projectPath || null,
231
+ remote: (entry.repoIdentity && entry.repoIdentity.remote) || null,
232
+ store: globalDir,
233
+ auto: true,
234
+ turns: entry.eventCount,
235
+ lastCapturedAt: entry.lastCapturedAt || null
236
+ });
237
+ }
238
+ }
239
+ return out;
240
+ }
241
+
242
+ async function annotationsFor(project, turns) {
243
+ const shasByTurn = new Map();
244
+ const add = (turnId, sha) => {
245
+ if (!turnId) return;
246
+ const set = shasByTurn.get(turnId) || new Set();
247
+ set.add(sha);
248
+ shasByTurn.set(turnId, set);
249
+ };
250
+ for (const source of sourcesForProject(project)) {
251
+ const journal = existingJournal(source.dir);
252
+ if (!journal) continue;
253
+ const boundaries = await journal.readEvents({ filter: (record) => record.schema === BOUNDARY_SCHEMA });
254
+ for (const boundary of boundaries) {
255
+ if (repoIdentityKey(boundary.repoIdentity) !== project.id) continue;
256
+ const sha = typeof boundary.commitSha === 'string' && /^[0-9a-f]{7,40}$/i.test(boundary.commitSha) ? boundary.commitSha : null;
257
+ if (!sha) continue;
258
+ for (const turnId of boundary.openTurnIdsAtCommit || []) add(turnId, sha);
259
+ // Sequence spans only compare within one journal's sequence space.
260
+ for (const turn of turns) {
261
+ if (turn._src !== source.tag) continue;
262
+ if (turn.startSequence <= boundary.sequence && boundary.sequence <= turn.endSequence) add(turn.turnId, sha);
263
+ }
264
+ }
265
+ }
266
+ const annotations = {};
267
+ for (const [turnId, shas] of shasByTurn) {
268
+ annotations[turnId] = { commitShas: [...shas] };
269
+ }
270
+ return annotations;
271
+ }
272
+
273
+ function sendJson(res, status, payload) {
274
+ const body = JSON.stringify(payload);
275
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
276
+ res.end(body);
277
+ }
278
+
279
+ // ---------- project journal maintenance (conveyor semantics) ----------
280
+
281
+ function sealDirOf(project) {
282
+ return path.join(project.store, 'commits');
283
+ }
284
+
285
+ async function maintainProject(project) {
286
+ const journal = existingJournal(projectJournalDir(project));
287
+ if (!journal) return;
288
+ const sealDir = sealDirOf(project);
289
+ const minAck = await new ConsumerRegistry(home).getMinAck({ journalId: project.id });
290
+ const throughSequence = minAck === null ? Infinity : minAck;
291
+ try {
292
+ await commitProjection.reconcileTrim({ journal, sealDir, throughSequence });
293
+ } catch (_) {
294
+ // best-effort: the next maintenance pass retries
295
+ }
296
+ try {
297
+ await commitProjection.sealUncommittedTail({ journal, sealDir, throughSequence });
298
+ } catch (_) {
299
+ // best-effort
300
+ }
301
+ }
302
+
303
+ function maintainAll() {
304
+ return Promise.all(projectRegistry.listProjects(home).map((entry) => maintainProject({ ...entry, auto: false }))).catch(() => {});
305
+ }
306
+
307
+ /**
308
+ * The current conversation of a registered project: turns that live in the
309
+ * project journal after its last commit boundary. Everything earlier is
310
+ * archived under <store>/commits and served by the commit-doc endpoints.
311
+ */
312
+ async function currentTurns(project, { limit, cursorRaw, order }) {
313
+ const dir = projectJournalDir(project);
314
+ const journal = existingJournal(dir);
315
+ if (!journal) {
316
+ return { project: project.id, date: null, turns: [], nextCursor: null, totalTurns: 0, annotations: {} };
317
+ }
318
+ const boundaries = await journal.readEvents({
319
+ filter: (record) => record.schema === BOUNDARY_SCHEMA && repoIdentityKey(record.repoIdentity) === project.id
320
+ });
321
+ const lastBoundary = boundaries.reduce((max, b) => Math.max(max, b.sequence), 0);
322
+ const records = await journal.readEvents({ fromSequence: lastBoundary + 1 });
323
+ const turns = queryAt(dir)._projectTurns(records.filter(isConversationEvent));
324
+ turns.sort((a, b) => a.startSequence - b.startSequence);
325
+ let pageAsc;
326
+ let nextCursor = null;
327
+ if (order === 'desc') {
328
+ // Newest on top: page 1 holds the newest turns; each cursor step pages
329
+ // further into the past.
330
+ let start;
331
+ if (cursorRaw) {
332
+ const decoded = decodeSeqCursor(cursorRaw);
333
+ if (!decoded || (decoded.o && decoded.o !== 'desc')) return null;
334
+ start = turns.findIndex((turn) => turn.startSequence < decoded.seq);
335
+ if (start === -1) start = turns.length;
336
+ } else {
337
+ start = Math.max(0, turns.length - limit);
338
+ }
339
+ pageAsc = turns.slice(start, start + limit);
340
+ if (start > 0) {
341
+ nextCursor = encodeCursor({ v: 1, seq: turns[start].startSequence, o: 'desc' });
342
+ }
343
+ } else {
344
+ let start = 0;
345
+ if (cursorRaw) {
346
+ const decoded = decodeSeqCursor(cursorRaw);
347
+ if (!decoded || (decoded.o && decoded.o !== 'asc')) return null;
348
+ start = turns.findIndex((turn) => turn.startSequence > decoded.seq);
349
+ if (start === -1) start = turns.length;
350
+ }
351
+ pageAsc = turns.slice(start, start + limit);
352
+ if (start + limit < turns.length && pageAsc.length) {
353
+ nextCursor = encodeCursor({ v: 1, seq: pageAsc[pageAsc.length - 1].startSequence, o: 'asc' });
354
+ }
355
+ }
356
+ const page = order === 'desc' ? pageAsc.slice().reverse() : pageAsc;
357
+ return {
358
+ project: project.id,
359
+ date: null,
360
+ turns: page,
361
+ nextCursor,
362
+ totalTurns: turns.length,
363
+ annotations: {}
364
+ };
365
+ }
366
+
367
+ // ---------- commit documents (the per-commit archive) ----------
368
+
369
+ function readDocHead(file, maxBytes = 4096) {
370
+ const fd = fs.openSync(file, 'r');
371
+ try {
372
+ const buf = Buffer.alloc(maxBytes);
373
+ const read = fs.readSync(fd, buf, 0, maxBytes, 0);
374
+ return buf.toString('utf8', 0, read);
375
+ } finally {
376
+ fs.closeSync(fd);
377
+ }
378
+ }
379
+
380
+ function parseFrontmatter(text) {
381
+ const match = text.match(/^---\r?\n([\s\S]*?)\r?\n---/);
382
+ if (!match) return {};
383
+ const out = {};
384
+ for (const line of match[1].split(/\r?\n/)) {
385
+ const kv = line.match(/^([A-Za-z]+):\s*(.*)$/);
386
+ if (kv) out[kv[1]] = kv[2].replace(/^'/, '').replace(/'$/, '');
387
+ }
388
+ return out;
389
+ }
390
+
391
+ async function listCommitDocs(project) {
392
+ if (project.auto) return [];
393
+ const dir = sealDirOf(project);
394
+ let names;
395
+ try {
396
+ names = fs.readdirSync(dir);
397
+ } catch (_) {
398
+ return [];
399
+ }
400
+ const docs = [];
401
+ for (const name of names) {
402
+ if (!name.endsWith('.md')) continue;
403
+ const fm = parseFrontmatter(readDocHead(path.join(dir, name)));
404
+ docs.push({
405
+ file: name,
406
+ sha: /^[0-9a-f]{7,64}$/.test(String(fm.commitSha || '')) ? fm.commitSha : null,
407
+ date: fm.committedAt || fm.sealedAt || null,
408
+ subject: fm.subject || (String(fm.uncommitted) === 'true' ? '未提交的对话' : name.replace(/\.md$/, '')),
409
+ turnCount: Number(fm.turnCount) || 0,
410
+ uncommitted: String(fm.uncommitted) === 'true'
411
+ });
412
+ }
413
+ docs.sort((a, b) => ((a.date || '') < (b.date || '') ? 1 : -1));
414
+ return docs;
415
+ }
416
+
417
+ function sendPage(res) {
418
+ let html;
419
+ try {
420
+ html = fs.readFileSync(consolePageFile);
421
+ } catch (_) {
422
+ sendJson(res, 500, { error: { code: 'CONSOLE_PAGE_MISSING', message: `console page not found: ${consolePageFile}` } });
423
+ return;
424
+ }
425
+ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' });
426
+ res.end(html);
427
+ }
428
+
429
+ function readBody(req) {
430
+ return new Promise((resolve, reject) => {
431
+ let size = 0;
432
+ const chunks = [];
433
+ req.on('data', (chunk) => {
434
+ size += chunk.length;
435
+ if (size > MAX_BODY_BYTES) {
436
+ reject(Object.assign(new Error('request body too large'), { code: 'BODY_TOO_LARGE' }));
437
+ req.destroy();
438
+ return;
439
+ }
440
+ chunks.push(chunk);
441
+ });
442
+ req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
443
+ req.on('error', reject);
444
+ });
445
+ }
446
+
447
+ async function handleApi(req, res, url) {
448
+ const route = `${req.method} ${url.pathname}`;
449
+ const journalId = url.searchParams.get('journalId') || 'global';
450
+
451
+ if (url.pathname.startsWith('/api/internal/')) {
452
+ const supplied = String(req.headers.authorization || '').replace(/^Bearer /, '');
453
+ const local = ['127.0.0.1', '::1', '::ffff:127.0.0.1'].includes(req.socket.remoteAddress);
454
+ if (!local || req.headers.origin || !runtimeToken || Buffer.byteLength(runtimeToken) !== Buffer.byteLength(supplied) || !crypto.timingSafeEqual(Buffer.from(runtimeToken), Buffer.from(supplied))) {
455
+ sendJson(res, 403, { error: { code: 'TRUSTED_SERVICE_REQUIRED', message: 'Trusted local service required' } }); return;
456
+ }
457
+ if (route !== 'POST /api/internal/workbench-event') { sendJson(res, 404, { error: { code: 'NOT_FOUND' } }); return; }
458
+ try { sendJson(res, 200, await bridge.captureWorkbenchEvent(JSON.parse(await readBody(req)))); }
459
+ catch (error) { sendJson(res, 409, { error: { code: error.code || 'CAPTURE_INVALID', message: '工作台会话尚未写入,请检查项目接线。' } }); }
460
+ return;
461
+ }
462
+
463
+ if (route === 'GET /api/journals') {
464
+ sendJson(res, 200, { journals: await bridge.listJournals() });
465
+ return;
466
+ }
467
+ if (route === 'GET /api/journal/high-watermark') {
468
+ sendJson(res, 200, { journalId, sequence: await bridge.getHighWatermark({ journalId }) });
469
+ return;
470
+ }
471
+ if (route === 'GET /api/journal/events') {
472
+ const from = Number(url.searchParams.get('fromSequence') || 1);
473
+ const limit = Number(url.searchParams.get('limit') || 100);
474
+ if (!Number.isSafeInteger(from) || from < 1 || !Number.isSafeInteger(limit) || limit < 1 || limit > 1000) {
475
+ sendJson(res, 400, { error: { code: 'INVALID_ARGUMENT', message: 'invalid journal range' } });
476
+ return;
477
+ }
478
+ sendJson(res, 200, { journalId, events: await bridge.readEvents({ journalId, fromSequence: from, limit }) });
479
+ return;
480
+ }
481
+ if (route === 'GET /api/consumers') {
482
+ sendJson(res, 200, { consumers: await bridge.listConsumers() });
483
+ return;
484
+ }
485
+ if (route === 'POST /api/consumers/register' || route === 'POST /api/consumers/ack') {
486
+ const body = JSON.parse(await readBody(req));
487
+ const result = route.endsWith('/register')
488
+ ? await bridge.registerConsumer(body.name, body.meta || {})
489
+ : await bridge.ackConsumerCursor(body.name, body.sequence, { journalId: body.journalId || 'global' });
490
+ sendJson(res, 200, result);
491
+ return;
492
+ }
493
+
494
+ if (route === 'GET /api/health') {
495
+ const journal = existingJournal(globalDir);
496
+ const bounds = journal ? await journal.getBounds() : { firstSequence: 0, lastSequence: 0 };
497
+ sendJson(res, 200, {
498
+ ok: true,
499
+ home,
500
+ registeredProjects: projectRegistry.listProjects(home).length,
501
+ firstSequence: bounds.firstSequence,
502
+ lastSequence: bounds.lastSequence
503
+ });
504
+ return;
505
+ }
506
+
507
+ if (route === 'GET /api/system/pick-folder') {
508
+ if (!pickFolderEnabled()) {
509
+ sendJson(res, 501, {
510
+ error: {
511
+ code: 'PICK_FOLDER_UNSUPPORTED',
512
+ message: 'folder picker disabled (BRIDGE_CONSOLE_PICK_FOLDER=0); type the path instead'
513
+ }
514
+ });
515
+ return;
516
+ }
517
+ // Optional `win=x,y,w,h` anchors the dialog on the calling browser
518
+ // window (unified picker contract).
519
+ const parts = (url.searchParams.get('win') || '').split(',').map(Number);
520
+ const windowRect = parts.length === 4 && parts.every(Number.isFinite)
521
+ ? { x: parts[0], y: parts[1], width: parts[2], height: parts[3] }
522
+ : undefined;
523
+ try {
524
+ const picked = await pickFolder({ title: '选择项目文件夹', windowRect });
525
+ sendJson(res, 200, picked);
526
+ } catch (err) {
527
+ const status = (err && err.status) || 500;
528
+ sendJson(res, status, {
529
+ error: {
530
+ code: status === 501 ? 'PICK_FOLDER_UNSUPPORTED' : 'PICK_FOLDER_FAILED',
531
+ message: (err && err.message) || 'failed to open the folder picker'
532
+ }
533
+ });
534
+ }
535
+ return;
536
+ }
537
+
538
+ if (route === 'GET /api/system/agents') {
539
+ const agents = await detectAgents({ homeDir: home });
540
+ let activeRuntimeVersion = null;
541
+ let runtimeVersions = [];
542
+ const runtimeRoot = path.join(home, 'runtime');
543
+ try {
544
+ const active = JSON.parse(fs.readFileSync(path.join(home, 'active-runtime.json'), 'utf8'));
545
+ if (active && typeof active.version === 'string') activeRuntimeVersion = active.version;
546
+ } catch (_) {
547
+ activeRuntimeVersion = null;
548
+ }
549
+ try {
550
+ runtimeVersions = fs
551
+ .readdirSync(runtimeRoot)
552
+ .filter((name) => fs.statSync(path.join(runtimeRoot, name)).isDirectory())
553
+ .sort();
554
+ } catch (_) {
555
+ runtimeVersions = [];
556
+ }
557
+ let consumers = [];
558
+ try {
559
+ consumers = (await new ConsumerRegistry(home).getConsumers()).map((consumer) => ({
560
+ name: consumer.name,
561
+ lastSeenAt: consumer.lastSeenAt || null,
562
+ notifyUrl: (consumer.meta && consumer.meta.notifyUrl) || null
563
+ }));
564
+ } catch (_) {
565
+ consumers = [];
566
+ }
567
+ sendJson(res, 200, {
568
+ agents,
569
+ bridge: {
570
+ home,
571
+ coreVersion: corePackage.version,
572
+ activeRuntimeVersion,
573
+ runtimeVersions,
574
+ upgradeAvailable: Boolean(activeRuntimeVersion) && semver.compare(activeRuntimeVersion, corePackage.version) === -1
575
+ },
576
+ consumers
577
+ });
578
+ return;
579
+ }
580
+
581
+ if (route === 'POST /api/system/upgrade-runtime') {
582
+ try {
583
+ const result = await ensureRuntimeHome({ homeDir: home });
584
+ if (result && result.conflict) {
585
+ sendJson(res, 409, {
586
+ error: {
587
+ code: 'RUNTIME_MAJOR_CONFLICT',
588
+ message: `active runtime ${result.activeVersion} and requested ${result.requestedVersion} differ in major version`
589
+ }
590
+ });
591
+ return;
592
+ }
593
+ sendJson(res, 200, { ok: true, action: result.action, activeVersion: result.activeVersion });
594
+ } catch (err) {
595
+ sendJson(res, 500, { error: { code: 'RUNTIME_UPGRADE_FAILED', message: (err && err.message) || 'upgrade failed' } });
596
+ }
597
+ return;
598
+ }
599
+
600
+ if (route === 'GET /api/projects') {
601
+ sendJson(res, 200, { projects: await listConsoleProjects() });
602
+ return;
603
+ }
604
+
605
+ if (route === 'POST /api/projects') {
606
+ let parsed = {};
607
+ try {
608
+ parsed = JSON.parse(await readBody(req) || '{}');
609
+ } catch (_) {
610
+ sendJson(res, 400, { error: { code: 'BODY_INVALID', message: 'request body must be JSON' } });
611
+ return;
612
+ }
613
+ try {
614
+ const project = await projectRegistry.addProject({ home, path: parsed.path, store: parsed.store });
615
+ sendJson(res, 201, { project: { ...project, auto: false } });
616
+ } catch (err) {
617
+ const statusByCode = {
618
+ PROJECT_PATH_REQUIRED: 400,
619
+ PROJECT_DIR_INVALID: 400,
620
+ PROJECT_STORE_INVALID: 400,
621
+ PROJECT_NOT_GIT: 422,
622
+ PROJECT_DUPLICATE: 409,
623
+ PROJECT_STORE_CONFLICT: 409
624
+ };
625
+ const status = statusByCode[err && err.code] || 500;
626
+ sendJson(res, status, { error: { code: (err && err.code) || 'INTERNAL', message: err && err.message } });
627
+ }
628
+ return;
629
+ }
630
+
631
+ if (route === 'DELETE /api/projects') {
632
+ const id = url.searchParams.get('id');
633
+ try {
634
+ const removed = await projectRegistry.removeProject({ home, id });
635
+ sendJson(res, 200, { removed });
636
+ } catch (err) {
637
+ const status = err && err.code === 'PROJECT_NOT_FOUND' ? 404 : err && err.code === 'PROJECT_ID_REQUIRED' ? 400 : 500;
638
+ sendJson(res, status, { error: { code: (err && err.code) || 'INTERNAL', message: err && err.message } });
639
+ }
640
+ return;
641
+ }
642
+
643
+ // Single commit document, fetched lazily by the UI when a history entry
644
+ // is opened. Only names the sealer could have produced are accepted.
645
+ if (req.method === 'GET' && url.pathname.startsWith('/api/commits/')) {
646
+ let docName;
647
+ try {
648
+ docName = decodeURIComponent(url.pathname.slice('/api/commits/'.length));
649
+ } catch (_) {
650
+ docName = '';
651
+ }
652
+ if (!COMMIT_DOC_PATTERN.test(docName)) {
653
+ sendJson(res, 400, { error: { code: 'DOC_NAME_INVALID', message: 'invalid commit document name' } });
654
+ return;
655
+ }
656
+ const project = await resolveProject(url.searchParams.get('project'));
657
+ if (!project) {
658
+ sendJson(res, 404, { error: { code: 'PROJECT_NOT_FOUND', message: `unknown project: ${url.searchParams.get('project')}` } });
659
+ return;
660
+ }
661
+ if (project.auto) {
662
+ sendJson(res, 404, { error: { code: 'COMMITS_UNSUPPORTED', message: 'auto-discovered projects keep no commit archive' } });
663
+ return;
664
+ }
665
+ const file = path.join(sealDirOf(project), docName);
666
+ let text;
667
+ try {
668
+ text = fs.readFileSync(file, 'utf8');
669
+ } catch (_) {
670
+ sendJson(res, 404, { error: { code: 'DOC_NOT_FOUND', message: `no sealed document: ${docName}` } });
671
+ return;
672
+ }
673
+ res.writeHead(200, { 'content-type': 'text/markdown; charset=utf-8', 'cache-control': 'no-store' });
674
+ res.end(text);
675
+ return;
676
+ }
677
+
678
+ const projectId = url.searchParams.get('project');
679
+ if (
680
+ route === 'GET /api/dates' ||
681
+ route === 'GET /api/turns' ||
682
+ route === 'GET /api/commits' ||
683
+ route === 'GET /api/sessions' ||
684
+ route === 'GET /api/search'
685
+ ) {
686
+ const project = await resolveProject(projectId);
687
+ if (!project) {
688
+ sendJson(res, 404, { error: { code: 'PROJECT_NOT_FOUND', message: `unknown project: ${projectId}` } });
689
+ return;
690
+ }
691
+
692
+ if (route === 'GET /api/dates') {
693
+ sendJson(res, 200, { project: project.id, dates: await datesForProject(project) });
694
+ return;
695
+ }
696
+
697
+ if (route === 'GET /api/turns') {
698
+ const limit = Math.max(1, Math.min(Number(url.searchParams.get('limit')) || 50, 200));
699
+ const order = url.searchParams.get('order') === 'desc' ? 'desc' : 'asc';
700
+ if (!project.auto) {
701
+ // Registered projects: the journal is a conveyor — serve only the
702
+ // current (uncommitted) conversation and keep it maintained.
703
+ await maintainProject(project);
704
+ const current = await currentTurns(project, { limit, cursorRaw: url.searchParams.get('cursor'), order });
705
+ if (!current) {
706
+ sendJson(res, 400, { error: { code: 'CURSOR_INVALID', message: 'cursor is invalid; restart the query' } });
707
+ return;
708
+ }
709
+ sendJson(res, 200, current);
710
+ return;
711
+ }
712
+ const date = url.searchParams.get('date') || null;
713
+ const merged = await mergedTurns(project, { date });
714
+ const cursor = url.searchParams.get('cursor');
715
+ let pageAsc;
716
+ let nextCursor = null;
717
+ if (order === 'desc') {
718
+ // Newest on top: page 1 holds the newest turns; each cursor step
719
+ // pages further into the past.
720
+ let start;
721
+ if (cursor) {
722
+ const decoded = decodeCursor(cursor);
723
+ if (!decoded || (decoded.o && decoded.o !== 'desc')) {
724
+ sendJson(res, 400, { error: { code: 'CURSOR_INVALID', message: 'cursor is invalid; restart the query' } });
725
+ return;
726
+ }
727
+ start = merged.findIndex((turn) => turnSortKey(turn) < decoded.key);
728
+ if (start === -1) start = merged.length;
729
+ } else {
730
+ start = Math.max(0, merged.length - limit);
731
+ }
732
+ pageAsc = merged.slice(start, start + limit);
733
+ if (start > 0) {
734
+ nextCursor = encodeCursor({ v: 1, key: turnSortKey(merged[start]), o: 'desc' });
735
+ }
736
+ } else {
737
+ let start = 0;
738
+ if (cursor) {
739
+ const decoded = decodeCursor(cursor);
740
+ if (!decoded || (decoded.o && decoded.o !== 'asc')) {
741
+ sendJson(res, 400, { error: { code: 'CURSOR_INVALID', message: 'cursor is invalid; restart the query' } });
742
+ return;
743
+ }
744
+ start = merged.findIndex((turn) => turnSortKey(turn) > decoded.key);
745
+ if (start === -1) start = merged.length;
746
+ }
747
+ pageAsc = merged.slice(start, start + limit);
748
+ if (start + limit < merged.length && pageAsc.length) {
749
+ nextCursor = encodeCursor({ v: 1, key: turnSortKey(pageAsc[pageAsc.length - 1]), o: 'asc' });
750
+ }
751
+ }
752
+ const page = order === 'desc' ? pageAsc.slice().reverse() : pageAsc;
753
+ sendJson(res, 200, {
754
+ project: project.id,
755
+ date,
756
+ turns: page,
757
+ nextCursor,
758
+ totalTurns: merged.length,
759
+ annotations: await annotationsFor(project, page)
760
+ });
761
+ return;
762
+ }
763
+
764
+ if (route === 'GET /api/commits') {
765
+ sendJson(res, 200, { project: project.id, commits: await listCommitDocs(project) });
766
+ return;
767
+ }
768
+
769
+ if (route === 'GET /api/sessions') {
770
+ const sessions = [];
771
+ for (const source of sourcesForProject(project)) {
772
+ if (!fs.existsSync(source.dir)) continue;
773
+ sessions.push(...(await queryAt(source.dir).listSessions({ project: project.id })));
774
+ }
775
+ sessions.sort((a, b) => b.lastSequence - a.lastSequence);
776
+ sendJson(res, 200, { project: project.id, sessions });
777
+ return;
778
+ }
779
+
780
+ if (route === 'GET /api/search') {
781
+ const q = url.searchParams.get('q') || '';
782
+ const limit = Math.max(1, Math.min(Number(url.searchParams.get('limit')) || 20, 100));
783
+ const matches = [];
784
+ for (const source of sourcesForProject(project)) {
785
+ if (!fs.existsSync(source.dir)) continue;
786
+ const result = await queryAt(source.dir).searchConversations({ project: project.id, q, limit });
787
+ matches.push(...result.matches.map((match) => ({ ...match, _src: source.tag })));
788
+ }
789
+ matches.sort((a, b) => (a.capturedAt || '') < (b.capturedAt || '') ? 1 : -1);
790
+ sendJson(res, 200, { project: project.id, q, matches: matches.slice(0, limit) });
791
+ return;
792
+ }
793
+ }
794
+
795
+ sendJson(res, 404, { error: { code: 'NOT_FOUND', message: route } });
796
+ }
797
+
798
+ // Startup maintenance: reconcile trim watermarks and fire age fuses. Runs
799
+ // in the background; failures never surface to requests.
800
+ maintainAll();
801
+
802
+ return async function handler(req, res) {
803
+ try {
804
+ const url = new URL(req.url, 'http://localhost');
805
+ if (url.pathname === '/' || url.pathname === '/index.html') {
806
+ sendPage(res);
807
+ return;
808
+ }
809
+ if (url.pathname === '/favicon.ico') {
810
+ res.writeHead(204);
811
+ res.end();
812
+ return;
813
+ }
814
+ if (url.pathname.startsWith('/api/')) {
815
+ await handleApi(req, res, url);
816
+ return;
817
+ }
818
+ sendJson(res, 404, { error: { code: 'NOT_FOUND', message: `${req.method} ${url.pathname}` } });
819
+ } catch (err) {
820
+ sendJson(res, 500, { error: { code: 'INTERNAL', message: (err && err.message) || 'internal error' } });
821
+ }
822
+ };
823
+ }
824
+
825
+ module.exports = { createConsoleServer, localDay };