@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.
- package/README.md +79 -0
- package/dist/api-extractor/extract.js +269 -0
- package/dist/api-extractor/language-extractor.js +15 -0
- package/dist/api-extractor/python-extractor.js +358 -0
- package/dist/api-extractor/render.js +195 -0
- package/dist/api-extractor/tree-sitter-extractor.js +1079 -0
- package/dist/api-extractor/types.js +11 -0
- package/dist/api-extractor/typescript-extractor.js +50 -0
- package/dist/api-extractor/walk.js +178 -0
- package/dist/api-reference.js +438 -0
- package/dist/benchmark-events.js +129 -0
- package/dist/benchmark.js +270 -0
- package/dist/binder-export.js +381 -0
- package/dist/canonical-target.js +168 -0
- package/dist/chart-insert.js +377 -0
- package/dist/chart-prompts.js +414 -0
- package/dist/context-cache.js +98 -0
- package/dist/contradicts-shipped-memory.js +232 -0
- package/dist/diff-context.js +142 -0
- package/dist/doctor.js +220 -0
- package/dist/generated-docs.js +219 -0
- package/dist/i18n.js +71 -0
- package/dist/index.js +49 -0
- package/dist/librarian.js +255 -0
- package/dist/maintenance-actions.js +244 -0
- package/dist/maintenance-inbox.js +842 -0
- package/dist/maintenance-runner.js +62 -0
- package/dist/page-drift.js +225 -0
- package/dist/page-inbox.js +168 -0
- package/dist/report-export.js +339 -0
- package/dist/review-bridge.js +1386 -0
- package/dist/search-index.js +199 -0
- package/dist/store.js +1617 -0
- package/dist/telemetry-defaults.js +44 -0
- package/dist/telemetry-report.js +263 -0
- package/dist/telemetry.js +544 -0
- package/dist/wiki-synthesis.js +901 -0
- package/package.json +35 -0
- package/src/api-extractor/extract.ts +333 -0
- package/src/api-extractor/language-extractor.ts +37 -0
- package/src/api-extractor/python-extractor.ts +380 -0
- package/src/api-extractor/render.ts +267 -0
- package/src/api-extractor/tree-sitter-extractor.ts +1210 -0
- package/src/api-extractor/types.ts +41 -0
- package/src/api-extractor/typescript-extractor.ts +56 -0
- package/src/api-extractor/walk.ts +209 -0
- package/src/api-reference.ts +552 -0
- package/src/benchmark-events.ts +216 -0
- package/src/benchmark.ts +376 -0
- package/src/binder-export.ts +437 -0
- package/src/canonical-target.ts +192 -0
- package/src/chart-insert.ts +478 -0
- package/src/chart-prompts.ts +417 -0
- package/src/context-cache.ts +129 -0
- package/src/contradicts-shipped-memory.ts +311 -0
- package/src/diff-context.ts +187 -0
- package/src/doctor.ts +260 -0
- package/src/generated-docs.ts +316 -0
- package/src/i18n.ts +106 -0
- package/src/index.ts +59 -0
- package/src/librarian.ts +331 -0
- package/src/maintenance-actions.ts +314 -0
- package/src/maintenance-inbox.ts +1132 -0
- package/src/maintenance-runner.ts +85 -0
- package/src/page-drift.ts +292 -0
- package/src/page-inbox.ts +254 -0
- package/src/report-export.ts +392 -0
- package/src/review-bridge.ts +1729 -0
- package/src/search-index.ts +266 -0
- package/src/store.ts +2171 -0
- package/src/telemetry-defaults.ts +50 -0
- package/src/telemetry-report.ts +365 -0
- package/src/telemetry.ts +757 -0
- 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
|
+
}
|