@rarusoft/dendrite-wiki 0.1.0-alpha.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.
Files changed (74) hide show
  1. package/README.md +79 -0
  2. package/dist/api-extractor/extract.js +269 -0
  3. package/dist/api-extractor/language-extractor.js +15 -0
  4. package/dist/api-extractor/python-extractor.js +358 -0
  5. package/dist/api-extractor/render.js +195 -0
  6. package/dist/api-extractor/tree-sitter-extractor.js +1079 -0
  7. package/dist/api-extractor/types.js +11 -0
  8. package/dist/api-extractor/typescript-extractor.js +50 -0
  9. package/dist/api-extractor/walk.js +178 -0
  10. package/dist/api-reference.js +438 -0
  11. package/dist/benchmark-events.js +129 -0
  12. package/dist/benchmark.js +270 -0
  13. package/dist/binder-export.js +381 -0
  14. package/dist/canonical-target.js +168 -0
  15. package/dist/chart-insert.js +377 -0
  16. package/dist/chart-prompts.js +414 -0
  17. package/dist/context-cache.js +98 -0
  18. package/dist/contradicts-shipped-memory.js +232 -0
  19. package/dist/diff-context.js +142 -0
  20. package/dist/doctor.js +220 -0
  21. package/dist/generated-docs.js +219 -0
  22. package/dist/i18n.js +71 -0
  23. package/dist/index.js +49 -0
  24. package/dist/librarian.js +255 -0
  25. package/dist/maintenance-actions.js +244 -0
  26. package/dist/maintenance-inbox.js +842 -0
  27. package/dist/maintenance-runner.js +62 -0
  28. package/dist/page-drift.js +225 -0
  29. package/dist/page-inbox.js +168 -0
  30. package/dist/report-export.js +339 -0
  31. package/dist/review-bridge.js +1386 -0
  32. package/dist/search-index.js +199 -0
  33. package/dist/store.js +1617 -0
  34. package/dist/telemetry-defaults.js +44 -0
  35. package/dist/telemetry-report.js +263 -0
  36. package/dist/telemetry.js +544 -0
  37. package/dist/wiki-synthesis.js +901 -0
  38. package/package.json +35 -0
  39. package/src/api-extractor/extract.ts +333 -0
  40. package/src/api-extractor/language-extractor.ts +37 -0
  41. package/src/api-extractor/python-extractor.ts +380 -0
  42. package/src/api-extractor/render.ts +267 -0
  43. package/src/api-extractor/tree-sitter-extractor.ts +1210 -0
  44. package/src/api-extractor/types.ts +41 -0
  45. package/src/api-extractor/typescript-extractor.ts +56 -0
  46. package/src/api-extractor/walk.ts +209 -0
  47. package/src/api-reference.ts +552 -0
  48. package/src/benchmark-events.ts +216 -0
  49. package/src/benchmark.ts +376 -0
  50. package/src/binder-export.ts +437 -0
  51. package/src/canonical-target.ts +192 -0
  52. package/src/chart-insert.ts +478 -0
  53. package/src/chart-prompts.ts +417 -0
  54. package/src/context-cache.ts +129 -0
  55. package/src/contradicts-shipped-memory.ts +311 -0
  56. package/src/diff-context.ts +187 -0
  57. package/src/doctor.ts +260 -0
  58. package/src/generated-docs.ts +316 -0
  59. package/src/i18n.ts +106 -0
  60. package/src/index.ts +59 -0
  61. package/src/librarian.ts +331 -0
  62. package/src/maintenance-actions.ts +314 -0
  63. package/src/maintenance-inbox.ts +1132 -0
  64. package/src/maintenance-runner.ts +85 -0
  65. package/src/page-drift.ts +292 -0
  66. package/src/page-inbox.ts +254 -0
  67. package/src/report-export.ts +392 -0
  68. package/src/review-bridge.ts +1729 -0
  69. package/src/search-index.ts +266 -0
  70. package/src/store.ts +2171 -0
  71. package/src/telemetry-defaults.ts +50 -0
  72. package/src/telemetry-report.ts +365 -0
  73. package/src/telemetry.ts +757 -0
  74. package/src/wiki-synthesis.ts +1307 -0
@@ -0,0 +1,1729 @@
1
+ /**
2
+ * Review bridge — the HTTP surface that lets the Review Board execute actions in the browser.
3
+ *
4
+ * Embedded inside the VitePress dev server as a same-origin route, so "Run now" buttons in
5
+ * the Review Board dispatch directly to this bridge without CORS, without a token paste,
6
+ * and without spinning up a separate server. Endpoints surface previews (so the Decision
7
+ * Modal's diff renders before the operator clicks Apply), execute approved maintenance
8
+ * actions through `runMaintenanceActionAndRefresh`, and stream live observation/recall
9
+ * activity for the live dashboard.
10
+ *
11
+ * Confirmation is enforced upstream in the modal — the bridge trusts an Apply call and
12
+ * runs it. Every mutation goes through `maintenance-runner.ts` so the project log gets a
13
+ * matching entry and an undoable artifact lands under `local-data/`.
14
+ */
15
+ import { randomUUID } from 'node:crypto';
16
+ import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
17
+ import { findMaintenanceInboxAction } from './maintenance-inbox.js';
18
+ import { buildPageInboxSnapshot, buildPageInboxSummary } from './page-inbox.js';
19
+ // Side-effect import: registers WikiCanonicalTarget on the brain DI surface.
20
+ import './canonical-target.js';
21
+ import { previewProjectMemoryPromotion } from '@rarusoft/dendrite-memory';
22
+ import { listOllamaModels, synthesizeMemoryAutoCleanDecisions, synthesizeWikiChart, synthesizeWikiDriftResolution, type MemoryAutoCleanCandidate } from './wiki-synthesis.js';
23
+ import {
24
+ acceptSupervisionProposal,
25
+ addProjectOpenQuestion,
26
+ buildCortexSnapshot,
27
+ forgetProjectMemory,
28
+ markProjectMemoryDecided,
29
+ markProjectMemoryDeferred,
30
+ markProjectTriggerSatisfied,
31
+ previewMemoryPromoteToSkill,
32
+ rejectSupervisionProposal,
33
+ reviewProjectMemories,
34
+ setProjectCurrentGoal,
35
+ type ProjectMemoryReviewFinding
36
+ } from '@rarusoft/dendrite-memory';
37
+ import { applyAutoCleanDecisions, listAutoCleanRuns, revertAutoCleanRun } from '@rarusoft/dendrite-memory';
38
+ import { previewTelemetryUploadPayload, setTelemetrySharingMode, uploadTelemetry, writeTelemetryStatusArtifact } from './telemetry.js';
39
+ import {
40
+ TELEMETRY_DEFAULT_REPORT_TABLE,
41
+ TELEMETRY_DEFAULT_REPORT_TOKEN,
42
+ TELEMETRY_DEFAULT_REPORT_URL
43
+ } from './telemetry-defaults.js';
44
+ import { buildTelemetryReport } from './telemetry-report.js';
45
+ import { appendProjectLog, lintWikiPages, listWikiPages, listWikiProposals, previewWikiProposal, readWikiPage, writeWikiPage } from './store.js';
46
+ import { runMaintenanceActionAndRefresh } from './maintenance-runner.js';
47
+ import { captureBenchmarkEvent } from './benchmark-events.js';
48
+ import { createHash } from 'node:crypto';
49
+ import { promises as nodeFs } from 'node:fs';
50
+ import nodePath from 'node:path';
51
+
52
+ export const REVIEW_BRIDGE_TOKEN_HEADER = 'x-dendrite-review-token';
53
+ const REVIEW_BRIDGE_CORS_MAX_AGE_SECONDS = 600;
54
+ const DEFAULT_REVIEW_BRIDGE_ALLOWED_ORIGINS = [
55
+ 'http://127.0.0.1:5177',
56
+ 'http://localhost:5177',
57
+ 'http://127.0.0.1:4177',
58
+ 'http://localhost:4177'
59
+ ];
60
+
61
+ type ReviewBridgeErrorCode =
62
+ | 'missing-request-metadata'
63
+ | 'disallowed-origin'
64
+ | 'missing-review-bridge-token'
65
+ | 'invalid-review-bridge-token'
66
+ | 'expired-review-bridge-token'
67
+ | 'missing-action-id'
68
+ | 'missing-memory-ids'
69
+ | 'missing-memory-id'
70
+ | 'missing-slug'
71
+ | 'missing-review-slug'
72
+ | 'unknown-maintenance-action'
73
+ | 'confirmation-required'
74
+ | 'preview-failed'
75
+ | 'preview-proposal-failed'
76
+ | 'preview-skill-promotion-failed'
77
+ | 'synthesize-drift-failed'
78
+ | 'synthesize-chart-failed'
79
+ | 'missing-chart-context'
80
+ | 'invalid-chart-kind'
81
+ | 'chart-replace-failed'
82
+ | 'chart-validation-failed'
83
+ | 'chart-not-found'
84
+ | 'missing-chart-id'
85
+ | 'ollama-models-failed'
86
+ | 'auto-clean-synthesis-failed'
87
+ | 'auto-clean-no-candidates'
88
+ | 'auto-clean-revert-failed'
89
+ | 'missing-run-id'
90
+ | 'telemetry-set-mode-failed'
91
+ | 'telemetry-upload-failed'
92
+ | 'telemetry-status-failed'
93
+ | 'telemetry-report-failed'
94
+ | 'cortex-snapshot-failed'
95
+ | 'cortex-execute-failed'
96
+ | 'cortex-unknown-tool'
97
+ | 'cortex-missing-args'
98
+ | 'telemetry-report-unconfigured'
99
+ | 'telemetry-preview-failed'
100
+ | 'telemetry-preview-no-consent'
101
+ | 'bridge-execution-failed'
102
+ | 'page-read-failed'
103
+ | 'page-write-failed'
104
+ | 'page-write-conflict'
105
+ | 'page-write-invalid-body'
106
+ | 'page-list-failed'
107
+ | 'page-inbox-failed'
108
+ | 'page-inbox-summary-failed'
109
+ | 'route-not-found';
110
+
111
+ export type ReviewBridgeAuthMode = 'token' | 'same-origin';
112
+
113
+ interface ReviewBridgeServerOptions {
114
+ authToken: string;
115
+ authTokenTtlMs?: number;
116
+ now?: () => number;
117
+ sessionId?: string;
118
+ allowedOrigins?: string[];
119
+ }
120
+
121
+ export interface ReviewBridgeHandlerOptions {
122
+ authMode?: ReviewBridgeAuthMode;
123
+ authToken?: string;
124
+ authTokenTtlMs?: number;
125
+ now?: () => number;
126
+ sessionId?: string;
127
+ allowedOrigins?: string[];
128
+ healthPath?: string;
129
+ executePath?: string;
130
+ previewPromotionPath?: string;
131
+ previewProposalPath?: string;
132
+ previewSkillPromotionPath?: string;
133
+ synthesizeDriftPath?: string;
134
+ synthesizeChartPath?: string;
135
+ chartReplacePath?: string;
136
+ ollamaModelsPath?: string;
137
+ pageReadPath?: string;
138
+ pageWritePath?: string;
139
+ pageListPath?: string;
140
+ pageInboxPath?: string;
141
+ pageInboxSummaryPath?: string;
142
+ autoCleanMemoriesPath?: string;
143
+ autoCleanRevertPath?: string;
144
+ autoCleanRunsPath?: string;
145
+ telemetryStatusPath?: string;
146
+ telemetryOptInPath?: string;
147
+ telemetryOptOutPath?: string;
148
+ telemetryUploadPath?: string;
149
+ telemetryReportPath?: string;
150
+ telemetryUploadPreviewPath?: string;
151
+ /** Supervision-panel slice 2b — the cortex view's data endpoint. GET returns
152
+ * the full CortexSnapshot from @rarusoft/dendrite-memory for the Vue page to
153
+ * render. Optional `?recentChangesLimit=N` query param (default 50). */
154
+ cortexPath?: string;
155
+ cortexExecutePath?: string;
156
+ }
157
+
158
+ export interface ReviewBridgeHandler {
159
+ handle(request: IncomingMessage, response: ServerResponse): Promise<boolean>;
160
+ bridge: 'dendrite-wiki-review-bridge' | 'dendrite-wiki-review-bridge-embedded';
161
+ healthPath: string;
162
+ executePath: string;
163
+ previewPromotionPath: string;
164
+ previewProposalPath: string;
165
+ previewSkillPromotionPath: string;
166
+ synthesizeDriftPath: string;
167
+ synthesizeChartPath: string;
168
+ chartReplacePath: string;
169
+ ollamaModelsPath: string;
170
+ pageReadPath: string;
171
+ pageWritePath: string;
172
+ pageListPath: string;
173
+ pageInboxPath: string;
174
+ pageInboxSummaryPath: string;
175
+ autoCleanMemoriesPath: string;
176
+ autoCleanRevertPath: string;
177
+ autoCleanRunsPath: string;
178
+ telemetryStatusPath: string;
179
+ telemetryOptInPath: string;
180
+ telemetryOptOutPath: string;
181
+ telemetryUploadPath: string;
182
+ telemetryReportPath: string;
183
+ telemetryUploadPreviewPath: string;
184
+ cortexPath: string;
185
+ cortexExecutePath: string;
186
+ authMode: ReviewBridgeAuthMode;
187
+ sessionId: string;
188
+ }
189
+
190
+ export function createReviewBridgeHandler(options: ReviewBridgeHandlerOptions): ReviewBridgeHandler {
191
+ const authMode: ReviewBridgeAuthMode = options.authMode ?? 'token';
192
+ const now = options.now ?? Date.now;
193
+ const sessionId = options.sessionId?.trim() || randomUUID();
194
+ const healthPath = options.healthPath ?? '/health';
195
+ const executePath = options.executePath ?? '/actions/execute';
196
+ const previewPromotionPath = options.previewPromotionPath ?? '/preview/memory-promotion';
197
+ const previewProposalPath = options.previewProposalPath ?? '/preview/wiki-proposal';
198
+ const previewSkillPromotionPath = options.previewSkillPromotionPath ?? '/preview/memory-promote-skill';
199
+ const synthesizeDriftPath = options.synthesizeDriftPath ?? '/synthesize/drift';
200
+ const synthesizeChartPath = options.synthesizeChartPath ?? '/synthesize/chart';
201
+ const chartReplacePath = options.chartReplacePath ?? '/charts/replace';
202
+ const ollamaModelsPath = options.ollamaModelsPath ?? '/ollama/models';
203
+ const pageReadPath = options.pageReadPath ?? '/pages/read';
204
+ const pageWritePath = options.pageWritePath ?? '/pages/write';
205
+ const pageListPath = options.pageListPath ?? '/pages/list';
206
+ const pageInboxPath = options.pageInboxPath ?? '/pages/inbox';
207
+ const pageInboxSummaryPath = options.pageInboxSummaryPath ?? '/pages/inbox-summary';
208
+ const autoCleanMemoriesPath = options.autoCleanMemoriesPath ?? '/auto-clean/memories';
209
+ const autoCleanRevertPath = options.autoCleanRevertPath ?? '/auto-clean/revert';
210
+ const autoCleanRunsPath = options.autoCleanRunsPath ?? '/auto-clean/runs';
211
+ const telemetryStatusPath = options.telemetryStatusPath ?? '/telemetry/status';
212
+ const telemetryOptInPath = options.telemetryOptInPath ?? '/telemetry/opt-in';
213
+ const telemetryOptOutPath = options.telemetryOptOutPath ?? '/telemetry/opt-out';
214
+ const telemetryUploadPath = options.telemetryUploadPath ?? '/telemetry/upload';
215
+ const telemetryReportPath = options.telemetryReportPath ?? '/telemetry/report';
216
+ const telemetryUploadPreviewPath = options.telemetryUploadPreviewPath ?? '/telemetry/upload/preview';
217
+ const cortexPath = options.cortexPath ?? '/cortex';
218
+ const cortexExecutePath = options.cortexExecutePath ?? '/cortex/execute';
219
+ const allowedOrigins = sanitizeAllowedOrigins(options.allowedOrigins);
220
+ const bridgeName = authMode === 'same-origin' ? 'dendrite-wiki-review-bridge-embedded' : 'dendrite-wiki-review-bridge';
221
+
222
+ let authToken = '';
223
+ let authTokenIssuedAtMs = now();
224
+ let authTokenExpiresAtMs: number | null = null;
225
+
226
+ if (authMode === 'token') {
227
+ authToken = (options.authToken ?? '').trim();
228
+ if (!authToken) {
229
+ throw new Error('Review bridge auth token is required when authMode is "token".');
230
+ }
231
+ const authTokenTtlMs = sanitizeAuthTokenTtlMs(options.authTokenTtlMs);
232
+ authTokenIssuedAtMs = now();
233
+ authTokenExpiresAtMs = authTokenTtlMs === null ? null : authTokenIssuedAtMs + authTokenTtlMs;
234
+ }
235
+
236
+ const checkBridgeToken = (request: IncomingMessage): {
237
+ statusCode: number;
238
+ errorCode: ReviewBridgeErrorCode;
239
+ message: string;
240
+ details: Record<string, unknown>;
241
+ } | null => {
242
+ const providedToken = readBridgeToken(request);
243
+
244
+ if (!providedToken) {
245
+ return {
246
+ statusCode: 401,
247
+ errorCode: 'missing-review-bridge-token',
248
+ message: 'Missing review bridge token.',
249
+ details: { authRequired: true, headerName: REVIEW_BRIDGE_TOKEN_HEADER }
250
+ };
251
+ }
252
+
253
+ if (providedToken !== authToken) {
254
+ return {
255
+ statusCode: 403,
256
+ errorCode: 'invalid-review-bridge-token',
257
+ message: 'Invalid review bridge token.',
258
+ details: { authRequired: true, headerName: REVIEW_BRIDGE_TOKEN_HEADER }
259
+ };
260
+ }
261
+
262
+ if (authTokenExpiresAtMs !== null && now() >= authTokenExpiresAtMs) {
263
+ return {
264
+ statusCode: 401,
265
+ errorCode: 'expired-review-bridge-token',
266
+ message: 'Review bridge token expired.',
267
+ details: {
268
+ authRequired: true,
269
+ headerName: REVIEW_BRIDGE_TOKEN_HEADER,
270
+ expiredAt: new Date(authTokenExpiresAtMs).toISOString(),
271
+ restartRequired: true
272
+ }
273
+ };
274
+ }
275
+
276
+ return null;
277
+ };
278
+
279
+ const handler: ReviewBridgeHandler['handle'] = async (request, response) => {
280
+ if (authMode === 'token') {
281
+ const requestOrigin = readRequestOrigin(request);
282
+ if (requestOrigin && !allowedOrigins.includes(requestOrigin)) {
283
+ writeCorsHeaders(response);
284
+ respondBridgeError(response, 403, 'disallowed-origin', `Origin not allowed: ${requestOrigin}`, {
285
+ origin: requestOrigin,
286
+ allowedOrigins
287
+ });
288
+ return true;
289
+ }
290
+ writeCorsHeaders(response, requestOrigin);
291
+ }
292
+
293
+ if (!request.url || !request.method) {
294
+ respondBridgeError(response, 400, 'missing-request-metadata', 'Missing request metadata.');
295
+ return true;
296
+ }
297
+
298
+ if (authMode === 'token' && request.method === 'OPTIONS') {
299
+ response.writeHead(204);
300
+ response.end();
301
+ return true;
302
+ }
303
+
304
+ const requestPath = stripQueryString(request.url);
305
+
306
+ if (request.method === 'GET' && requestPath === healthPath) {
307
+ const ttlMs = authTokenExpiresAtMs === null ? null : authTokenExpiresAtMs - authTokenIssuedAtMs;
308
+ respondJson(response, 200, {
309
+ ok: true,
310
+ bridge: bridgeName,
311
+ sessionId,
312
+ executePath,
313
+ previewPromotionPath,
314
+ previewProposalPath,
315
+ previewSkillPromotionPath,
316
+ synthesizeDriftPath,
317
+ synthesizeChartPath,
318
+ autoCleanMemoriesPath,
319
+ autoCleanRevertPath,
320
+ autoCleanRunsPath,
321
+ telemetryStatusPath,
322
+ telemetryOptInPath,
323
+ telemetryOptOutPath,
324
+ telemetryUploadPath,
325
+ telemetryReportPath,
326
+ telemetryUploadPreviewPath,
327
+ chartReplacePath,
328
+ ollamaModelsPath,
329
+ pageReadPath,
330
+ pageWritePath,
331
+ pageListPath,
332
+ pageInboxPath,
333
+ pageInboxSummaryPath,
334
+ cortexPath,
335
+ cortexExecutePath,
336
+ allowedOrigins,
337
+ auth: authMode === 'same-origin'
338
+ ? { type: 'same-origin' }
339
+ : {
340
+ type: 'header-token',
341
+ headerName: REVIEW_BRIDGE_TOKEN_HEADER,
342
+ issuedAt: new Date(authTokenIssuedAtMs).toISOString(),
343
+ expiresAt: authTokenExpiresAtMs === null ? null : new Date(authTokenExpiresAtMs).toISOString(),
344
+ ttlMs
345
+ }
346
+ });
347
+ return true;
348
+ }
349
+
350
+ if (request.method === 'POST' && requestPath === previewPromotionPath) {
351
+ try {
352
+ if (authMode === 'token') {
353
+ const tokenError = checkBridgeToken(request);
354
+ if (tokenError) {
355
+ respondBridgeError(response, tokenError.statusCode, tokenError.errorCode, tokenError.message, tokenError.details);
356
+ return true;
357
+ }
358
+ }
359
+
360
+ const body = await readJsonBody(request);
361
+ const memoryIds = Array.isArray(body.memoryIds)
362
+ ? body.memoryIds.flatMap((id) => (typeof id === 'string' ? [id.trim()] : [])).filter(Boolean)
363
+ : [];
364
+
365
+ if (memoryIds.length === 0) {
366
+ respondBridgeError(response, 400, 'missing-memory-ids', 'Provide at least one memoryId in the request body.');
367
+ return true;
368
+ }
369
+
370
+ const targetPage = typeof body.targetPage === 'string' ? body.targetPage : undefined;
371
+ const sectionHeading = typeof body.sectionHeading === 'string' ? body.sectionHeading : undefined;
372
+
373
+ const preview = await previewProjectMemoryPromotion(memoryIds, { targetPage, sectionHeading });
374
+ respondJson(response, 200, preview);
375
+ return true;
376
+ } catch (error) {
377
+ respondBridgeError(
378
+ response,
379
+ 500,
380
+ 'preview-failed',
381
+ error instanceof Error ? error.message : String(error)
382
+ );
383
+ return true;
384
+ }
385
+ }
386
+
387
+ // Preview a wiki proposal apply (route-guidance / merge-guidance) — runs the same render
388
+ // logic that applyWikiProposal would use, but returns the proposed content + unified diff
389
+ // for every affected file instead of writing to disk. Read-only, never mutates.
390
+ if (request.method === 'POST' && requestPath === previewProposalPath) {
391
+ try {
392
+ if (authMode === 'token') {
393
+ const tokenError = checkBridgeToken(request);
394
+ if (tokenError) {
395
+ respondBridgeError(response, tokenError.statusCode, tokenError.errorCode, tokenError.message, tokenError.details);
396
+ return true;
397
+ }
398
+ }
399
+
400
+ const body = await readJsonBody(request);
401
+ const reviewSlug = typeof body.reviewSlug === 'string' ? body.reviewSlug.trim() : '';
402
+ if (!reviewSlug) {
403
+ respondBridgeError(response, 400, 'missing-review-slug', 'Provide a reviewSlug in the request body.');
404
+ return true;
405
+ }
406
+
407
+ const preview = await previewWikiProposal(reviewSlug);
408
+ respondJson(response, 200, preview);
409
+ return true;
410
+ } catch (error) {
411
+ respondBridgeError(
412
+ response,
413
+ 500,
414
+ 'preview-proposal-failed',
415
+ error instanceof Error ? error.message : String(error)
416
+ );
417
+ return true;
418
+ }
419
+ }
420
+
421
+ // Preview a memory→skill promotion — runs scope inference and returns the prospective
422
+ // skill record alongside the source memory, plus a plain-language list of effects so the
423
+ // operator can see what apply will do. Read-only, never mutates.
424
+ if (request.method === 'POST' && requestPath === previewSkillPromotionPath) {
425
+ try {
426
+ if (authMode === 'token') {
427
+ const tokenError = checkBridgeToken(request);
428
+ if (tokenError) {
429
+ respondBridgeError(response, tokenError.statusCode, tokenError.errorCode, tokenError.message, tokenError.details);
430
+ return true;
431
+ }
432
+ }
433
+
434
+ const body = await readJsonBody(request);
435
+ const memoryId = typeof body.memoryId === 'string' ? body.memoryId.trim() : '';
436
+ if (!memoryId) {
437
+ respondBridgeError(response, 400, 'missing-memory-id', 'Provide a memoryId in the request body.');
438
+ return true;
439
+ }
440
+
441
+ const preview = await previewMemoryPromoteToSkill(memoryId);
442
+ respondJson(response, 200, preview);
443
+ return true;
444
+ } catch (error) {
445
+ respondBridgeError(
446
+ response,
447
+ 500,
448
+ 'preview-skill-promotion-failed',
449
+ error instanceof Error ? error.message : String(error)
450
+ );
451
+ return true;
452
+ }
453
+ }
454
+
455
+ // List models available in the local Ollama install. Powers the review-board
456
+ // model picker. Read-only, no writes — same auth mode as the rest of the bridge.
457
+ if (request.method === 'GET' && requestPath === ollamaModelsPath) {
458
+ try {
459
+ if (authMode === 'token') {
460
+ const tokenError = checkBridgeToken(request);
461
+ if (tokenError) {
462
+ respondBridgeError(response, tokenError.statusCode, tokenError.errorCode, tokenError.message, tokenError.details);
463
+ return true;
464
+ }
465
+ }
466
+ const result = await listOllamaModels();
467
+ respondJson(response, 200, result);
468
+ return true;
469
+ } catch (error) {
470
+ respondBridgeError(
471
+ response,
472
+ 500,
473
+ 'ollama-models-failed',
474
+ error instanceof Error ? error.message : String(error)
475
+ );
476
+ return true;
477
+ }
478
+ }
479
+
480
+ // Synthesize a page-drift resolution: given a page slug, gathers evidence
481
+ // (current intent + recent project-log activity) and asks the configured
482
+ // synthesis provider to either propose a replacement first paragraph or
483
+ // recommend snooze. Read-only (no writes), so no confirmation gate is needed.
484
+ if (request.method === 'POST' && requestPath === synthesizeDriftPath) {
485
+ try {
486
+ if (authMode === 'token') {
487
+ const tokenError = checkBridgeToken(request);
488
+ if (tokenError) {
489
+ respondBridgeError(response, tokenError.statusCode, tokenError.errorCode, tokenError.message, tokenError.details);
490
+ return true;
491
+ }
492
+ }
493
+
494
+ const body = await readJsonBody(request);
495
+ const slug = typeof body.slug === 'string' ? body.slug.trim() : '';
496
+ if (!slug) {
497
+ respondBridgeError(response, 400, 'missing-slug', 'Provide a page slug in the request body.');
498
+ return true;
499
+ }
500
+
501
+ const ollamaModel = typeof body.model === 'string' && body.model.trim() ? body.model.trim() : undefined;
502
+ const result = await synthesizeWikiDriftResolution(slug, { ollamaModel });
503
+ respondJson(response, 200, result);
504
+ return true;
505
+ } catch (error) {
506
+ respondBridgeError(
507
+ response,
508
+ 500,
509
+ 'synthesize-drift-failed',
510
+ error instanceof Error ? error.message : String(error)
511
+ );
512
+ return true;
513
+ }
514
+ }
515
+
516
+ // Synthesize a Mermaid diagram from page content. Drives the operator-side
517
+ // Insert Chart wizard (M5 of the AI-mermaid-charts roadmap). Body shape:
518
+ // { chartKind: 'flowchart' | 'sequence' | 'state' | 'class' | 'er' | 'gantt',
519
+ // context: string,
520
+ // intent?: string,
521
+ // model?: string }
522
+ // The model field is the same Ollama-model shortcut the drift endpoint
523
+ // uses; an empty/missing value falls back to the default provider
524
+ // resolution (server $OLLAMA_MODEL env, or the agent-handoff path when
525
+ // no provider is configured). Returns the synthesizeWikiChart result
526
+ // including the cleaned mermaidSource (fences/preamble stripped) ready
527
+ // to flow straight into the editor's preview pane. Read-only — does
528
+ // NOT write to disk; insertion is a separate operator click that calls
529
+ // the existing /pages/write endpoint.
530
+ if (request.method === 'POST' && requestPath === synthesizeChartPath) {
531
+ try {
532
+ if (authMode === 'token') {
533
+ const tokenError = checkBridgeToken(request);
534
+ if (tokenError) {
535
+ respondBridgeError(response, tokenError.statusCode, tokenError.errorCode, tokenError.message, tokenError.details);
536
+ return true;
537
+ }
538
+ }
539
+
540
+ const body = await readJsonBody(request);
541
+ const chartKindRaw = typeof body.chartKind === 'string' ? body.chartKind.trim() : '';
542
+ const VALID_CHART_KINDS = ['flowchart', 'sequence', 'state', 'class', 'er', 'gantt'] as const;
543
+ if (!VALID_CHART_KINDS.includes(chartKindRaw as typeof VALID_CHART_KINDS[number])) {
544
+ respondBridgeError(response, 400, 'invalid-chart-kind',
545
+ `chartKind must be one of: ${VALID_CHART_KINDS.join(', ')}.`,
546
+ { validKinds: VALID_CHART_KINDS });
547
+ return true;
548
+ }
549
+ const context = typeof body.context === 'string' ? body.context : '';
550
+ if (!context.trim()) {
551
+ respondBridgeError(response, 400, 'missing-chart-context', 'Provide non-empty `context` text the diagram should illustrate.');
552
+ return true;
553
+ }
554
+ const intent = typeof body.intent === 'string' && body.intent.trim() ? body.intent.trim() : undefined;
555
+ const ollamaModel = typeof body.model === 'string' && body.model.trim() ? body.model.trim() : undefined;
556
+
557
+ const result = await synthesizeWikiChart(
558
+ { chartKind: chartKindRaw as typeof VALID_CHART_KINDS[number], context, intent },
559
+ { ollamaModel }
560
+ );
561
+ respondJson(response, 200, result);
562
+ return true;
563
+ } catch (error) {
564
+ respondBridgeError(
565
+ response,
566
+ 500,
567
+ 'synthesize-chart-failed',
568
+ error instanceof Error ? error.message : String(error)
569
+ );
570
+ return true;
571
+ }
572
+ }
573
+
574
+ // Replace an existing chart in a wiki page. M6 of the AI-mermaid-charts
575
+ // roadmap — powers the inline edit affordance on rendered charts. Body:
576
+ // { slug, chartId, newSource, caption? }
577
+ // Calls into the same `replaceChartInPage` module the `wiki_replace_chart`
578
+ // MCP tool uses, so validation + idempotency + project-log + benchmark
579
+ // event side-effects are identical between agent and operator paths.
580
+ // Errors are returned as structured JSON with discriminator codes
581
+ // (chart-validation-failed / chart-not-found / chart-replace-failed).
582
+ if (request.method === 'POST' && requestPath === chartReplacePath) {
583
+ try {
584
+ if (authMode === 'token') {
585
+ const tokenError = checkBridgeToken(request);
586
+ if (tokenError) {
587
+ respondBridgeError(response, tokenError.statusCode, tokenError.errorCode, tokenError.message, tokenError.details);
588
+ return true;
589
+ }
590
+ }
591
+ const body = await readJsonBody(request);
592
+ const slug = typeof body.slug === 'string' ? body.slug.trim() : '';
593
+ const chartId = typeof body.chartId === 'string' ? body.chartId.trim() : '';
594
+ const newSource = typeof body.newSource === 'string' ? body.newSource : '';
595
+ const caption = typeof body.caption === 'string' && body.caption.trim() ? body.caption.trim() : undefined;
596
+ if (!slug) {
597
+ respondBridgeError(response, 400, 'missing-slug', 'Provide `slug` in the request body.');
598
+ return true;
599
+ }
600
+ if (!chartId) {
601
+ respondBridgeError(response, 400, 'missing-chart-id', 'Provide `chartId` in the request body.');
602
+ return true;
603
+ }
604
+ const { replaceChartInPage, ChartValidationError, ChartNotFoundError } = await import('./chart-insert.js');
605
+ try {
606
+ const result = await replaceChartInPage({ slug, chartId, newSource, caption, authorTag: 'operator' });
607
+ respondJson(response, 200, {
608
+ chartId: result.chartId,
609
+ noop: result.noop,
610
+ insertedAt: result.insertedAt
611
+ });
612
+ return true;
613
+ } catch (error) {
614
+ if (error instanceof ChartValidationError) {
615
+ respondBridgeError(response, 400, 'chart-validation-failed', error.message, { source: error.source });
616
+ return true;
617
+ }
618
+ if (error instanceof ChartNotFoundError) {
619
+ respondBridgeError(response, 404, 'chart-not-found', error.message);
620
+ return true;
621
+ }
622
+ throw error;
623
+ }
624
+ } catch (error) {
625
+ respondBridgeError(
626
+ response,
627
+ 500,
628
+ 'chart-replace-failed',
629
+ error instanceof Error ? error.message : String(error)
630
+ );
631
+ return true;
632
+ }
633
+ }
634
+
635
+ // List all wiki pages — backs the `[[` wiki-link autocomplete in the
636
+ // in-browser editor (R4 of the retro-editor experiment). Returns a
637
+ // compact array of `{ slug, title }` so the autocomplete popover can
638
+ // filter by both. Read-only.
639
+ if (request.method === 'GET' && requestPath === pageListPath) {
640
+ try {
641
+ if (authMode === 'token') {
642
+ const tokenError = checkBridgeToken(request);
643
+ if (tokenError) {
644
+ respondBridgeError(response, tokenError.statusCode, tokenError.errorCode, tokenError.message, tokenError.details);
645
+ return true;
646
+ }
647
+ }
648
+ const pages = await listWikiPages();
649
+ const compact = pages.map((page) => ({ slug: page.slug, title: page.title }));
650
+ respondJson(response, 200, { pages: compact, count: compact.length });
651
+ return true;
652
+ } catch (error) {
653
+ respondBridgeError(
654
+ response,
655
+ 500,
656
+ 'page-list-failed',
657
+ error instanceof Error ? error.message : String(error)
658
+ );
659
+ return true;
660
+ }
661
+ }
662
+
663
+ // Supervision-panel slice 2b: cortex-snapshot data endpoint. The cortex Vue
664
+ // view polls this on a low cadence. Read-only — no brain mutations. Pure
665
+ // aggregation through the brain's `buildCortexSnapshot()` primitive.
666
+ if (request.method === 'GET' && requestPath === cortexPath) {
667
+ try {
668
+ if (authMode === 'token') {
669
+ const tokenError = checkBridgeToken(request);
670
+ if (tokenError) {
671
+ respondBridgeError(response, tokenError.statusCode, tokenError.errorCode, tokenError.message, tokenError.details);
672
+ return true;
673
+ }
674
+ }
675
+ const url = new URL(request.url, 'http://localhost');
676
+ const limitParam = url.searchParams.get('recentChangesLimit');
677
+ const recentChangesLimit = limitParam ? Number.parseInt(limitParam, 10) : undefined;
678
+ const includeArchivedParam = url.searchParams.get('includeArchived');
679
+ const includeArchived = includeArchivedParam === 'true' || includeArchivedParam === '1';
680
+ const snapshot = await buildCortexSnapshot({
681
+ recentChangesLimit: Number.isFinite(recentChangesLimit) ? recentChangesLimit : undefined,
682
+ includeArchived
683
+ });
684
+ respondJson(response, 200, snapshot);
685
+ return true;
686
+ } catch (error) {
687
+ respondBridgeError(
688
+ response,
689
+ 500,
690
+ 'cortex-snapshot-failed',
691
+ error instanceof Error ? error.message : String(error)
692
+ );
693
+ return true;
694
+ }
695
+ }
696
+
697
+ // Supervision-panel slice 2c.3: cortex-drawer execute endpoint. Operator-
698
+ // driven supervision-state mutations (mark decided / mark deferred /
699
+ // trigger satisfied / add open-question / set goal / forget memory /
700
+ // accept proposal / reject proposal) dispatch through one POST handler
701
+ // that maps `tool` to the right brain helper. The operator is the trust
702
+ // source here — the autonomous trust gate that demotes to a proposal
703
+ // (slice 1.4) does not apply because the click is explicit consent.
704
+ if (request.method === 'POST' && requestPath === cortexExecutePath) {
705
+ try {
706
+ if (authMode === 'token') {
707
+ const tokenError = checkBridgeToken(request);
708
+ if (tokenError) {
709
+ respondBridgeError(response, tokenError.statusCode, tokenError.errorCode, tokenError.message, tokenError.details);
710
+ return true;
711
+ }
712
+ }
713
+ const body = await readJsonBody(request).catch((): Record<string, unknown> => ({}));
714
+ const tool = typeof body.tool === 'string' ? body.tool : '';
715
+ const reason = typeof body.reason === 'string' && body.reason.trim() ? body.reason : 'Operator action from cortex view';
716
+ const args = (body.args && typeof body.args === 'object' ? body.args : {}) as Record<string, unknown>;
717
+
718
+ const str = (key: string): string => (typeof args[key] === 'string' ? (args[key] as string) : '');
719
+ const strArr = (key: string): string[] | undefined =>
720
+ Array.isArray(args[key]) ? (args[key] as unknown[]).filter((v): v is string => typeof v === 'string') : undefined;
721
+
722
+ let result: unknown;
723
+ switch (tool) {
724
+ case 'memory_set_goal': {
725
+ const text = str('text');
726
+ if (!text) {
727
+ respondBridgeError(response, 400, 'cortex-missing-args', 'memory_set_goal requires args.text.');
728
+ return true;
729
+ }
730
+ result = await setProjectCurrentGoal(text, reason);
731
+ break;
732
+ }
733
+ case 'memory_add_open_question': {
734
+ const text = str('text');
735
+ const triggerText = str('triggerText');
736
+ if (!text || !triggerText) {
737
+ respondBridgeError(response, 400, 'cortex-missing-args', 'memory_add_open_question requires args.text and args.triggerText.');
738
+ return true;
739
+ }
740
+ result = await addProjectOpenQuestion({
741
+ text,
742
+ triggerText,
743
+ reason,
744
+ sources: strArr('sources'),
745
+ relatedFiles: strArr('relatedFiles'),
746
+ relatedPages: strArr('relatedPages'),
747
+ tags: strArr('tags')
748
+ });
749
+ break;
750
+ }
751
+ case 'memory_mark_decided': {
752
+ const memoryId = str('memoryId');
753
+ if (!memoryId) {
754
+ respondBridgeError(response, 400, 'cortex-missing-args', 'memory_mark_decided requires args.memoryId.');
755
+ return true;
756
+ }
757
+ result = await markProjectMemoryDecided(memoryId, reason);
758
+ break;
759
+ }
760
+ case 'memory_mark_deferred': {
761
+ const memoryId = str('memoryId');
762
+ const trigger = str('trigger');
763
+ if (!memoryId || !trigger) {
764
+ respondBridgeError(response, 400, 'cortex-missing-args', 'memory_mark_deferred requires args.memoryId and args.trigger.');
765
+ return true;
766
+ }
767
+ result = await markProjectMemoryDeferred(memoryId, trigger, reason);
768
+ break;
769
+ }
770
+ case 'memory_trigger_satisfied': {
771
+ const deferredMemoryId = str('deferredMemoryId');
772
+ const evidence = str('evidence');
773
+ if (!deferredMemoryId || !evidence) {
774
+ respondBridgeError(response, 400, 'cortex-missing-args', 'memory_trigger_satisfied requires args.deferredMemoryId and args.evidence.');
775
+ return true;
776
+ }
777
+ result = await markProjectTriggerSatisfied(deferredMemoryId, evidence, reason);
778
+ break;
779
+ }
780
+ case 'memory_forget': {
781
+ const memoryId = str('memoryId');
782
+ if (!memoryId) {
783
+ respondBridgeError(response, 400, 'cortex-missing-args', 'memory_forget requires args.memoryId.');
784
+ return true;
785
+ }
786
+ result = await forgetProjectMemory(memoryId, 'archive');
787
+ break;
788
+ }
789
+ case 'memory_accept_supervision_proposal': {
790
+ const proposalId = str('proposalId');
791
+ if (!proposalId) {
792
+ respondBridgeError(response, 400, 'cortex-missing-args', 'memory_accept_supervision_proposal requires args.proposalId.');
793
+ return true;
794
+ }
795
+ result = await acceptSupervisionProposal(proposalId);
796
+ break;
797
+ }
798
+ case 'memory_reject_supervision_proposal': {
799
+ const proposalId = str('proposalId');
800
+ const rejectionReason = str('rejectionReason') || reason;
801
+ if (!proposalId) {
802
+ respondBridgeError(response, 400, 'cortex-missing-args', 'memory_reject_supervision_proposal requires args.proposalId.');
803
+ return true;
804
+ }
805
+ result = await rejectSupervisionProposal(proposalId, rejectionReason);
806
+ break;
807
+ }
808
+ default:
809
+ respondBridgeError(response, 400, 'cortex-unknown-tool', `Unknown supervision tool: ${tool}`);
810
+ return true;
811
+ }
812
+
813
+ respondJson(response, 200, { ok: true, tool, result });
814
+ return true;
815
+ } catch (error) {
816
+ respondBridgeError(
817
+ response,
818
+ 500,
819
+ 'cortex-execute-failed',
820
+ error instanceof Error ? error.message : String(error)
821
+ );
822
+ return true;
823
+ }
824
+ }
825
+
826
+ // Sidebar/nav decoration: per-slug pending counts across the whole wiki. Lets the
827
+ // browser theme inject a small badge next to every link to a page that has any
828
+ // pending memory promotions or lint findings, so the operator sees "this page has
829
+ // stuff to review" without having to visit each page first. Read-only.
830
+ if (request.method === 'GET' && requestPath === pageInboxSummaryPath) {
831
+ try {
832
+ if (authMode === 'token') {
833
+ const tokenError = checkBridgeToken(request);
834
+ if (tokenError) {
835
+ respondBridgeError(response, tokenError.statusCode, tokenError.errorCode, tokenError.message, tokenError.details);
836
+ return true;
837
+ }
838
+ }
839
+ const entries = await buildPageInboxSummary();
840
+ respondJson(response, 200, { entries });
841
+ return true;
842
+ } catch (error) {
843
+ respondBridgeError(
844
+ response,
845
+ 500,
846
+ 'page-inbox-summary-failed',
847
+ error instanceof Error ? error.message : String(error)
848
+ );
849
+ return true;
850
+ }
851
+ }
852
+
853
+ // Per-page maintenance projection — what memories want to land on THIS slug, plus any
854
+ // lint findings whose page matches. Powers the in-page badge that lets the operator
855
+ // approve a pending promotion without leaving the page. Read-only — apply is still
856
+ // routed through the existing /actions/execute path so audit + project-log behavior
857
+ // is identical to the central Review Board.
858
+ if (request.method === 'GET' && requestPath === pageInboxPath) {
859
+ try {
860
+ if (authMode === 'token') {
861
+ const tokenError = checkBridgeToken(request);
862
+ if (tokenError) {
863
+ respondBridgeError(response, tokenError.statusCode, tokenError.errorCode, tokenError.message, tokenError.details);
864
+ return true;
865
+ }
866
+ }
867
+ const url = new URL(request.url, 'http://localhost');
868
+ const slug = (url.searchParams.get('slug') ?? '').trim();
869
+ if (!slug) {
870
+ respondBridgeError(response, 400, 'missing-slug', 'Provide a slug query parameter.');
871
+ return true;
872
+ }
873
+ const snapshot = await buildPageInboxSnapshot(slug);
874
+ respondJson(response, 200, snapshot);
875
+ return true;
876
+ } catch (error) {
877
+ respondBridgeError(
878
+ response,
879
+ 500,
880
+ 'page-inbox-failed',
881
+ error instanceof Error ? error.message : String(error)
882
+ );
883
+ return true;
884
+ }
885
+ }
886
+
887
+ // Read a wiki page's raw markdown for the in-browser editor (R2 of the
888
+ // retro-editor experiment). Read-only — never mutates. Returns the slug,
889
+ // raw markdown, file mtime (ms since epoch), and a sha256 hash of the
890
+ // content. The mtime+hash pair is the precondition token the future R3
891
+ // save path will check on write to detect concurrent edits.
892
+ if (request.method === 'GET' && requestPath === pageReadPath) {
893
+ try {
894
+ if (authMode === 'token') {
895
+ const tokenError = checkBridgeToken(request);
896
+ if (tokenError) {
897
+ respondBridgeError(response, tokenError.statusCode, tokenError.errorCode, tokenError.message, tokenError.details);
898
+ return true;
899
+ }
900
+ }
901
+
902
+ const url = new URL(request.url, 'http://localhost');
903
+ const slug = (url.searchParams.get('slug') ?? '').trim();
904
+ if (!slug) {
905
+ respondBridgeError(response, 400, 'missing-slug', 'Provide a slug query parameter.');
906
+ return true;
907
+ }
908
+
909
+ const content = await readWikiPage(slug);
910
+ const wikiRoot = nodePath.resolve(process.cwd(), 'docs', 'wiki');
911
+ const stat = await nodeFs.stat(nodePath.join(wikiRoot, `${slug}.md`));
912
+ const hash = createHash('sha256').update(content, 'utf8').digest('hex');
913
+ respondJson(response, 200, {
914
+ slug,
915
+ content,
916
+ mtime: stat.mtimeMs,
917
+ hash,
918
+ bytes: Buffer.byteLength(content, 'utf8')
919
+ });
920
+ return true;
921
+ } catch (error) {
922
+ respondBridgeError(
923
+ response,
924
+ 500,
925
+ 'page-read-failed',
926
+ error instanceof Error ? error.message : String(error)
927
+ );
928
+ return true;
929
+ }
930
+ }
931
+
932
+ // Save a wiki page from the in-browser editor (R3 of the retro-editor
933
+ // experiment). Body shape: { slug, content, ifMatch?: { mtime, hash } }.
934
+ // The ifMatch precondition is content-addressed: if the file's current
935
+ // mtime+hash differs from what the editor last read, we return 409 with
936
+ // the current state so the editor can render a 3-way diff. On success,
937
+ // appends a project-log entry, fires a `wiki_updated` benchmark event
938
+ // with trigger `browser-editor`, and returns the fresh mtime+hash for
939
+ // the editor to use as the next save's precondition.
940
+ if (request.method === 'POST' && requestPath === pageWritePath) {
941
+ try {
942
+ if (authMode === 'token') {
943
+ const tokenError = checkBridgeToken(request);
944
+ if (tokenError) {
945
+ respondBridgeError(response, tokenError.statusCode, tokenError.errorCode, tokenError.message, tokenError.details);
946
+ return true;
947
+ }
948
+ }
949
+
950
+ const body = await readJsonBody(request);
951
+ const slug = typeof body.slug === 'string' ? body.slug.trim() : '';
952
+ const content = typeof body.content === 'string' ? body.content : null;
953
+ const ifMatch = body.ifMatch && typeof body.ifMatch === 'object' && !Array.isArray(body.ifMatch)
954
+ ? body.ifMatch as { mtime?: unknown; hash?: unknown }
955
+ : null;
956
+
957
+ if (!slug) {
958
+ respondBridgeError(response, 400, 'page-write-invalid-body', 'Provide a slug in the request body.');
959
+ return true;
960
+ }
961
+ if (content === null) {
962
+ respondBridgeError(response, 400, 'page-write-invalid-body', 'Provide a content string in the request body.');
963
+ return true;
964
+ }
965
+
966
+ const wikiRoot = nodePath.resolve(process.cwd(), 'docs', 'wiki');
967
+ const filePath = nodePath.join(wikiRoot, `${slug}.md`);
968
+
969
+ // Detect whether the page already exists on disk. The four valid
970
+ // intent/state combinations are:
971
+ // ifMatch present, file exists → normal edit (verify hash)
972
+ // ifMatch present, file missing → 409 (file deleted out from under us)
973
+ // ifMatch absent, file missing → create (R7: new-page wizard)
974
+ // ifMatch absent, file exists → 409 (someone created the same slug first)
975
+ let currentContent = '';
976
+ let currentMtime = 0;
977
+ let fileExists = false;
978
+ try {
979
+ currentContent = await readWikiPage(slug);
980
+ const stat = await nodeFs.stat(filePath);
981
+ currentMtime = stat.mtimeMs;
982
+ fileExists = true;
983
+ } catch {
984
+ fileExists = false;
985
+ }
986
+ const currentHash = fileExists
987
+ ? createHash('sha256').update(currentContent, 'utf8').digest('hex')
988
+ : '';
989
+
990
+ const isCreate = !fileExists;
991
+
992
+ // ifMatch absent + file already exists → operator thinks they're
993
+ // creating fresh, but someone beat them to the slug. Surface as a
994
+ // conflict so the wizard can show the existing content.
995
+ if (!ifMatch && fileExists) {
996
+ response.statusCode = 409;
997
+ response.setHeader('Content-Type', 'application/json; charset=utf-8');
998
+ response.end(JSON.stringify({
999
+ error: 'A page already exists at this slug.',
1000
+ errorCode: 'page-write-conflict',
1001
+ conflict: {
1002
+ slug,
1003
+ expected: { hash: '', mtime: null },
1004
+ current: { hash: currentHash, mtime: currentMtime, content: currentContent }
1005
+ }
1006
+ }, null, 2));
1007
+ return true;
1008
+ }
1009
+
1010
+ // ifMatch present + file missing → file was deleted between read and
1011
+ // write. Surface as a conflict with empty current content.
1012
+ if (ifMatch && !fileExists) {
1013
+ response.statusCode = 409;
1014
+ response.setHeader('Content-Type', 'application/json; charset=utf-8');
1015
+ response.end(JSON.stringify({
1016
+ error: 'Page no longer exists on disk.',
1017
+ errorCode: 'page-write-conflict',
1018
+ conflict: {
1019
+ slug,
1020
+ expected: {
1021
+ hash: typeof ifMatch.hash === 'string' ? ifMatch.hash : '',
1022
+ mtime: typeof ifMatch.mtime === 'number' ? ifMatch.mtime : null
1023
+ },
1024
+ current: { hash: '', mtime: 0, content: '' }
1025
+ }
1026
+ }, null, 2));
1027
+ return true;
1028
+ }
1029
+
1030
+ // Normal edit path: file exists + ifMatch present. Verify the hash.
1031
+ if (ifMatch) {
1032
+ const expectedHash = typeof ifMatch.hash === 'string' ? ifMatch.hash : '';
1033
+ if (expectedHash && expectedHash !== currentHash) {
1034
+ response.statusCode = 409;
1035
+ response.setHeader('Content-Type', 'application/json; charset=utf-8');
1036
+ response.end(JSON.stringify({
1037
+ error: 'Page changed since you opened the editor.',
1038
+ errorCode: 'page-write-conflict',
1039
+ conflict: {
1040
+ slug,
1041
+ expected: { hash: expectedHash, mtime: typeof ifMatch.mtime === 'number' ? ifMatch.mtime : null },
1042
+ current: { hash: currentHash, mtime: currentMtime, content: currentContent }
1043
+ }
1044
+ }, null, 2));
1045
+ return true;
1046
+ }
1047
+ }
1048
+
1049
+ // Persist. writeWikiPage normalizes the trailing newline and
1050
+ // invalidates the wiki-context cache. For new pages it creates any
1051
+ // missing parent directories.
1052
+ await writeWikiPage(slug, content);
1053
+
1054
+ // Project-log entry: operator-authored, distinct from agent edits
1055
+ // by trigger phrasing so future readers can grep the source.
1056
+ const verb = isCreate ? 'Created' : 'Edited';
1057
+ await appendProjectLog(`${verb} \`${slug}\` via the in-browser editor (browser-editor save, ${Buffer.byteLength(content, 'utf8')} bytes).`);
1058
+
1059
+ // Fire the same benchmark event the agent wiki_write path fires so
1060
+ // the wiki_updated counter stays accurate. Trigger value distinguishes
1061
+ // browser-editor saves from agent saves.
1062
+ await captureBenchmarkEvent({
1063
+ event: 'wiki_updated',
1064
+ trigger: 'browser-editor',
1065
+ detail: { slug, bytes: Buffer.byteLength(content, 'utf8'), created: isCreate }
1066
+ });
1067
+
1068
+ const newStat = await nodeFs.stat(filePath);
1069
+ const newHash = createHash('sha256').update(content, 'utf8').digest('hex');
1070
+ respondJson(response, 200, {
1071
+ ok: true,
1072
+ slug,
1073
+ mtime: newStat.mtimeMs,
1074
+ hash: newHash,
1075
+ bytes: Buffer.byteLength(content, 'utf8')
1076
+ });
1077
+ return true;
1078
+ } catch (error) {
1079
+ respondBridgeError(
1080
+ response,
1081
+ 500,
1082
+ 'page-write-failed',
1083
+ error instanceof Error ? error.message : String(error)
1084
+ );
1085
+ return true;
1086
+ }
1087
+ }
1088
+
1089
+ // Memory auto-clean: pulls the current memory_review snapshot, hands the candidates
1090
+ // (memories with archive-available findings — growing, stale, unsupported, duplicate)
1091
+ // to the configured LLM in batches, and applies the parsed decisions through
1092
+ // applyAutoCleanDecisions. The response is NDJSON — one JSON event per line — so the
1093
+ // UI can render per-batch progress in real time. Events:
1094
+ // {type:'started', totalCandidates, batchSize, batchCount, provider}
1095
+ // {type:'batch-start', batchIndex, batchSize}
1096
+ // {type:'batch-complete', batchIndex, decisions, durationMs}
1097
+ // {type:'batch-failed', batchIndex, failureReason, status, rawResponse}
1098
+ // {type:'result', ok:true, run, batchFailures, batchSize, batchCount}
1099
+ // {type:'result', ok:false, failureReason, status, rawResponse?, batchFailures}
1100
+ // Body: { model?: string, maxCandidates?: number, batchSize?: number }.
1101
+ if (request.method === 'POST' && requestPath === autoCleanMemoriesPath) {
1102
+ if (authMode === 'token') {
1103
+ const tokenError = checkBridgeToken(request);
1104
+ if (tokenError) {
1105
+ respondBridgeError(response, tokenError.statusCode, tokenError.errorCode, tokenError.message, tokenError.details);
1106
+ return true;
1107
+ }
1108
+ }
1109
+
1110
+ const body: Record<string, unknown> = await readJsonBody(request).catch((): Record<string, unknown> => ({}));
1111
+ const ollamaModel = typeof body.model === 'string' && body.model.trim() ? (body.model as string).trim() : undefined;
1112
+ const batchSizeRaw = body.batchSize;
1113
+ const batchSize = typeof batchSizeRaw === 'number' && batchSizeRaw > 0
1114
+ ? Math.min(Math.floor(batchSizeRaw), 25)
1115
+ : 8;
1116
+ const maxCandidatesRaw = body.maxCandidates;
1117
+ const maxCandidates = typeof maxCandidatesRaw === 'number' && maxCandidatesRaw > 0
1118
+ ? Math.floor(maxCandidatesRaw)
1119
+ : Infinity;
1120
+
1121
+ const memoryReview = await reviewProjectMemories();
1122
+ const allCandidates = collectAutoCleanCandidates(memoryReview.findings, maxCandidates);
1123
+
1124
+ if (allCandidates.length === 0) {
1125
+ respondBridgeError(response, 400, 'auto-clean-no-candidates',
1126
+ 'No memories currently match the auto-clean candidate set (growing, stale, unsupported, or duplicate findings).');
1127
+ return true;
1128
+ }
1129
+
1130
+ // Switch the response into NDJSON streaming mode. flushHeaders ensures the client
1131
+ // sees status/headers immediately so it can start its stream reader; without it, Node
1132
+ // may buffer up to the highWaterMark before flushing and the operator stares at a
1133
+ // frozen modal for several seconds before the first progress event lands.
1134
+ response.statusCode = 200;
1135
+ response.setHeader('Content-Type', 'application/x-ndjson; charset=utf-8');
1136
+ response.setHeader('Cache-Control', 'no-cache, no-transform');
1137
+ response.setHeader('X-Accel-Buffering', 'no');
1138
+ response.flushHeaders?.();
1139
+
1140
+ const emit = (event: Record<string, unknown>): void => {
1141
+ response.write(`${JSON.stringify(event)}\n`);
1142
+ };
1143
+
1144
+ let clientDisconnected = false;
1145
+ const onClose = (): void => { clientDisconnected = true; };
1146
+ request.on('close', onClose);
1147
+
1148
+ try {
1149
+ const totalBatches = Math.ceil(allCandidates.length / batchSize);
1150
+ const allDecisions: Awaited<ReturnType<typeof synthesizeMemoryAutoCleanDecisions>>['decisions'] = [];
1151
+ const batchFailures: Array<{ batchIndex: number; size: number; failureReason: string; status: string }> = [];
1152
+ let firstProvider: Awaited<ReturnType<typeof synthesizeMemoryAutoCleanDecisions>>['provider'] | undefined;
1153
+ let lastRawResponse = '';
1154
+
1155
+ emit({
1156
+ type: 'started',
1157
+ totalCandidates: allCandidates.length,
1158
+ batchSize,
1159
+ batchCount: totalBatches
1160
+ });
1161
+
1162
+ for (let cursor = 0, batchIndex = 0; cursor < allCandidates.length; cursor += batchSize, batchIndex += 1) {
1163
+ if (clientDisconnected) break;
1164
+ const batch = allCandidates.slice(cursor, cursor + batchSize);
1165
+ emit({ type: 'batch-start', batchIndex, batchSize: batch.length });
1166
+ const batchStartedAt = Date.now();
1167
+ const synthesis = await synthesizeMemoryAutoCleanDecisions(batch, { ollamaModel });
1168
+ const durationMs = Date.now() - batchStartedAt;
1169
+ if (!firstProvider) firstProvider = synthesis.provider;
1170
+ if (synthesis.rawResponse) lastRawResponse = synthesis.rawResponse;
1171
+ if (synthesis.status !== 'generated' || !synthesis.decisions) {
1172
+ batchFailures.push({
1173
+ batchIndex,
1174
+ size: batch.length,
1175
+ failureReason: synthesis.failureReason ?? 'unknown',
1176
+ status: synthesis.status
1177
+ });
1178
+ emit({
1179
+ type: 'batch-failed',
1180
+ batchIndex,
1181
+ durationMs,
1182
+ failureReason: synthesis.failureReason ?? 'unknown',
1183
+ status: synthesis.status,
1184
+ rawResponse: synthesis.rawResponse
1185
+ });
1186
+ // First-batch failure with no partial decisions: surface and stop. No point
1187
+ // burning more time on a model that's clearly misbehaving.
1188
+ if (batchIndex === 0 && allDecisions.length === 0) {
1189
+ emit({
1190
+ type: 'result',
1191
+ ok: false,
1192
+ provider: synthesis.provider,
1193
+ status: synthesis.status,
1194
+ failureReason: synthesis.failureReason,
1195
+ handoffPrompt: synthesis.handoffPrompt,
1196
+ rawResponse: synthesis.rawResponse,
1197
+ batchFailures
1198
+ });
1199
+ response.end();
1200
+ return true;
1201
+ }
1202
+ continue;
1203
+ }
1204
+ allDecisions.push(...synthesis.decisions);
1205
+ emit({
1206
+ type: 'batch-complete',
1207
+ batchIndex,
1208
+ durationMs,
1209
+ decisions: synthesis.decisions
1210
+ });
1211
+ }
1212
+
1213
+ if (allDecisions.length === 0) {
1214
+ emit({
1215
+ type: 'result',
1216
+ ok: false,
1217
+ provider: firstProvider,
1218
+ status: 'failed',
1219
+ failureReason: 'Every batch failed; no decisions produced.',
1220
+ rawResponse: lastRawResponse,
1221
+ batchFailures
1222
+ });
1223
+ response.end();
1224
+ return true;
1225
+ }
1226
+
1227
+ const run = await applyAutoCleanDecisions(allDecisions);
1228
+ emit({
1229
+ type: 'result',
1230
+ ok: true,
1231
+ provider: firstProvider,
1232
+ status: 'generated',
1233
+ run,
1234
+ rawResponse: lastRawResponse,
1235
+ batchFailures,
1236
+ batchSize,
1237
+ batchCount: totalBatches
1238
+ });
1239
+ response.end();
1240
+ return true;
1241
+ } catch (error) {
1242
+ emit({
1243
+ type: 'result',
1244
+ ok: false,
1245
+ status: 'failed',
1246
+ failureReason: error instanceof Error ? error.message : String(error)
1247
+ });
1248
+ response.end();
1249
+ return true;
1250
+ } finally {
1251
+ request.off('close', onClose);
1252
+ }
1253
+ }
1254
+
1255
+ // Revert a previous auto-clean run. Body: { runId: string }.
1256
+ if (request.method === 'POST' && requestPath === autoCleanRevertPath) {
1257
+ try {
1258
+ if (authMode === 'token') {
1259
+ const tokenError = checkBridgeToken(request);
1260
+ if (tokenError) {
1261
+ respondBridgeError(response, tokenError.statusCode, tokenError.errorCode, tokenError.message, tokenError.details);
1262
+ return true;
1263
+ }
1264
+ }
1265
+ const body = await readJsonBody(request);
1266
+ const runId = typeof body.runId === 'string' ? body.runId.trim() : '';
1267
+ if (!runId) {
1268
+ respondBridgeError(response, 400, 'missing-run-id', 'Provide runId in the request body.');
1269
+ return true;
1270
+ }
1271
+ const result = await revertAutoCleanRun(runId);
1272
+ respondJson(response, 200, result);
1273
+ return true;
1274
+ } catch (error) {
1275
+ respondBridgeError(response, 500, 'auto-clean-revert-failed',
1276
+ error instanceof Error ? error.message : String(error));
1277
+ return true;
1278
+ }
1279
+ }
1280
+
1281
+ // List recent auto-clean runs (newest first).
1282
+ if (request.method === 'GET' && requestPath === autoCleanRunsPath) {
1283
+ try {
1284
+ if (authMode === 'token') {
1285
+ const tokenError = checkBridgeToken(request);
1286
+ if (tokenError) {
1287
+ respondBridgeError(response, tokenError.statusCode, tokenError.errorCode, tokenError.message, tokenError.details);
1288
+ return true;
1289
+ }
1290
+ }
1291
+ const runs = await listAutoCleanRuns();
1292
+ respondJson(response, 200, { runs });
1293
+ return true;
1294
+ } catch (error) {
1295
+ respondBridgeError(response, 500, 'bridge-execution-failed',
1296
+ error instanceof Error ? error.message : String(error));
1297
+ return true;
1298
+ }
1299
+ }
1300
+
1301
+ // T9: Browser-side telemetry consent UI. Four endpoints (status / opt-in / opt-out
1302
+ // / upload) mirror the existing CLI subcommands so the operator can manage
1303
+ // telemetry consent and trigger uploads without leaving the wiki UI. Each endpoint
1304
+ // also returns the refreshed status payload so the UI can update in place after
1305
+ // every action without a separate fetch round trip.
1306
+ if (request.method === 'GET' && requestPath === telemetryStatusPath) {
1307
+ try {
1308
+ if (authMode === 'token') {
1309
+ const tokenError = checkBridgeToken(request);
1310
+ if (tokenError) {
1311
+ respondBridgeError(response, tokenError.statusCode, tokenError.errorCode, tokenError.message, tokenError.details);
1312
+ return true;
1313
+ }
1314
+ }
1315
+ const status = await writeTelemetryStatusArtifact();
1316
+ respondJson(response, 200, { status });
1317
+ return true;
1318
+ } catch (error) {
1319
+ respondBridgeError(response, 500, 'telemetry-status-failed',
1320
+ error instanceof Error ? error.message : String(error));
1321
+ return true;
1322
+ }
1323
+ }
1324
+
1325
+ if (request.method === 'POST' && requestPath === telemetryOptInPath) {
1326
+ try {
1327
+ if (authMode === 'token') {
1328
+ const tokenError = checkBridgeToken(request);
1329
+ if (tokenError) {
1330
+ respondBridgeError(response, tokenError.statusCode, tokenError.errorCode, tokenError.message, tokenError.details);
1331
+ return true;
1332
+ }
1333
+ }
1334
+ const status = await setTelemetrySharingMode('opt-in');
1335
+ respondJson(response, 200, { status });
1336
+ return true;
1337
+ } catch (error) {
1338
+ respondBridgeError(response, 500, 'telemetry-set-mode-failed',
1339
+ error instanceof Error ? error.message : String(error));
1340
+ return true;
1341
+ }
1342
+ }
1343
+
1344
+ if (request.method === 'POST' && requestPath === telemetryOptOutPath) {
1345
+ try {
1346
+ if (authMode === 'token') {
1347
+ const tokenError = checkBridgeToken(request);
1348
+ if (tokenError) {
1349
+ respondBridgeError(response, tokenError.statusCode, tokenError.errorCode, tokenError.message, tokenError.details);
1350
+ return true;
1351
+ }
1352
+ }
1353
+ const status = await setTelemetrySharingMode('off');
1354
+ respondJson(response, 200, { status });
1355
+ return true;
1356
+ } catch (error) {
1357
+ respondBridgeError(response, 500, 'telemetry-set-mode-failed',
1358
+ error instanceof Error ? error.message : String(error));
1359
+ return true;
1360
+ }
1361
+ }
1362
+
1363
+ // T10: operator live-preview of the cohort report. Calls buildTelemetryReport against
1364
+ // the read-scoped env vars the operator sets locally (DENDRITE_WIKI_TELEMETRY_REPORT_URL/
1365
+ // _REPORT_TOKEN). When unconfigured, returns 412 with a human-readable message so the
1366
+ // dashboard can show "set the env vars and restart" instead of a hard error. The same
1367
+ // JSON shape that docs/public/aggregate-learnings.json stores is what comes back, so
1368
+ // the operator can copy it into that file to publish the snapshot.
1369
+ if (request.method === 'GET' && requestPath === telemetryReportPath) {
1370
+ try {
1371
+ if (authMode === 'token') {
1372
+ const tokenError = checkBridgeToken(request);
1373
+ if (tokenError) {
1374
+ respondBridgeError(response, tokenError.statusCode, tokenError.errorCode, tokenError.message, tokenError.details);
1375
+ return true;
1376
+ }
1377
+ }
1378
+ // Resolution order (T13): env vars (BYO destination — operator-owned Turso DB
1379
+ // wins over baked defaults) → telemetry-defaults.ts (Dendrite-hosted public
1380
+ // cohort, baked at publish time) → unconfigured (412).
1381
+ const envUrl = process.env.DENDRITE_WIKI_TELEMETRY_REPORT_URL?.trim() ?? '';
1382
+ const envToken = process.env.DENDRITE_WIKI_TELEMETRY_REPORT_TOKEN?.trim() ?? '';
1383
+ const envTable = process.env.DENDRITE_WIKI_TELEMETRY_REPORT_TABLE?.trim() ?? '';
1384
+ const url = envUrl || TELEMETRY_DEFAULT_REPORT_URL.trim();
1385
+ const token = envToken || TELEMETRY_DEFAULT_REPORT_TOKEN.trim();
1386
+ const table = envTable || TELEMETRY_DEFAULT_REPORT_TABLE.trim() || undefined;
1387
+ if (!url || !token) {
1388
+ respondBridgeError(
1389
+ response,
1390
+ 412,
1391
+ 'telemetry-report-unconfigured',
1392
+ 'Live cohort refresh has no destination configured. Either (a) wait until the next published release which bakes in the Dendrite-hosted destination, or (b) set DENDRITE_WIKI_TELEMETRY_REPORT_URL and DENDRITE_WIKI_TELEMETRY_REPORT_TOKEN in the shell that runs npm run docs:dev. The token must be READ-scoped — never reuse the package-baked write-scoped token.'
1393
+ );
1394
+ return true;
1395
+ }
1396
+ const report = await buildTelemetryReport({ url, token, table });
1397
+ respondJson(response, 200, { report });
1398
+ return true;
1399
+ } catch (error) {
1400
+ respondBridgeError(response, 500, 'telemetry-report-failed',
1401
+ error instanceof Error ? error.message : String(error));
1402
+ return true;
1403
+ }
1404
+ }
1405
+
1406
+ // T12: preview the exact payload uploadTelemetry would send right now without
1407
+ // sending it. Returns 200 + { payload } when a telemetry config exists, or 404
1408
+ // + telemetry-preview-no-consent when the user hasn't opted in yet — the UI shows
1409
+ // "opt in first" rather than rendering a confusing empty preview.
1410
+ if (request.method === 'GET' && requestPath === telemetryUploadPreviewPath) {
1411
+ try {
1412
+ if (authMode === 'token') {
1413
+ const tokenError = checkBridgeToken(request);
1414
+ if (tokenError) {
1415
+ respondBridgeError(response, tokenError.statusCode, tokenError.errorCode, tokenError.message, tokenError.details);
1416
+ return true;
1417
+ }
1418
+ }
1419
+ const payload = await previewTelemetryUploadPayload();
1420
+ if (!payload) {
1421
+ respondBridgeError(
1422
+ response,
1423
+ 404,
1424
+ 'telemetry-preview-no-consent',
1425
+ 'No telemetry consent recorded yet. Opt in to telemetry first; a random installationId/projectId pair is generated at that moment and the preview becomes available.'
1426
+ );
1427
+ return true;
1428
+ }
1429
+ respondJson(response, 200, { payload });
1430
+ return true;
1431
+ } catch (error) {
1432
+ respondBridgeError(response, 500, 'telemetry-preview-failed',
1433
+ error instanceof Error ? error.message : String(error));
1434
+ return true;
1435
+ }
1436
+ }
1437
+
1438
+ if (request.method === 'POST' && requestPath === telemetryUploadPath) {
1439
+ try {
1440
+ if (authMode === 'token') {
1441
+ const tokenError = checkBridgeToken(request);
1442
+ if (tokenError) {
1443
+ respondBridgeError(response, tokenError.statusCode, tokenError.errorCode, tokenError.message, tokenError.details);
1444
+ return true;
1445
+ }
1446
+ }
1447
+ // uploadTelemetry returns an object with `ok`, `message`, `destination`,
1448
+ // `auditPath`, and `status`. Even when consent is off or the destination
1449
+ // is unconfigured, the call resolves cleanly with `ok: false` and a
1450
+ // human-readable `message` — we forward the whole payload so the UI can
1451
+ // surface skipped/configured/error states without inferring from HTTP status.
1452
+ const result = await uploadTelemetry();
1453
+ respondJson(response, 200, result);
1454
+ return true;
1455
+ } catch (error) {
1456
+ respondBridgeError(response, 500, 'telemetry-upload-failed',
1457
+ error instanceof Error ? error.message : String(error));
1458
+ return true;
1459
+ }
1460
+ }
1461
+
1462
+ if (request.method === 'POST' && requestPath === executePath) {
1463
+ try {
1464
+ if (authMode === 'token') {
1465
+ const tokenError = checkBridgeToken(request);
1466
+ if (tokenError) {
1467
+ respondBridgeError(response, tokenError.statusCode, tokenError.errorCode, tokenError.message, tokenError.details);
1468
+ return true;
1469
+ }
1470
+ }
1471
+
1472
+ const body = await readJsonBody(request);
1473
+ const actionId = typeof body.actionId === 'string' ? body.actionId.trim() : '';
1474
+ const confirmActionId = typeof body.confirmActionId === 'string' ? body.confirmActionId.trim() : '';
1475
+ // Narrow operator-supplied field consumed only by edit-page-summary actions.
1476
+ // Kept as a typed scalar (not a generic argumentOverrides map) so the bridge cannot
1477
+ // be tricked into rewriting arbitrary action arguments — only the summary text the
1478
+ // inline editor produces flows through this path.
1479
+ const summaryDraft = typeof body.summaryDraft === 'string' ? body.summaryDraft : undefined;
1480
+
1481
+ if (!actionId) {
1482
+ respondBridgeError(response, 400, 'missing-action-id', 'Missing actionId.');
1483
+ return true;
1484
+ }
1485
+
1486
+ const [findings, proposals, memoryReview] = await Promise.all([
1487
+ lintWikiPages(),
1488
+ listWikiProposals(),
1489
+ reviewProjectMemories()
1490
+ ]);
1491
+ const resolved = await findMaintenanceInboxAction(actionId, findings, proposals, {
1492
+ memoryFindings: memoryReview.findings
1493
+ });
1494
+
1495
+ if (!resolved) {
1496
+ respondBridgeError(response, 404, 'unknown-maintenance-action', `Unknown maintenance action: ${actionId}`, {
1497
+ actionId
1498
+ });
1499
+ return true;
1500
+ }
1501
+
1502
+ if (requiresBridgeConfirmation(resolved.action.kind) && confirmActionId !== actionId) {
1503
+ respondBridgeError(response, 409, 'confirmation-required', `Confirmation required for maintenance action: ${actionId}`, {
1504
+ actionId,
1505
+ actionKind: resolved.action.kind,
1506
+ confirmationRequired: true
1507
+ });
1508
+ return true;
1509
+ }
1510
+
1511
+ const artifact = await runMaintenanceActionAndRefresh(actionId, { summaryDraft });
1512
+ respondJson(response, 200, artifact);
1513
+ return true;
1514
+ } catch (error) {
1515
+ respondBridgeError(
1516
+ response,
1517
+ 500,
1518
+ 'bridge-execution-failed',
1519
+ error instanceof Error ? error.message : String(error)
1520
+ );
1521
+ return true;
1522
+ }
1523
+ }
1524
+
1525
+ return false;
1526
+ };
1527
+
1528
+ return {
1529
+ handle: handler,
1530
+ bridge: bridgeName,
1531
+ healthPath,
1532
+ executePath,
1533
+ previewPromotionPath,
1534
+ previewProposalPath,
1535
+ previewSkillPromotionPath,
1536
+ synthesizeDriftPath,
1537
+ synthesizeChartPath,
1538
+ chartReplacePath,
1539
+ ollamaModelsPath,
1540
+ pageReadPath,
1541
+ pageWritePath,
1542
+ pageListPath,
1543
+ pageInboxPath,
1544
+ pageInboxSummaryPath,
1545
+ autoCleanMemoriesPath,
1546
+ autoCleanRevertPath,
1547
+ autoCleanRunsPath,
1548
+ telemetryStatusPath,
1549
+ telemetryOptInPath,
1550
+ telemetryOptOutPath,
1551
+ telemetryUploadPath,
1552
+ telemetryReportPath,
1553
+ telemetryUploadPreviewPath,
1554
+ cortexPath,
1555
+ cortexExecutePath,
1556
+ authMode,
1557
+ sessionId
1558
+ };
1559
+ }
1560
+
1561
+ function stripQueryString(url: string): string {
1562
+ const queryIndex = url.indexOf('?');
1563
+ return queryIndex === -1 ? url : url.slice(0, queryIndex);
1564
+ }
1565
+
1566
+ // Collect the memories from a memory-review finding set that are candidates for the LLM
1567
+ // auto-clean decision pass. Includes anything with an archive-memory action available:
1568
+ // growing (incubating, no findings), stale (aged out), unsupported (no sources), and
1569
+ // duplicate (the older copies). Skips promotion-ready / skill-promotion-ready /
1570
+ // contradiction findings — those are graduation or reconciliation decisions, not retirement.
1571
+ function collectAutoCleanCandidates(
1572
+ findings: ProjectMemoryReviewFinding[],
1573
+ maxCandidates: number
1574
+ ): MemoryAutoCleanCandidate[] {
1575
+ const seen = new Set<string>();
1576
+ const candidates: MemoryAutoCleanCandidate[] = [];
1577
+ for (const finding of findings) {
1578
+ if (
1579
+ finding.kind !== 'growing' &&
1580
+ finding.kind !== 'stale' &&
1581
+ finding.kind !== 'unsupported' &&
1582
+ finding.kind !== 'duplicate'
1583
+ ) {
1584
+ continue;
1585
+ }
1586
+ for (const record of finding.records) {
1587
+ if (seen.has(record.id)) continue;
1588
+ seen.add(record.id);
1589
+ const createdAt = Date.parse(record.createdAt || record.updatedAt || '');
1590
+ const ageInDays = Number.isFinite(createdAt)
1591
+ ? Math.max(0, Math.floor((Date.now() - createdAt) / (24 * 60 * 60 * 1000)))
1592
+ : 0;
1593
+ candidates.push({
1594
+ memoryId: record.id,
1595
+ kind: record.kind,
1596
+ text: record.text,
1597
+ recallCount: record.recallCount,
1598
+ ageInDays,
1599
+ lastRecalledAt: record.lastRecalledAt,
1600
+ sources: record.sources.length,
1601
+ reviewFindingKind: finding.kind
1602
+ });
1603
+ if (Number.isFinite(maxCandidates) && candidates.length >= maxCandidates) {
1604
+ return candidates;
1605
+ }
1606
+ }
1607
+ }
1608
+ return candidates;
1609
+ }
1610
+
1611
+ export function createReviewBridgeServer(options: ReviewBridgeServerOptions): Server {
1612
+ const handler = createReviewBridgeHandler({
1613
+ authMode: 'token',
1614
+ authToken: options.authToken,
1615
+ authTokenTtlMs: options.authTokenTtlMs,
1616
+ now: options.now,
1617
+ sessionId: options.sessionId,
1618
+ allowedOrigins: options.allowedOrigins
1619
+ });
1620
+
1621
+ return createServer(async (request, response) => {
1622
+ const handled = await handler.handle(request, response);
1623
+ if (!handled) {
1624
+ respondBridgeError(response, 404, 'route-not-found', 'Not found.');
1625
+ }
1626
+ });
1627
+ }
1628
+
1629
+ function sanitizeAuthTokenTtlMs(value: number | undefined): number | null {
1630
+ if (value === undefined || Number.isNaN(value) || value <= 0) {
1631
+ return null;
1632
+ }
1633
+
1634
+ return Math.floor(value);
1635
+ }
1636
+
1637
+ function requiresBridgeConfirmation(actionKind: string): boolean {
1638
+ // High-risk actions need an explicit confirm step before the bridge accepts them.
1639
+ // archive-guidance-file moves a file on disk; edit-page-summary rewrites a wiki page's
1640
+ // first paragraph (operator-supplied text — must be reviewed, not rubber-stamped); the
1641
+ // others apply curated content to canonical pages. Snooze and insert-h1 are intentionally
1642
+ // NOT here: snooze touches only local-data, and insert-h1 is a mechanical, idempotent
1643
+ // write the operator already approved by clicking it.
1644
+ return (
1645
+ actionKind === 'apply-proposal' ||
1646
+ actionKind === 'apply-memory-promotion' ||
1647
+ actionKind === 'archive-guidance-file' ||
1648
+ actionKind === 'edit-page-summary'
1649
+ );
1650
+ }
1651
+
1652
+ function readBridgeToken(request: IncomingMessage): string {
1653
+ const headerValue = request.headers[REVIEW_BRIDGE_TOKEN_HEADER];
1654
+
1655
+ if (Array.isArray(headerValue)) {
1656
+ return headerValue[0]?.trim() ?? '';
1657
+ }
1658
+
1659
+ return typeof headerValue === 'string' ? headerValue.trim() : '';
1660
+ }
1661
+
1662
+ function readRequestOrigin(request: IncomingMessage): string {
1663
+ const headerValue = request.headers.origin;
1664
+
1665
+ if (Array.isArray(headerValue)) {
1666
+ return headerValue[0]?.trim() ?? '';
1667
+ }
1668
+
1669
+ return typeof headerValue === 'string' ? headerValue.trim() : '';
1670
+ }
1671
+
1672
+ function writeCorsHeaders(response: ServerResponse, requestOrigin?: string): void {
1673
+ if (requestOrigin) {
1674
+ response.setHeader('Access-Control-Allow-Origin', requestOrigin);
1675
+ response.setHeader('Vary', 'Origin');
1676
+ response.setHeader('Access-Control-Max-Age', String(REVIEW_BRIDGE_CORS_MAX_AGE_SECONDS));
1677
+ }
1678
+
1679
+ response.setHeader('Access-Control-Allow-Methods', 'GET,POST,OPTIONS');
1680
+ response.setHeader('Access-Control-Allow-Headers', `Content-Type, ${REVIEW_BRIDGE_TOKEN_HEADER}`);
1681
+ }
1682
+
1683
+ function sanitizeAllowedOrigins(value: string[] | undefined): string[] {
1684
+ const candidates = value ?? DEFAULT_REVIEW_BRIDGE_ALLOWED_ORIGINS;
1685
+ const uniqueOrigins = new Set<string>();
1686
+
1687
+ for (const origin of candidates) {
1688
+ const trimmed = origin.trim();
1689
+ if (trimmed) {
1690
+ uniqueOrigins.add(trimmed);
1691
+ }
1692
+ }
1693
+
1694
+ return [...uniqueOrigins];
1695
+ }
1696
+
1697
+ function respondBridgeError(
1698
+ response: ServerResponse,
1699
+ statusCode: number,
1700
+ errorCode: ReviewBridgeErrorCode,
1701
+ error: string,
1702
+ details: Record<string, unknown> = {}
1703
+ ): void {
1704
+ respondJson(response, statusCode, {
1705
+ error,
1706
+ errorCode,
1707
+ ...details
1708
+ });
1709
+ }
1710
+
1711
+ function respondJson(response: ServerResponse, statusCode: number, payload: unknown): void {
1712
+ response.statusCode = statusCode;
1713
+ response.setHeader('Content-Type', 'application/json; charset=utf-8');
1714
+ response.end(JSON.stringify(payload, null, 2));
1715
+ }
1716
+
1717
+ async function readJsonBody(request: IncomingMessage): Promise<Record<string, unknown>> {
1718
+ const chunks: Buffer[] = [];
1719
+
1720
+ for await (const chunk of request) {
1721
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
1722
+ }
1723
+
1724
+ if (chunks.length === 0) {
1725
+ return {};
1726
+ }
1727
+
1728
+ return JSON.parse(Buffer.concat(chunks).toString('utf8')) as Record<string, unknown>;
1729
+ }