@sanqianx/ai-coding-event-bridge-console 0.4.0

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