@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,1307 @@
1
+ /**
2
+ * Synthesis providers — deterministic prompt builders for LLM-assisted wiki work.
3
+ *
4
+ * Builds structured prompts for three distinct tasks: claim synthesis (turn a page's prose
5
+ * into source-backed `[planned]`/`[current]` claims), guidance synthesis (suggest where a
6
+ * piece of agent guidance should live based on existing patterns), and proposal synthesis
7
+ * (draft a `WikiMergeGuidanceProposal` or `WikiRouteGuidanceProposal` for the maintenance
8
+ * inbox). Drift-resolution prompts assist when a page-drift finding needs an LLM to
9
+ * suggest whether to update the page, the project log, or both.
10
+ *
11
+ * No LLM is called from this module — every function returns a structured prompt the
12
+ * operator pastes into Claude/GPT/local-Ollama, then feeds the result back through the
13
+ * normal `wiki_apply_proposal` or `memory_remember` paths. This is the "agent provider"
14
+ * pattern: provider-agnostic, no API keys required by default, no opaque dependencies.
15
+ * `listOllamaModels` exists for the optional local-model path.
16
+ */
17
+ import { promises as fs } from 'node:fs';
18
+ import path from 'node:path';
19
+ import {
20
+ extractWikiClaims,
21
+ listProjectGuidanceFiles,
22
+ listWikiPages,
23
+ listWikiProposals,
24
+ readWikiPage,
25
+ type WikiClaim,
26
+ type WikiGuidanceFile,
27
+ type WikiProposal
28
+ } from './store.js';
29
+ import { buildChartPrompt, parseChartResponse, type ChartPromptKind } from './chart-prompts.js';
30
+
31
+ export type WikiSynthesisProviderKind = 'none' | 'agent' | 'ollama' | 'cloud';
32
+ export type WikiSynthesisProviderStatus = 'disabled' | 'ready' | 'unavailable' | 'misconfigured';
33
+ export type WikiSynthesisItemStatus = 'disabled' | 'unavailable' | 'handoff' | 'generated' | 'failed';
34
+ export type WikiProposalSynthesisStatus = WikiSynthesisItemStatus;
35
+
36
+ export interface WikiSynthesisProviderInfo {
37
+ kind: WikiSynthesisProviderKind;
38
+ status: WikiSynthesisProviderStatus;
39
+ reason?: string;
40
+ model?: string;
41
+ endpoint?: string;
42
+ timeoutMs: number;
43
+ }
44
+
45
+ export interface WikiProposalSynthesisItem {
46
+ reviewSlug: string;
47
+ kind: WikiProposal['kind'];
48
+ summary: string;
49
+ currentStateSummary: string;
50
+ afterApplySummary: string;
51
+ rationale: string;
52
+ synthesisStatus: WikiProposalSynthesisStatus;
53
+ synthesizedSummary?: string;
54
+ handoffPrompt?: string;
55
+ failureReason?: string;
56
+ }
57
+
58
+ export interface WikiClaimSynthesisItem {
59
+ pageSlug: string;
60
+ text: string;
61
+ status: WikiClaim['status'];
62
+ sources: WikiClaim['sources'];
63
+ synthesisStatus: WikiSynthesisItemStatus;
64
+ synthesizedExplanation?: string;
65
+ handoffPrompt?: string;
66
+ failureReason?: string;
67
+ }
68
+
69
+ export interface WikiGuidanceSynthesisItem {
70
+ path: string;
71
+ kind: WikiGuidanceFile['kind'];
72
+ summary: string;
73
+ synthesisStatus: WikiSynthesisItemStatus;
74
+ synthesizedDistillation?: string;
75
+ handoffPrompt?: string;
76
+ failureReason?: string;
77
+ }
78
+
79
+ export interface WikiProposalSynthesisResult {
80
+ provider: WikiSynthesisProviderInfo;
81
+ proposals: WikiProposalSynthesisItem[];
82
+ }
83
+
84
+ export interface WikiClaimSynthesisResult {
85
+ provider: WikiSynthesisProviderInfo;
86
+ claims: WikiClaimSynthesisItem[];
87
+ }
88
+
89
+ export interface WikiGuidanceSynthesisResult {
90
+ provider: WikiSynthesisProviderInfo;
91
+ guidanceFiles: WikiGuidanceSynthesisItem[];
92
+ }
93
+
94
+ export interface ResolveWikiSynthesisProviderOptions {
95
+ requestedKind?: WikiSynthesisProviderKind;
96
+ /** Per-call override for OLLAMA_MODEL — lets the review board pick a model from a dropdown
97
+ * without restarting the server. Ignored unless the resolved provider is `ollama`. */
98
+ requestedOllamaModel?: string;
99
+ env?: NodeJS.ProcessEnv;
100
+ }
101
+
102
+ export interface SynthesizeWikiProposalsOptions extends ResolveWikiSynthesisProviderOptions {
103
+ reviewSlug?: string;
104
+ maxItems?: number;
105
+ fetcher?: typeof fetch;
106
+ proposals?: WikiProposal[];
107
+ }
108
+
109
+ export interface SynthesizeWikiClaimsOptions extends ResolveWikiSynthesisProviderOptions {
110
+ pageSlug?: string;
111
+ maxItems?: number;
112
+ fetcher?: typeof fetch;
113
+ claims?: WikiClaim[];
114
+ }
115
+
116
+ export interface SynthesizeWikiGuidanceOptions extends ResolveWikiSynthesisProviderOptions {
117
+ guidancePath?: string;
118
+ maxItems?: number;
119
+ fetcher?: typeof fetch;
120
+ guidanceFiles?: WikiGuidanceFile[];
121
+ }
122
+
123
+ const defaultOllamaUrl = 'http://localhost:11434';
124
+ // Per-provider default timeouts. Local Ollama generations on slow hardware can take
125
+ // 30-90s for the first call (cold-start of a freshly-loaded model is the worst case).
126
+ // Cloud APIs reliably respond well under 30s. The agent provider doesn't actually call
127
+ // out — it just returns a handoff prompt — so its timeout is only here for symmetry.
128
+ // All values are an upper bound; the request will return as soon as the provider does.
129
+ //
130
+ // Ollama default at 5 minutes: chart synthesis (M4 of the AI-mermaid-charts roadmap)
131
+ // regularly exceeds the previous 2-minute default for small models on CPU producing
132
+ // flowcharts with many nodes. The env var DENDRITE_WIKI_SYNTHESIS_TIMEOUT_MS overrides
133
+ // for operators with bigger workloads or beefier hardware.
134
+ const defaultSynthesisTimeoutMsByKind: Record<WikiSynthesisProviderKind, number> = {
135
+ none: 8_000,
136
+ agent: 5_000,
137
+ ollama: 300_000,
138
+ cloud: 30_000
139
+ };
140
+ const fallbackSynthesisTimeoutMs = 8_000;
141
+ const maxSynthesizedSummaryLength = 280;
142
+ const maxSynthesizedExplanationLength = 360;
143
+ const maxSynthesizedDistillationLength = 600;
144
+ const maxPromptContentLength = 4_000;
145
+ const repoRoot = path.resolve(process.cwd());
146
+
147
+ export function resolveWikiSynthesisProvider(
148
+ options: ResolveWikiSynthesisProviderOptions = {}
149
+ ): WikiSynthesisProviderInfo {
150
+ const env = options.env ?? process.env;
151
+ const kind = options.requestedKind ?? parseProviderKind(env.DENDRITE_WIKI_SYNTHESIS_PROVIDER);
152
+ // Default timeout depends on provider kind. The env var still wins for explicit overrides.
153
+ const timeoutMs = parseTimeoutMs(env.DENDRITE_WIKI_SYNTHESIS_TIMEOUT_MS, defaultSynthesisTimeoutMsByKind[kind]);
154
+
155
+ switch (kind) {
156
+ case 'none':
157
+ return {
158
+ kind,
159
+ status: 'disabled',
160
+ reason: 'Optional synthesis is disabled. Set DENDRITE_WIKI_SYNTHESIS_PROVIDER=ollama or pass provider "ollama" to this tool.',
161
+ timeoutMs
162
+ };
163
+ case 'agent':
164
+ return {
165
+ kind,
166
+ status: 'ready',
167
+ reason: 'The agent provider returns a bounded handoff prompt for the active coding agent instead of running server-side inference.',
168
+ timeoutMs
169
+ };
170
+ case 'cloud':
171
+ return resolveCloudProvider(env, timeoutMs);
172
+ case 'ollama': {
173
+ // Per-call override (e.g., from the review board model picker) wins over env.
174
+ const overrideModel = options.requestedOllamaModel?.trim() ?? '';
175
+ const model = overrideModel || env.OLLAMA_MODEL?.trim() || '';
176
+ const endpoint = env.OLLAMA_URL?.trim() || defaultOllamaUrl;
177
+ if (model.length === 0) {
178
+ return {
179
+ kind,
180
+ status: 'misconfigured',
181
+ reason: 'OLLAMA_MODEL must be set (or a model passed in the request) before the ollama provider can run.',
182
+ endpoint,
183
+ timeoutMs
184
+ };
185
+ }
186
+
187
+ return {
188
+ kind,
189
+ status: 'ready',
190
+ model,
191
+ endpoint,
192
+ timeoutMs
193
+ };
194
+ }
195
+ }
196
+ }
197
+
198
+ function resolveCloudProvider(env: NodeJS.ProcessEnv, timeoutMs: number): WikiSynthesisProviderInfo {
199
+ const endpoint = env.DENDRITE_WIKI_CLOUD_URL?.trim() ?? '';
200
+ const model = env.DENDRITE_WIKI_CLOUD_MODEL?.trim() ?? '';
201
+ const apiKey = env.DENDRITE_WIKI_CLOUD_API_KEY?.trim() ?? '';
202
+
203
+ if (!endpoint || !model || !apiKey) {
204
+ const missing = [
205
+ endpoint ? '' : 'DENDRITE_WIKI_CLOUD_URL',
206
+ model ? '' : 'DENDRITE_WIKI_CLOUD_MODEL',
207
+ apiKey ? '' : 'DENDRITE_WIKI_CLOUD_API_KEY'
208
+ ].filter(Boolean).join(', ');
209
+
210
+ return {
211
+ kind: 'cloud',
212
+ status: 'misconfigured',
213
+ reason: `Cloud synthesis requires ${missing}.`,
214
+ endpoint: endpoint || undefined,
215
+ model: model || undefined,
216
+ timeoutMs
217
+ };
218
+ }
219
+
220
+ return {
221
+ kind: 'cloud',
222
+ status: 'ready',
223
+ endpoint,
224
+ model,
225
+ timeoutMs
226
+ };
227
+ }
228
+
229
+ export async function synthesizeWikiProposals(
230
+ options: SynthesizeWikiProposalsOptions = {}
231
+ ): Promise<WikiProposalSynthesisResult> {
232
+ const provider = resolveWikiSynthesisProvider(options);
233
+ const proposals = options.proposals ?? (await listWikiProposals());
234
+ const selected = selectProposals(proposals, options.reviewSlug, options.maxItems);
235
+
236
+ return {
237
+ provider,
238
+ proposals: await Promise.all(
239
+ selected.map((proposal) => synthesizeProposalSummary(proposal, provider, { fetcher: options.fetcher }))
240
+ )
241
+ };
242
+ }
243
+
244
+ export async function synthesizeWikiClaims(options: SynthesizeWikiClaimsOptions = {}): Promise<WikiClaimSynthesisResult> {
245
+ const provider = resolveWikiSynthesisProvider(options);
246
+ const claims = options.claims ?? (await listStaleClaims());
247
+ const selected = selectClaims(claims, options.pageSlug, options.maxItems);
248
+
249
+ return {
250
+ provider,
251
+ claims: await Promise.all(
252
+ selected.map((claim) => synthesizeStaleClaimExplanation(claim, provider, { fetcher: options.fetcher }))
253
+ )
254
+ };
255
+ }
256
+
257
+ export async function synthesizeWikiGuidance(
258
+ options: SynthesizeWikiGuidanceOptions = {}
259
+ ): Promise<WikiGuidanceSynthesisResult> {
260
+ const provider = resolveWikiSynthesisProvider(options);
261
+ const guidanceFiles = options.guidanceFiles ?? (await listProjectGuidanceFiles());
262
+ const selected = selectGuidanceFiles(guidanceFiles, options.guidancePath, options.maxItems);
263
+
264
+ return {
265
+ provider,
266
+ guidanceFiles: await Promise.all(
267
+ selected.map((guidance) => synthesizeGuidanceDistillation(guidance, provider, { fetcher: options.fetcher }))
268
+ )
269
+ };
270
+ }
271
+
272
+ export async function synthesizeProposalSummary(
273
+ proposal: WikiProposal,
274
+ provider: WikiSynthesisProviderInfo,
275
+ options: { fetcher?: typeof fetch } = {}
276
+ ): Promise<WikiProposalSynthesisItem> {
277
+ const synthesis = await synthesizeText(buildProposalSummaryPrompt(proposal), provider, {
278
+ fetcher: options.fetcher,
279
+ maxLength: maxSynthesizedSummaryLength,
280
+ emptyMessage: 'Synthesis provider returned an empty proposal summary.'
281
+ });
282
+
283
+ return {
284
+ reviewSlug: proposal.reviewSlug,
285
+ kind: proposal.kind,
286
+ summary: proposal.summary,
287
+ currentStateSummary: proposal.currentStateSummary,
288
+ afterApplySummary: proposal.afterApplySummary,
289
+ rationale: proposal.rationale,
290
+ synthesisStatus: synthesis.status,
291
+ synthesizedSummary: synthesis.text,
292
+ handoffPrompt: synthesis.handoffPrompt,
293
+ failureReason: synthesis.failureReason
294
+ };
295
+ }
296
+
297
+ export async function synthesizeStaleClaimExplanation(
298
+ claim: WikiClaim,
299
+ provider: WikiSynthesisProviderInfo,
300
+ options: { fetcher?: typeof fetch } = {}
301
+ ): Promise<WikiClaimSynthesisItem> {
302
+ const synthesis = await synthesizeText(buildClaimExplanationPrompt(claim), provider, {
303
+ fetcher: options.fetcher,
304
+ maxLength: maxSynthesizedExplanationLength,
305
+ emptyMessage: 'Synthesis provider returned an empty stale-claim explanation.'
306
+ });
307
+
308
+ return {
309
+ pageSlug: claim.pageSlug,
310
+ text: claim.text,
311
+ status: claim.status,
312
+ sources: claim.sources,
313
+ synthesisStatus: synthesis.status,
314
+ synthesizedExplanation: synthesis.text,
315
+ handoffPrompt: synthesis.handoffPrompt,
316
+ failureReason: synthesis.failureReason
317
+ };
318
+ }
319
+
320
+ export async function synthesizeGuidanceDistillation(
321
+ guidance: WikiGuidanceFile,
322
+ provider: WikiSynthesisProviderInfo,
323
+ options: { fetcher?: typeof fetch } = {}
324
+ ): Promise<WikiGuidanceSynthesisItem> {
325
+ const content = await fs.readFile(path.join(repoRoot, guidance.path), 'utf8').catch(() => '');
326
+ const synthesis = await synthesizeText(buildGuidanceDistillationPrompt(guidance, content), provider, {
327
+ fetcher: options.fetcher,
328
+ maxLength: maxSynthesizedDistillationLength,
329
+ emptyMessage: 'Synthesis provider returned an empty guidance distillation.'
330
+ });
331
+
332
+ return {
333
+ path: guidance.path,
334
+ kind: guidance.kind,
335
+ summary: guidance.summary,
336
+ synthesisStatus: synthesis.status,
337
+ synthesizedDistillation: synthesis.text,
338
+ handoffPrompt: synthesis.handoffPrompt,
339
+ failureReason: synthesis.failureReason
340
+ };
341
+ }
342
+
343
+ function selectProposals(proposals: WikiProposal[], reviewSlug?: string, maxItems = 3): WikiProposal[] {
344
+ if (reviewSlug) {
345
+ const proposal = proposals.find((candidate) => candidate.reviewSlug === reviewSlug);
346
+ if (!proposal) {
347
+ throw new Error(`Unknown active proposal: ${reviewSlug}`);
348
+ }
349
+
350
+ return [proposal];
351
+ }
352
+
353
+ return proposals.slice(0, maxItems);
354
+ }
355
+
356
+ function selectClaims(claims: WikiClaim[], pageSlug?: string, maxItems = 5): WikiClaim[] {
357
+ return (pageSlug ? claims.filter((claim) => claim.pageSlug === pageSlug) : claims).slice(0, maxItems);
358
+ }
359
+
360
+ function selectGuidanceFiles(guidanceFiles: WikiGuidanceFile[], guidancePath?: string, maxItems = 3): WikiGuidanceFile[] {
361
+ return (guidancePath ? guidanceFiles.filter((guidance) => guidance.path === guidancePath) : guidanceFiles).slice(0, maxItems);
362
+ }
363
+
364
+ async function listStaleClaims(): Promise<WikiClaim[]> {
365
+ const pages = await listWikiPages();
366
+ const pageByPath = new Map(pages.map((page) => [page.path, page.slug]));
367
+ const claims: WikiClaim[] = [];
368
+
369
+ for (const page of pages) {
370
+ const content = await readWikiPage(page.slug);
371
+ claims.push(...extractWikiClaims(page.slug, content, pageByPath).filter((claim) => claim.status !== 'current'));
372
+ }
373
+
374
+ return claims.sort((left, right) => `${left.pageSlug}:${left.text}`.localeCompare(`${right.pageSlug}:${right.text}`));
375
+ }
376
+
377
+ function parseProviderKind(value: string | undefined): WikiSynthesisProviderKind {
378
+ switch (value?.trim()) {
379
+ case 'agent':
380
+ return 'agent';
381
+ case 'ollama':
382
+ return 'ollama';
383
+ case 'cloud':
384
+ return 'cloud';
385
+ default:
386
+ return 'none';
387
+ }
388
+ }
389
+
390
+ function parseTimeoutMs(value: string | undefined, fallback: number = fallbackSynthesisTimeoutMs): number {
391
+ if (!value) {
392
+ return fallback;
393
+ }
394
+
395
+ const parsed = Number.parseInt(value, 10);
396
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
397
+ }
398
+
399
+ async function synthesizeText(
400
+ prompt: string,
401
+ provider: WikiSynthesisProviderInfo,
402
+ options: { fetcher?: typeof fetch; maxLength: number; emptyMessage: string }
403
+ ): Promise<{ status: WikiSynthesisItemStatus; text?: string; handoffPrompt?: string; failureReason?: string }> {
404
+ if (provider.status === 'disabled') {
405
+ return {
406
+ status: 'disabled',
407
+ failureReason: provider.reason
408
+ };
409
+ }
410
+
411
+ if (provider.status !== 'ready') {
412
+ return {
413
+ status: 'unavailable',
414
+ failureReason: provider.reason
415
+ };
416
+ }
417
+
418
+ if (provider.kind === 'agent') {
419
+ return {
420
+ status: 'handoff',
421
+ handoffPrompt: prompt
422
+ };
423
+ }
424
+
425
+ try {
426
+ if (provider.kind === 'cloud') {
427
+ return {
428
+ status: 'generated',
429
+ text: await requestCloudSynthesis(prompt, provider, options.fetcher ?? fetch, options.maxLength, options.emptyMessage)
430
+ };
431
+ }
432
+
433
+ return {
434
+ status: 'generated',
435
+ text: await requestOllamaSynthesis(prompt, provider, options.fetcher ?? fetch, options.maxLength, options.emptyMessage)
436
+ };
437
+ } catch (error) {
438
+ return {
439
+ status: 'failed',
440
+ failureReason: error instanceof Error ? error.message : 'Unknown synthesis error.'
441
+ };
442
+ }
443
+ }
444
+
445
+ async function requestCloudSynthesis(
446
+ prompt: string,
447
+ provider: WikiSynthesisProviderInfo,
448
+ fetcher: typeof fetch,
449
+ maxLength: number,
450
+ emptyMessage: string
451
+ ): Promise<string> {
452
+ const apiKey = process.env.DENDRITE_WIKI_CLOUD_API_KEY?.trim() ?? '';
453
+ const controller = new AbortController();
454
+ const timeoutHandle = setTimeout(() => controller.abort(), provider.timeoutMs);
455
+
456
+ try {
457
+ const response = await fetcher(provider.endpoint ?? '', {
458
+ method: 'POST',
459
+ headers: {
460
+ authorization: `Bearer ${apiKey}`,
461
+ 'content-type': 'application/json'
462
+ },
463
+ body: JSON.stringify({
464
+ model: provider.model,
465
+ messages: [
466
+ { role: 'system', content: 'You produce bounded, read-only synthesis for a local project wiki.' },
467
+ { role: 'user', content: prompt }
468
+ ],
469
+ temperature: 0
470
+ }),
471
+ signal: controller.signal
472
+ });
473
+
474
+ if (!response.ok) {
475
+ throw new Error(`Cloud synthesis request failed with status ${response.status}.`);
476
+ }
477
+
478
+ const payload = (await response.json()) as { choices?: Array<{ message?: { content?: unknown } }>; output_text?: unknown };
479
+ const content = typeof payload.output_text === 'string'
480
+ ? payload.output_text
481
+ : typeof payload.choices?.[0]?.message?.content === 'string'
482
+ ? payload.choices[0].message.content
483
+ : '';
484
+ return normalizeSynthesizedText(content, maxLength, emptyMessage);
485
+ } catch (error) {
486
+ if (error instanceof Error && error.name === 'AbortError') {
487
+ throw new Error(`Cloud synthesis timed out after ${provider.timeoutMs}ms.`);
488
+ }
489
+
490
+ throw error;
491
+ } finally {
492
+ clearTimeout(timeoutHandle);
493
+ }
494
+ }
495
+
496
+ async function requestOllamaSynthesis(
497
+ prompt: string,
498
+ provider: WikiSynthesisProviderInfo,
499
+ fetcher: typeof fetch,
500
+ maxLength: number,
501
+ emptyMessage: string
502
+ ): Promise<string> {
503
+ const controller = new AbortController();
504
+ const timeoutHandle = setTimeout(() => controller.abort(), provider.timeoutMs);
505
+
506
+ try {
507
+ const response = await fetcher(new URL('/api/generate', provider.endpoint ?? defaultOllamaUrl), {
508
+ method: 'POST',
509
+ headers: {
510
+ 'content-type': 'application/json'
511
+ },
512
+ body: JSON.stringify({
513
+ model: provider.model,
514
+ stream: false,
515
+ prompt
516
+ }),
517
+ signal: controller.signal
518
+ });
519
+
520
+ if (!response.ok) {
521
+ throw new Error(`Ollama request failed with status ${response.status}.`);
522
+ }
523
+
524
+ const payload = (await response.json()) as { response?: unknown };
525
+ return normalizeSynthesizedText(typeof payload.response === 'string' ? payload.response : '', maxLength, emptyMessage);
526
+ } catch (error) {
527
+ if (error instanceof Error && error.name === 'AbortError') {
528
+ throw new Error(`Ollama synthesis timed out after ${provider.timeoutMs}ms.`);
529
+ }
530
+
531
+ throw error;
532
+ } finally {
533
+ clearTimeout(timeoutHandle);
534
+ }
535
+ }
536
+
537
+ function buildProposalSummaryPrompt(proposal: WikiProposal): string {
538
+ return [
539
+ 'You are summarizing a deterministic wiki maintenance proposal for a cautious reviewer.',
540
+ `Return exactly one sentence under ${maxSynthesizedSummaryLength} characters.`,
541
+ 'Mention the cleanup being suggested and the main safety boundary.',
542
+ 'Do not use markdown bullets, code fences, or extra commentary.',
543
+ '',
544
+ `Proposal kind: ${proposal.kind}`,
545
+ `Summary: ${proposal.summary}`,
546
+ `Current state: ${proposal.currentStateSummary}`,
547
+ `After apply: ${proposal.afterApplySummary}`,
548
+ `Rationale: ${proposal.rationale}`
549
+ ].join('\n');
550
+ }
551
+
552
+ function buildClaimExplanationPrompt(claim: WikiClaim): string {
553
+ const sources = claim.sources.length > 0 ? claim.sources.map((source) => `${source.label} (${source.slug})`).join(', ') : 'No linked sources.';
554
+ return [
555
+ 'You are explaining a stale or non-current wiki claim for a cautious project maintainer.',
556
+ `Return exactly one sentence under ${maxSynthesizedExplanationLength} characters.`,
557
+ 'Explain why this claim should be reviewed before it is trusted, using only the evidence below.',
558
+ 'Do not mark the claim current and do not propose a write.',
559
+ '',
560
+ `Page: ${claim.pageSlug}`,
561
+ `Status: ${claim.status}`,
562
+ `Claim: ${claim.text}`,
563
+ `Sources: ${sources}`
564
+ ].join('\n');
565
+ }
566
+
567
+ function buildGuidanceDistillationPrompt(guidance: WikiGuidanceFile, content: string): string {
568
+ return [
569
+ 'You are distilling an agent guidance file into concise candidate notes for review.',
570
+ `Return at most three short bullets under ${maxSynthesizedDistillationLength} characters total.`,
571
+ 'Preserve only durable operating guidance and mention if details should stay in linked wiki pages.',
572
+ 'Do not output replacement file content and do not propose an automatic edit.',
573
+ '',
574
+ `Guidance path: ${guidance.path}`,
575
+ `Guidance kind: ${guidance.kind}`,
576
+ `Existing summary: ${guidance.summary}`,
577
+ '',
578
+ 'Guidance content excerpt:',
579
+ truncateForPrompt(content)
580
+ ].join('\n');
581
+ }
582
+
583
+ function normalizeSynthesizedText(value: string, maxLength: number, emptyMessage: string): string {
584
+ const normalized = value.replace(/\s+/g, ' ').trim();
585
+
586
+ if (normalized.length === 0) {
587
+ throw new Error(emptyMessage);
588
+ }
589
+
590
+ if (normalized.length > maxLength) {
591
+ throw new Error(`Synthesis provider returned ${normalized.length} characters, which exceeds the ${maxLength} character limit.`);
592
+ }
593
+
594
+ return normalized;
595
+ }
596
+
597
+ function truncateForPrompt(value: string): string {
598
+ const normalized = value.trim();
599
+ if (normalized.length <= maxPromptContentLength) {
600
+ return normalized;
601
+ }
602
+
603
+ return `${normalized.slice(0, maxPromptContentLength)}\n[truncated]`;
604
+ }
605
+
606
+ // =============================================================================
607
+ // PAGE-DRIFT RESOLUTION SYNTHESIS
608
+ // =============================================================================
609
+ //
610
+ // The maintenance review board's drift findings ask the operator to either
611
+ // rewrite a page's first paragraph or snooze the finding. Asking the operator
612
+ // to draft prose from scratch is hostile UX — they don't know what the page
613
+ // currently says, what recent activity has been about, or what new wording
614
+ // would close the vocabulary gap. This synthesizer flips the workflow:
615
+ // the system gathers the evidence, asks the configured AI provider for a
616
+ // proposed replacement, and the operator just approves / regenerates / snoozes.
617
+ //
618
+ // The synthesizer also recognizes a "this is session noise, recommend snooze"
619
+ // outcome — if the AI examines the evidence and concludes the drift signal
620
+ // shouldn't be acted on, it returns a snooze recommendation with reasoning
621
+ // instead of a replacement paragraph.
622
+
623
+ import { extractPageIntent, extractRecentEntriesMentioningPage } from './page-drift.js';
624
+ import { pagePathFromSlug } from './store.js';
625
+
626
+ const maxSynthesizedFirstParagraphLength = 800;
627
+ const maxRecentActivityEntriesShown = 6;
628
+
629
+ export type WikiDriftResolutionOutcome = 'replacement' | 'snooze-recommended' | 'unavailable';
630
+
631
+ export interface WikiDriftResolutionEvidence {
632
+ slug: string;
633
+ currentIntent: string;
634
+ recentActivityEntries: string[];
635
+ matchedDistinctDays: number;
636
+ }
637
+
638
+ export interface WikiDriftResolutionSuggestion {
639
+ outcome: WikiDriftResolutionOutcome;
640
+ /** Generated replacement first-paragraph text (only set when outcome === 'replacement' and provider returned text). */
641
+ text?: string;
642
+ /** Handoff prompt for agent provider — operator copies this into their connected AI. */
643
+ handoffPrompt?: string;
644
+ /** AI's stated reasoning for either the replacement or the snooze recommendation. */
645
+ reasoning?: string;
646
+ /** Why a suggestion couldn't be generated (when outcome === 'unavailable'). */
647
+ failureReason?: string;
648
+ status: WikiSynthesisItemStatus;
649
+ }
650
+
651
+ export interface WikiDriftResolutionResult {
652
+ provider: WikiSynthesisProviderInfo;
653
+ evidence: WikiDriftResolutionEvidence;
654
+ suggestion: WikiDriftResolutionSuggestion;
655
+ }
656
+
657
+ export interface SynthesizeWikiDriftResolutionOptions extends ResolveWikiSynthesisProviderOptions {
658
+ fetcher?: typeof fetch;
659
+ /** Convenience shortcut: when set, forces requestedKind='ollama' and uses this model. */
660
+ ollamaModel?: string;
661
+ }
662
+
663
+ export async function synthesizeWikiDriftResolution(
664
+ slug: string,
665
+ options: SynthesizeWikiDriftResolutionOptions = {}
666
+ ): Promise<WikiDriftResolutionResult> {
667
+ // The review board's model picker passes ollamaModel as a UX-friendly shortcut.
668
+ // It implies requestedKind='ollama' (the picker only makes sense for ollama).
669
+ const resolverOptions: ResolveWikiSynthesisProviderOptions = options.ollamaModel?.trim()
670
+ ? {
671
+ ...options,
672
+ requestedKind: 'ollama',
673
+ requestedOllamaModel: options.ollamaModel
674
+ }
675
+ : options;
676
+ const provider = resolveWikiSynthesisProvider(resolverOptions);
677
+ const evidence = await gatherDriftEvidence(slug);
678
+
679
+ // If we couldn't even gather the evidence (page missing, no activity), return early
680
+ // with a snooze recommendation — there's nothing for the AI to chew on.
681
+ if (!evidence.currentIntent) {
682
+ return {
683
+ provider,
684
+ evidence,
685
+ suggestion: {
686
+ outcome: 'unavailable',
687
+ status: 'failed',
688
+ failureReason: `Could not read page intent for ${slug}.`
689
+ }
690
+ };
691
+ }
692
+ if (evidence.recentActivityEntries.length === 0) {
693
+ return {
694
+ provider,
695
+ evidence,
696
+ suggestion: {
697
+ outcome: 'snooze-recommended',
698
+ status: 'generated',
699
+ reasoning: 'No recent project-log activity mentions this page right now. The drift signal has nothing to compare against — snoozing is safer than guessing at a rewrite.'
700
+ }
701
+ };
702
+ }
703
+
704
+ const prompt = buildDriftResolutionPrompt(evidence);
705
+ const result = await synthesizeText(prompt, provider, {
706
+ fetcher: options.fetcher,
707
+ maxLength: maxSynthesizedFirstParagraphLength,
708
+ emptyMessage: 'Synthesis provider returned no text for the drift resolution.'
709
+ });
710
+
711
+ if (result.status === 'handoff') {
712
+ return {
713
+ provider,
714
+ evidence,
715
+ suggestion: {
716
+ outcome: 'replacement',
717
+ status: 'handoff',
718
+ handoffPrompt: result.handoffPrompt
719
+ }
720
+ };
721
+ }
722
+
723
+ if (result.status === 'generated' && result.text) {
724
+ const parsed = parseDriftResolutionResponse(result.text);
725
+ return {
726
+ provider,
727
+ evidence,
728
+ suggestion: { ...parsed, status: 'generated' }
729
+ };
730
+ }
731
+
732
+ return {
733
+ provider,
734
+ evidence,
735
+ suggestion: {
736
+ outcome: 'unavailable',
737
+ status: result.status,
738
+ failureReason: result.failureReason
739
+ }
740
+ };
741
+ }
742
+
743
+ async function gatherDriftEvidence(slug: string): Promise<WikiDriftResolutionEvidence> {
744
+ const pageContent = await fs.readFile(pagePathFromSlug(slug), 'utf8').catch(() => '');
745
+ const projectLog = await fs.readFile(pagePathFromSlug('project-log'), 'utf8').catch(() => '');
746
+ const intent = pageContent ? extractPageIntent(pageContent) : '';
747
+ const match = projectLog
748
+ ? extractRecentEntriesMentioningPage(projectLog, slug, maxRecentActivityEntriesShown, 7)
749
+ : { entries: [], distinctDays: 0 };
750
+ return {
751
+ slug,
752
+ currentIntent: intent,
753
+ recentActivityEntries: match.entries,
754
+ matchedDistinctDays: match.distinctDays
755
+ };
756
+ }
757
+
758
+ function buildDriftResolutionPrompt(evidence: WikiDriftResolutionEvidence): string {
759
+ const activityBlock = evidence.recentActivityEntries
760
+ .map((entry, idx) => `${idx + 1}. ${entry}`)
761
+ .join('\n');
762
+
763
+ return [
764
+ `You are helping resolve a "page drift" finding on a project wiki page (slug: ${evidence.slug}).`,
765
+ '',
766
+ 'Page drift fires when the page\'s first paragraph (its stated intent) does not share much vocabulary with recent project-log entries that mention the page. The hypothesis is that the page may have outgrown its summary.',
767
+ '',
768
+ 'CURRENT FIRST PARAGRAPH (the page\'s stated intent — title + first paragraph):',
769
+ `"""${truncateForPrompt(evidence.currentIntent)}"""`,
770
+ '',
771
+ `RECENT PROJECT-LOG ENTRIES MENTIONING THIS PAGE (last 7 days, ${evidence.matchedDistinctDays} distinct day${evidence.matchedDistinctDays === 1 ? '' : 's'}):`,
772
+ activityBlock,
773
+ '',
774
+ 'Decide ONE of two outcomes:',
775
+ '',
776
+ '1. The activity reflects a real shift in what the page is about. Generate a replacement first paragraph (1-3 sentences, plain prose, no markdown headings) that better describes what the page is now actually about. The replacement should keep the same level of abstraction as the current intent — it summarizes the page, it does not list every recent change.',
777
+ '',
778
+ '2. The activity is just session noise (a temporary burst of unrelated work, or implementation detail that doesn\'t belong in the page summary). Recommend snooze instead.',
779
+ '',
780
+ 'Respond in EXACTLY one of these two formats and nothing else:',
781
+ '',
782
+ 'REPLACEMENT: <one to three sentence replacement first paragraph>',
783
+ 'REASONING: <one sentence explaining why this rewrite captures the page better>',
784
+ '',
785
+ 'OR',
786
+ '',
787
+ 'SNOOZE: <one sentence reason — what makes this look like noise rather than real drift>'
788
+ ].join('\n');
789
+ }
790
+
791
+ // =============================================================================
792
+ // OLLAMA MODEL LISTING
793
+ // =============================================================================
794
+ //
795
+ // The review board's model picker calls this to populate its dropdown without
796
+ // requiring the operator to set OLLAMA_MODEL ahead of time. It hits Ollama's
797
+ // /api/tags endpoint, which returns the list of locally-installed models.
798
+
799
+ export interface OllamaModelsResult {
800
+ endpoint: string;
801
+ status: 'ok' | 'unreachable' | 'error';
802
+ models: Array<{
803
+ name: string;
804
+ /** Model size in bytes if Ollama reports it. */
805
+ size?: number;
806
+ /** Last-modified timestamp from Ollama (ISO string). */
807
+ modifiedAt?: string;
808
+ /** Family/parameter info Ollama provides (e.g. 'llama', '8B'). */
809
+ details?: { family?: string; parameterSize?: string };
810
+ }>;
811
+ failureReason?: string;
812
+ }
813
+
814
+ export interface ListOllamaModelsOptions {
815
+ env?: NodeJS.ProcessEnv;
816
+ fetcher?: typeof fetch;
817
+ timeoutMs?: number;
818
+ }
819
+
820
+ export async function listOllamaModels(options: ListOllamaModelsOptions = {}): Promise<OllamaModelsResult> {
821
+ const env = options.env ?? process.env;
822
+ const endpoint = env.OLLAMA_URL?.trim() || defaultOllamaUrl;
823
+ const fetcher = options.fetcher ?? fetch;
824
+ const timeoutMs = options.timeoutMs ?? parseTimeoutMs(env.DENDRITE_WIKI_SYNTHESIS_TIMEOUT_MS);
825
+
826
+ const controller = new AbortController();
827
+ const timeoutHandle = setTimeout(() => controller.abort(), Math.min(timeoutMs, 5_000));
828
+
829
+ try {
830
+ const response = await fetcher(new URL('/api/tags', endpoint), {
831
+ signal: controller.signal
832
+ });
833
+ if (!response.ok) {
834
+ return {
835
+ endpoint,
836
+ status: 'error',
837
+ models: [],
838
+ failureReason: `Ollama returned HTTP ${response.status}`
839
+ };
840
+ }
841
+ const payload = (await response.json()) as { models?: unknown };
842
+ const rawModels = Array.isArray(payload.models) ? payload.models : [];
843
+ const models = rawModels.flatMap((entry): OllamaModelsResult['models'] => {
844
+ if (!entry || typeof entry !== 'object') return [];
845
+ const e = entry as Record<string, unknown>;
846
+ if (typeof e.name !== 'string' || !e.name.trim()) return [];
847
+ const details = (e.details && typeof e.details === 'object') ? e.details as Record<string, unknown> : {};
848
+ return [{
849
+ name: e.name,
850
+ size: typeof e.size === 'number' ? e.size : undefined,
851
+ modifiedAt: typeof e.modified_at === 'string'
852
+ ? e.modified_at
853
+ : typeof e.modifiedAt === 'string' ? e.modifiedAt : undefined,
854
+ details: {
855
+ family: typeof details.family === 'string' ? details.family : undefined,
856
+ parameterSize: typeof details.parameter_size === 'string'
857
+ ? details.parameter_size
858
+ : typeof details.parameterSize === 'string' ? details.parameterSize : undefined
859
+ }
860
+ }];
861
+ });
862
+ // Sort alphabetical for stable UX in the picker.
863
+ models.sort((left, right) => left.name.localeCompare(right.name));
864
+ return { endpoint, status: 'ok', models };
865
+ } catch (error) {
866
+ const failureReason = error instanceof Error
867
+ ? (error.name === 'AbortError' ? `Ollama did not respond within ${Math.min(timeoutMs, 5_000)}ms — is the server running on ${endpoint}?` : error.message)
868
+ : String(error);
869
+ return {
870
+ endpoint,
871
+ status: 'unreachable',
872
+ models: [],
873
+ failureReason
874
+ };
875
+ } finally {
876
+ clearTimeout(timeoutHandle);
877
+ }
878
+ }
879
+
880
+ function parseDriftResolutionResponse(text: string): {
881
+ outcome: WikiDriftResolutionOutcome;
882
+ text?: string;
883
+ reasoning?: string;
884
+ } {
885
+ const normalized = text.replace(/\r\n/g, '\n').trim();
886
+
887
+ // Snooze recommendation
888
+ const snoozeMatch = normalized.match(/^SNOOZE:\s*(.+?)$/im);
889
+ if (snoozeMatch) {
890
+ return {
891
+ outcome: 'snooze-recommended',
892
+ reasoning: snoozeMatch[1].trim()
893
+ };
894
+ }
895
+
896
+ // Replacement (with optional REASONING line)
897
+ const replacementMatch = normalized.match(/^REPLACEMENT:\s*([\s\S]+?)(?=\n\s*REASONING:|$)/im);
898
+ if (replacementMatch) {
899
+ const replacementText = replacementMatch[1].trim();
900
+ const reasoningMatch = normalized.match(/^REASONING:\s*(.+?)$/im);
901
+ return {
902
+ outcome: 'replacement',
903
+ text: replacementText,
904
+ reasoning: reasoningMatch?.[1].trim()
905
+ };
906
+ }
907
+
908
+ // Provider didn't follow the format — treat the whole response as a candidate
909
+ // replacement so the operator can still see it. They can edit before applying.
910
+ return {
911
+ outcome: 'replacement',
912
+ text: normalized,
913
+ reasoning: 'Provider did not follow the structured format; using full response as the candidate replacement.'
914
+ };
915
+ }
916
+
917
+ // ----------------------------------------------------------------------------
918
+ // Chart synthesis (M4 of the AI-mermaid-charts roadmap)
919
+ // ----------------------------------------------------------------------------
920
+ // Powers `POST /__review-bridge/synthesize/chart` (the operator-side modal in
921
+ // M5). Builds a prompt from the per-kind template + the operator's context,
922
+ // dispatches via the same `synthesizeText` path the other synthesis flows
923
+ // use (so cloud / ollama / agent fallbacks all behave identically), and
924
+ // strips fences/preamble from the response before returning. The validation
925
+ // step happens downstream when the operator clicks Insert and chart-insert.ts
926
+ // runs the heuristic validator — keeping concerns separate.
927
+
928
+ export interface SynthesizeWikiChartOptions extends ResolveWikiSynthesisProviderOptions {
929
+ fetcher?: typeof fetch;
930
+ /** Convenience shortcut: when set, forces requestedKind='ollama' and uses this model. */
931
+ ollamaModel?: string;
932
+ }
933
+
934
+ export interface SynthesizeWikiChartInput {
935
+ /** Diagram type to produce. The template + first-word constraint differ per kind. */
936
+ chartKind: ChartPromptKind;
937
+ /** Source content the diagram should illustrate (typically the surrounding section). */
938
+ context: string;
939
+ /** Optional one-line "what should this diagram show" hint. */
940
+ intent?: string;
941
+ }
942
+
943
+ export interface WikiChartSynthesisResult {
944
+ provider: WikiSynthesisProviderInfo;
945
+ status: WikiSynthesisItemStatus;
946
+ /** The cleaned Mermaid source (fences and preamble stripped). Present on status='generated'. */
947
+ mermaidSource?: string;
948
+ /** Raw model response, kept for debugging when the parsed result looks suspicious. */
949
+ rawResponse?: string;
950
+ /** When the operator's provider is 'agent', the prompt the operator should paste into a frontier model. */
951
+ handoffPrompt?: string;
952
+ failureReason?: string;
953
+ /** Wall-clock duration of the model call in ms (only set on cloud/ollama paths). */
954
+ durationMs?: number;
955
+ }
956
+
957
+ const CHART_SYNTHESIS_MAX_LENGTH = 4_096;
958
+
959
+ export async function synthesizeWikiChart(
960
+ input: SynthesizeWikiChartInput,
961
+ options: SynthesizeWikiChartOptions = {}
962
+ ): Promise<WikiChartSynthesisResult> {
963
+ const resolverOptions: ResolveWikiSynthesisProviderOptions = options.ollamaModel?.trim()
964
+ ? { ...options, requestedKind: 'ollama', requestedOllamaModel: options.ollamaModel }
965
+ : options;
966
+ const provider = resolveWikiSynthesisProvider(resolverOptions);
967
+ const prompt = buildChartPrompt({ kind: input.chartKind, context: input.context, intent: input.intent });
968
+
969
+ const startedAt = Date.now();
970
+ const result = await synthesizeText(prompt, provider, {
971
+ fetcher: options.fetcher,
972
+ maxLength: CHART_SYNTHESIS_MAX_LENGTH,
973
+ emptyMessage: 'Synthesis provider returned no text for the chart request.'
974
+ });
975
+ const durationMs = Date.now() - startedAt;
976
+
977
+ if (result.status === 'handoff') {
978
+ return { provider, status: 'handoff', handoffPrompt: result.handoffPrompt, durationMs };
979
+ }
980
+ if (result.status === 'generated' && result.text) {
981
+ const mermaidSource = parseChartResponse(result.text);
982
+ return { provider, status: 'generated', mermaidSource, rawResponse: result.text, durationMs };
983
+ }
984
+ return { provider, status: result.status, failureReason: result.failureReason, durationMs };
985
+ }
986
+
987
+ // =============================================================================
988
+ // MEMORY AUTO-CLEAN SYNTHESIS
989
+ // =============================================================================
990
+ //
991
+ // Given a list of project-local memory candidates, ask the configured LLM to emit
992
+ // one decision per candidate: `archive` (junk, low signal, no future value) or
993
+ // `keep-and-watch` (still has signal — let it incubate). The output is a JSON
994
+ // array; this module parses it and hands it to memory_auto_clean_apply.
995
+ //
996
+ // This is the synthesis half of the Review Board's "Auto-clean" button. The bridge
997
+ // route calls this, then forwards the decisions to applyAutoCleanDecisions().
998
+
999
+ export interface MemoryAutoCleanCandidate {
1000
+ memoryId: string;
1001
+ kind: string;
1002
+ text: string;
1003
+ recallCount: number;
1004
+ ageInDays: number;
1005
+ lastRecalledAt: string;
1006
+ sources: number;
1007
+ reviewFindingKind: string;
1008
+ }
1009
+
1010
+ export interface MemoryAutoCleanDecision {
1011
+ memoryId: string;
1012
+ verb: 'archive' | 'keep-and-watch';
1013
+ reason: string;
1014
+ confidence: number;
1015
+ }
1016
+
1017
+ export type MemoryAutoCleanSynthesisStatus = 'generated' | 'handoff' | 'parse-failed' | 'disabled' | 'unavailable' | 'failed';
1018
+
1019
+ export interface MemoryAutoCleanSynthesisResult {
1020
+ provider: WikiSynthesisProviderInfo;
1021
+ status: MemoryAutoCleanSynthesisStatus;
1022
+ decisions?: MemoryAutoCleanDecision[];
1023
+ handoffPrompt?: string;
1024
+ rawResponse?: string;
1025
+ failureReason?: string;
1026
+ }
1027
+
1028
+ export interface SynthesizeMemoryAutoCleanDecisionsOptions extends ResolveWikiSynthesisProviderOptions {
1029
+ ollamaModel?: string;
1030
+ fetcher?: typeof fetch;
1031
+ }
1032
+
1033
+ const memoryAutoCleanResponseMaxLength = 32_000;
1034
+
1035
+ export async function synthesizeMemoryAutoCleanDecisions(
1036
+ candidates: MemoryAutoCleanCandidate[],
1037
+ options: SynthesizeMemoryAutoCleanDecisionsOptions = {}
1038
+ ): Promise<MemoryAutoCleanSynthesisResult> {
1039
+ const resolverOptions: ResolveWikiSynthesisProviderOptions = options.ollamaModel?.trim()
1040
+ ? { ...options, requestedKind: 'ollama', requestedOllamaModel: options.ollamaModel }
1041
+ : options;
1042
+ const provider = resolveWikiSynthesisProvider(resolverOptions);
1043
+
1044
+ if (candidates.length === 0) {
1045
+ return { provider, status: 'generated', decisions: [] };
1046
+ }
1047
+
1048
+ const prompt = buildMemoryAutoCleanPrompt(candidates);
1049
+
1050
+ if (provider.status === 'disabled') {
1051
+ return { provider, status: 'disabled', failureReason: provider.reason };
1052
+ }
1053
+ if (provider.status !== 'ready') {
1054
+ return { provider, status: 'unavailable', failureReason: provider.reason };
1055
+ }
1056
+ if (provider.kind === 'agent') {
1057
+ return { provider, status: 'handoff', handoffPrompt: prompt };
1058
+ }
1059
+
1060
+ let rawResponse: string;
1061
+ try {
1062
+ rawResponse =
1063
+ provider.kind === 'cloud'
1064
+ ? await requestCloudMemoryAutoCleanResponse(prompt, provider, options.fetcher ?? fetch)
1065
+ : await requestOllamaMemoryAutoCleanResponse(prompt, provider, options.fetcher ?? fetch);
1066
+ } catch (error) {
1067
+ return {
1068
+ provider,
1069
+ status: 'failed',
1070
+ failureReason: error instanceof Error ? error.message : String(error)
1071
+ };
1072
+ }
1073
+
1074
+ const parsed = parseMemoryAutoCleanResponse(rawResponse, candidates);
1075
+ if (!parsed.ok) {
1076
+ return { provider, status: 'parse-failed', rawResponse, failureReason: parsed.failureReason };
1077
+ }
1078
+ return { provider, status: 'generated', decisions: parsed.decisions, rawResponse };
1079
+ }
1080
+
1081
+ function buildMemoryAutoCleanPrompt(candidates: MemoryAutoCleanCandidate[]): string {
1082
+ const lines: string[] = [
1083
+ 'You are an expert memory archivist for an AI coding agent\'s project-local memory store.',
1084
+ 'For each candidate memory below, decide one of two verbs:',
1085
+ ' - "archive": junk, vague, restates the obvious, no actionable content, or a weaker duplicate of another memory.',
1086
+ ' - "keep-and-watch": concrete lessons, specific facts, decisions with context — still has signal. When uncertain, prefer this.',
1087
+ '',
1088
+ 'Output format:',
1089
+ ' Return a JSON object with exactly this shape:',
1090
+ ' { "decisions": [ { "memoryId": "...", "verb": "...", "reason": "...", "confidence": 0.0 }, ... ] }',
1091
+ ' Field rules per decision:',
1092
+ ' - memoryId: string, exactly the id given in the input (e.g. "mem_abc-123").',
1093
+ ' - verb: either "archive" or "keep-and-watch" (literal string, no other values).',
1094
+ ' - reason: one short sentence (under 200 chars) explaining the choice.',
1095
+ ' - confidence: a number from 0.0 to 1.0.',
1096
+ ' Cover EVERY candidate memoryId below exactly once. No code fences. No prose outside the JSON.',
1097
+ '',
1098
+ 'Guidelines:',
1099
+ ' - Memories with recallCount > 0 almost always keep-and-watch (recall proves usefulness).',
1100
+ ' - Memories under 14 days old default to keep-and-watch unless the text is clearly junk.',
1101
+ ' - High-confidence (>=0.8) for clearly junk or clearly valuable. Lower for genuinely uncertain.',
1102
+ '',
1103
+ 'Candidates:'
1104
+ ];
1105
+
1106
+ candidates.forEach((candidate, index) => {
1107
+ const text = candidate.text.length > 600 ? `${candidate.text.slice(0, 597)}...` : candidate.text;
1108
+ const lastRecalled = candidate.lastRecalledAt ? `, last recalled ${candidate.lastRecalledAt.slice(0, 10)}` : ', never recalled';
1109
+ lines.push(
1110
+ '',
1111
+ `[${index + 1}] memoryId: ${candidate.memoryId}`,
1112
+ ` kind: ${candidate.kind}, finding: ${candidate.reviewFindingKind}, age: ${candidate.ageInDays}d, recallCount: ${candidate.recallCount}${lastRecalled}, sources: ${candidate.sources}`,
1113
+ ` text: ${text.replace(/\n+/g, ' ').trim()}`
1114
+ );
1115
+ });
1116
+
1117
+ lines.push('', 'Return the JSON array now.');
1118
+ return lines.join('\n');
1119
+ }
1120
+
1121
+ async function requestOllamaMemoryAutoCleanResponse(
1122
+ prompt: string,
1123
+ provider: WikiSynthesisProviderInfo,
1124
+ fetcher: typeof fetch
1125
+ ): Promise<string> {
1126
+ const controller = new AbortController();
1127
+ const timeoutHandle = setTimeout(() => controller.abort(), provider.timeoutMs);
1128
+
1129
+ try {
1130
+ const response = await fetcher(new URL('/api/generate', provider.endpoint ?? defaultOllamaUrl), {
1131
+ method: 'POST',
1132
+ headers: { 'content-type': 'application/json' },
1133
+ body: JSON.stringify({
1134
+ model: provider.model,
1135
+ stream: false,
1136
+ prompt,
1137
+ format: 'json',
1138
+ // Keep the model resident across batches. Without this, Ollama unloads after each
1139
+ // request and the next batch pays a cold-load penalty (often 30-60s on slow boxes),
1140
+ // which compounds painfully across N batches. 15 minutes covers the full auto-clean
1141
+ // sweep with margin for slow generation.
1142
+ keep_alive: '15m'
1143
+ }),
1144
+ signal: controller.signal
1145
+ });
1146
+
1147
+ if (!response.ok) {
1148
+ throw new Error(`Ollama request failed with status ${response.status}.`);
1149
+ }
1150
+
1151
+ const payload = (await response.json()) as { response?: unknown };
1152
+ const raw = typeof payload.response === 'string' ? payload.response : '';
1153
+ if (raw.length === 0) {
1154
+ throw new Error('Ollama returned an empty response.');
1155
+ }
1156
+ if (raw.length > memoryAutoCleanResponseMaxLength) {
1157
+ throw new Error(`Ollama returned ${raw.length} characters, which exceeds the ${memoryAutoCleanResponseMaxLength} character limit for auto-clean decisions.`);
1158
+ }
1159
+ return raw;
1160
+ } catch (error) {
1161
+ if (error instanceof Error && error.name === 'AbortError') {
1162
+ throw new Error(`Ollama auto-clean synthesis timed out after ${provider.timeoutMs}ms.`);
1163
+ }
1164
+ throw error;
1165
+ } finally {
1166
+ clearTimeout(timeoutHandle);
1167
+ }
1168
+ }
1169
+
1170
+ async function requestCloudMemoryAutoCleanResponse(
1171
+ prompt: string,
1172
+ provider: WikiSynthesisProviderInfo,
1173
+ fetcher: typeof fetch
1174
+ ): Promise<string> {
1175
+ const apiKey = process.env.DENDRITE_WIKI_CLOUD_API_KEY?.trim() ?? '';
1176
+ const controller = new AbortController();
1177
+ const timeoutHandle = setTimeout(() => controller.abort(), provider.timeoutMs);
1178
+
1179
+ try {
1180
+ const response = await fetcher(provider.endpoint ?? '', {
1181
+ method: 'POST',
1182
+ headers: {
1183
+ authorization: `Bearer ${apiKey}`,
1184
+ 'content-type': 'application/json'
1185
+ },
1186
+ body: JSON.stringify({
1187
+ model: provider.model,
1188
+ messages: [
1189
+ { role: 'system', content: 'You produce strict JSON output for an AI memory archivist task. No prose. No fences.' },
1190
+ { role: 'user', content: prompt }
1191
+ ],
1192
+ temperature: 0
1193
+ }),
1194
+ signal: controller.signal
1195
+ });
1196
+ if (!response.ok) {
1197
+ throw new Error(`Cloud auto-clean request failed with status ${response.status}.`);
1198
+ }
1199
+ const payload = (await response.json()) as { choices?: Array<{ message?: { content?: unknown } }>; output_text?: unknown };
1200
+ const content =
1201
+ typeof payload.output_text === 'string'
1202
+ ? payload.output_text
1203
+ : typeof payload.choices?.[0]?.message?.content === 'string'
1204
+ ? payload.choices[0].message.content
1205
+ : '';
1206
+ if (!content) {
1207
+ throw new Error('Cloud provider returned an empty response.');
1208
+ }
1209
+ if (content.length > memoryAutoCleanResponseMaxLength) {
1210
+ throw new Error(`Cloud provider returned ${content.length} characters, which exceeds the ${memoryAutoCleanResponseMaxLength} character limit.`);
1211
+ }
1212
+ return content;
1213
+ } catch (error) {
1214
+ if (error instanceof Error && error.name === 'AbortError') {
1215
+ throw new Error(`Cloud auto-clean synthesis timed out after ${provider.timeoutMs}ms.`);
1216
+ }
1217
+ throw error;
1218
+ } finally {
1219
+ clearTimeout(timeoutHandle);
1220
+ }
1221
+ }
1222
+
1223
+ function parseMemoryAutoCleanResponse(
1224
+ text: string,
1225
+ candidates: MemoryAutoCleanCandidate[]
1226
+ ): { ok: true; decisions: MemoryAutoCleanDecision[] } | { ok: false; failureReason: string } {
1227
+ const candidateIds = new Set(candidates.map((candidate) => candidate.memoryId));
1228
+
1229
+ let parsed: unknown;
1230
+ try {
1231
+ parsed = JSON.parse(stripJsonWrapping(text));
1232
+ } catch (error) {
1233
+ return { ok: false, failureReason: `Response was not valid JSON: ${error instanceof Error ? error.message : String(error)}` };
1234
+ }
1235
+
1236
+ // Walk the tree and collect anything that looks like a decision. Handles all the shapes
1237
+ // local models tend to emit under `format: 'json'`:
1238
+ // - bare array: [ {...}, {...} ]
1239
+ // - wrapped: { decisions: [...] }, { results: [...] }, { memories: [...] }
1240
+ // - keyed map: { mem_abc: { verb, reason }, mem_def: { verb, reason } }
1241
+ // - nested: { data: { decisions: [...] } }
1242
+ const collected = collectDecisionLikeObjects(parsed);
1243
+
1244
+ const decisions: MemoryAutoCleanDecision[] = [];
1245
+ const seenIds = new Set<string>();
1246
+ for (const entry of collected) {
1247
+ const memoryId = typeof entry.memoryId === 'string' ? entry.memoryId : '';
1248
+ const verbRaw = typeof entry.verb === 'string' ? entry.verb : '';
1249
+ const reason = typeof entry.reason === 'string' ? entry.reason : '';
1250
+ const confidenceRaw = typeof entry.confidence === 'number' ? entry.confidence : Number.NaN;
1251
+
1252
+ if (!memoryId || !candidateIds.has(memoryId)) continue;
1253
+ if (seenIds.has(memoryId)) continue;
1254
+ if (verbRaw !== 'archive' && verbRaw !== 'keep-and-watch') continue;
1255
+ if (!reason) continue;
1256
+
1257
+ const confidence = Number.isFinite(confidenceRaw) ? Math.max(0, Math.min(1, confidenceRaw)) : 0.5;
1258
+ decisions.push({ memoryId, verb: verbRaw, reason, confidence });
1259
+ seenIds.add(memoryId);
1260
+ }
1261
+
1262
+ if (decisions.length === 0) {
1263
+ return { ok: false, failureReason: 'No decisions in the response matched the candidate IDs.' };
1264
+ }
1265
+
1266
+ return { ok: true, decisions };
1267
+ }
1268
+
1269
+ // Walk a parsed JSON value and collect every object that looks like an auto-clean
1270
+ // decision (has both `memoryId` and `verb`). Handles arbitrary nesting / wrapping so
1271
+ // it tolerates whatever shape the local model decides to emit under `format: 'json'`.
1272
+ // Also handles the "keyed map" shape where decisions are object keys: `{mem_abc: {verb, reason}}`.
1273
+ function collectDecisionLikeObjects(value: unknown): Array<Record<string, unknown>> {
1274
+ if (!value) return [];
1275
+ if (Array.isArray(value)) {
1276
+ return value.flatMap((item) => collectDecisionLikeObjects(item));
1277
+ }
1278
+ if (typeof value === 'object') {
1279
+ const obj = value as Record<string, unknown>;
1280
+ if (typeof obj.memoryId === 'string' && typeof obj.verb === 'string') {
1281
+ return [obj];
1282
+ }
1283
+ // Keyed-map fallback: if every key looks like a memory id (`mem_*`) and every value
1284
+ // is a verb-bearing object, treat the keys as memoryIds and the values as decisions.
1285
+ const entries = Object.entries(obj);
1286
+ if (entries.length > 0 && entries.every(([key, val]) =>
1287
+ key.startsWith('mem_') &&
1288
+ val !== null && typeof val === 'object' &&
1289
+ typeof (val as Record<string, unknown>).verb === 'string'
1290
+ )) {
1291
+ return entries.map(([key, val]) => ({ memoryId: key, ...(val as Record<string, unknown>) }));
1292
+ }
1293
+ return entries.flatMap(([, val]) => collectDecisionLikeObjects(val));
1294
+ }
1295
+ return [];
1296
+ }
1297
+
1298
+ function stripJsonWrapping(text: string): string {
1299
+ // Be lenient: some local models wrap JSON in code fences or add a prose preamble.
1300
+ // Strip the most common offenders and trim. The parser is tolerant about object vs
1301
+ // array roots, so we don't need to surgically extract a [...] block anymore.
1302
+ return text
1303
+ .replace(/^[^{[]*([{[])/, '$1') // drop any prose preamble before the first { or [
1304
+ .replace(/^```(?:json)?\s*/i, '')
1305
+ .replace(/\s*```\s*$/i, '')
1306
+ .trim();
1307
+ }