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