@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,417 @@
1
+ /**
2
+ * Per-chart-kind prompt templates for Mermaid diagram synthesis.
3
+ *
4
+ * M4 of the AI-mermaid-charts roadmap. The operator-side modal in M5 will
5
+ * call `POST /__review-bridge/synthesize/chart`, which calls a local
6
+ * Ollama model with a prompt built from these templates plus the
7
+ * surrounding page content. The model returns Mermaid source which the
8
+ * heuristic validator in `chart-insert.ts` accepts or rejects.
9
+ *
10
+ * Design notes:
11
+ * - Each template is small. Local models (especially 3B–8B) have short
12
+ * effective context windows in practice; long preambles hurt output
13
+ * quality and increase latency. Each prompt is ~200 tokens of
14
+ * instructions + the page content.
15
+ * - Each template includes ONE concrete example of valid Mermaid for
16
+ * that kind. Few-shot prompting works well for diagram syntax.
17
+ * - Each template ends with an explicit "Output ONLY the Mermaid
18
+ * source. Do not wrap in code fences. Do not explain." instruction.
19
+ * We still tolerate fences in the response (the parser strips them)
20
+ * but telling the model not to produce them gives cleaner output.
21
+ * - The "context" provided to the model is intentionally just the
22
+ * section the operator's cursor is in, not the whole page. Local
23
+ * models do better with focused input — the modal in M5 enforces
24
+ * this by default.
25
+ */
26
+
27
+ export type ChartPromptKind = 'flowchart' | 'sequence' | 'state' | 'class' | 'er' | 'gantt';
28
+
29
+ export interface ChartPromptInput {
30
+ kind: ChartPromptKind;
31
+ /**
32
+ * The text the diagram should illustrate. Typically the section the
33
+ * operator's cursor is in, but the modal also lets the operator pass
34
+ * a free-form prompt instead.
35
+ */
36
+ context: string;
37
+ /**
38
+ * Optional one-line caption / title the diagram should illustrate.
39
+ * Helps the model focus when the surrounding context is broad.
40
+ */
41
+ intent?: string;
42
+ }
43
+
44
+ export function buildChartPrompt(input: ChartPromptInput): string {
45
+ const template = TEMPLATES[input.kind];
46
+ const intentLine = input.intent?.trim()
47
+ ? `What the diagram should illustrate: ${input.intent.trim()}\n\n`
48
+ : '';
49
+ return `${template.instructions}
50
+
51
+ ${intentLine}Source content to draw from:
52
+ """
53
+ ${input.context.trim()}
54
+ """
55
+
56
+ Output ONLY the Mermaid source. Do not wrap in code fences. Do not explain. Begin with "${template.firstWord}".`;
57
+ }
58
+
59
+ interface PromptTemplate {
60
+ instructions: string;
61
+ /** The first word the model should emit — lets us tell it where to start. */
62
+ firstWord: string;
63
+ }
64
+
65
+ // Each template's `instructions` field is a short paragraph + one valid
66
+ // example. The example is what makes the difference between "model knows
67
+ // the syntax abstractly" and "model produces correct output." Keep
68
+ // examples minimal — 4-6 nodes/edges max — so the model focuses on
69
+ // SHAPE rather than imitating example content.
70
+ const TEMPLATES: Record<ChartPromptKind, PromptTemplate> = {
71
+ flowchart: {
72
+ firstWord: 'flowchart',
73
+ instructions: `You produce Mermaid flowchart diagrams. A flowchart shows steps and decisions in a process. Use the source content below to identify the steps and the order they happen in. Use [rectangles] for steps and {curly braces} for yes/no decisions. Label arrows with the condition or action that triggers them.
74
+
75
+ CRITICAL FORMATTING RULES:
76
+ - Put the header ("flowchart TD" or "flowchart LR") on its OWN LINE.
77
+ - Put EACH node and EACH edge on its OWN LINE.
78
+ - Do NOT use semicolons (;) to separate statements. Mermaid requires NEWLINES.
79
+ - Indent each statement two spaces under the header.
80
+
81
+ Example of valid Mermaid flowchart syntax:
82
+
83
+ flowchart TD
84
+ A[Read request] --> B{Cache hit?}
85
+ B -->|yes| C[Return cached]
86
+ B -->|no| D[Fetch from DB]
87
+ D --> E[Update cache]
88
+ E --> C`
89
+ },
90
+ sequence: {
91
+ firstWord: 'sequenceDiagram',
92
+ instructions: `You produce Mermaid sequence diagrams. A sequence diagram shows messages flowing between participants over time, top to bottom. Use the source content below to identify the actors and the messages they send. Use --> for synchronous calls, -->> for responses, and -.- for asynchronous notifications.
93
+
94
+ Example of valid Mermaid sequenceDiagram syntax:
95
+
96
+ sequenceDiagram
97
+ participant Client
98
+ participant API
99
+ participant DB
100
+ Client->>API: POST /save
101
+ API->>DB: INSERT
102
+ DB-->>API: ok
103
+ API-->>Client: 200 OK`
104
+ },
105
+ state: {
106
+ firstWord: 'stateDiagram-v2',
107
+ instructions: `You produce Mermaid state diagrams. A state diagram shows the lifecycle of a single entity — what states it can be in and what transitions move it between them. Use [*] for the initial and final pseudo-states. Label transitions with the event or condition that triggers them.
108
+
109
+ Example of valid Mermaid stateDiagram-v2 syntax:
110
+
111
+ stateDiagram-v2
112
+ [*] --> Idle
113
+ Idle --> Running : start
114
+ Running --> Paused : pause
115
+ Paused --> Running : resume
116
+ Running --> [*] : finish`
117
+ },
118
+ class: {
119
+ firstWord: 'classDiagram',
120
+ instructions: `You produce Mermaid class diagrams. A class diagram shows the structure of a domain — classes (or types), their fields and methods, and the relationships between them. Use the source content below to identify the entities and their relationships.
121
+
122
+ Example of valid Mermaid classDiagram syntax:
123
+
124
+ classDiagram
125
+ class Order {
126
+ +String id
127
+ +Date createdAt
128
+ +submit()
129
+ }
130
+ class Customer {
131
+ +String email
132
+ +place(Order)
133
+ }
134
+ Customer "1" --> "*" Order : places`
135
+ },
136
+ er: {
137
+ firstWord: 'erDiagram',
138
+ instructions: `You produce Mermaid entity-relationship diagrams. An ER diagram shows database entities (tables/collections), their fields, and the relationships between them. Use the source content below to identify the entities and how they connect.
139
+
140
+ Example of valid Mermaid erDiagram syntax:
141
+
142
+ erDiagram
143
+ USER ||--o{ ORDER : places
144
+ ORDER ||--|{ LINE_ITEM : contains
145
+ USER {
146
+ string id PK
147
+ string email
148
+ }
149
+ ORDER {
150
+ string id PK
151
+ string user_id FK
152
+ date created_at
153
+ }`
154
+ },
155
+ gantt: {
156
+ firstWord: 'gantt',
157
+ instructions: `You produce Mermaid gantt charts. A gantt chart shows tasks scheduled over time. Use the source content below to identify the tasks, their durations, and any dependencies. Group related tasks under "section" headers.
158
+
159
+ Example of valid Mermaid gantt syntax:
160
+
161
+ gantt
162
+ title Project plan
163
+ dateFormat YYYY-MM-DD
164
+ section Design
165
+ Wireframes :a1, 2026-01-01, 5d
166
+ Visual design :a2, after a1, 7d
167
+ section Build
168
+ Backend :b1, 2026-01-08, 14d
169
+ Frontend :b2, after a2, 12d`
170
+ }
171
+ };
172
+
173
+ /**
174
+ * Strip Mermaid code fences from a model response if present. Models
175
+ * sometimes wrap their output in ```mermaid ... ``` despite being told not
176
+ * to; we accept both shapes. Also trims any prose before the diagram-type
177
+ * keyword (e.g., "Here's the diagram:\n\nflowchart TD..." → "flowchart TD...").
178
+ *
179
+ * Final pass: `normalizeMermaidLayout` repairs the common small-model
180
+ * failure mode of producing the entire diagram on a single line with `;`
181
+ * as statement separator (which Mermaid does NOT accept — it requires
182
+ * newlines). Without this, gemma3:4b / phi3:mini / similar sub-8B models
183
+ * regularly produce output the renderer rejects with "Expecting NEWLINE,
184
+ * got NODE_STRING".
185
+ */
186
+ export function parseChartResponse(text: string): string {
187
+ let body = text.trim();
188
+
189
+ // Strip an outer code fence if present.
190
+ const fenceMatch = body.match(/^```(?:mermaid)?\r?\n([\s\S]*?)\r?\n```$/);
191
+ if (fenceMatch) {
192
+ body = fenceMatch[1].trim();
193
+ }
194
+
195
+ // If the model added a preamble, find the first line that looks like a
196
+ // diagram-type keyword and trim everything before it. Same keyword list
197
+ // as chart-insert.ts's validator, kept in sync intentionally.
198
+ const KEYWORDS = [
199
+ 'flowchart', 'graph',
200
+ 'sequenceDiagram',
201
+ 'stateDiagram-v2', 'stateDiagram',
202
+ 'classDiagram',
203
+ 'erDiagram',
204
+ 'gantt', 'pie', 'journey',
205
+ 'gitGraph',
206
+ 'mindmap', 'timeline',
207
+ 'quadrantChart',
208
+ 'xychart-beta', 'sankey-beta',
209
+ 'requirementDiagram'
210
+ ];
211
+ const lines = body.split(/\r?\n/);
212
+ for (let i = 0; i < lines.length; i++) {
213
+ const trimmed = lines[i].trim();
214
+ if (KEYWORDS.some((k) => new RegExp(`^${k.replace('-', '\\-')}\\b`, 'i').test(trimmed))) {
215
+ body = lines.slice(i).join('\n').trim();
216
+ return normalizeMermaidLayout(body);
217
+ }
218
+ }
219
+
220
+ // No keyword found anywhere — return the body as-is (still normalize, in
221
+ // case the keyword was on the same line as the rest) and let the
222
+ // validator reject it with a clear error if the normalize can't help.
223
+ return normalizeMermaidLayout(body);
224
+ }
225
+
226
+ /**
227
+ * Repair the "all on one line, semicolons as separators" failure mode.
228
+ *
229
+ * Mermaid's flowchart/graph/sequence/state/class/er parsers ALL require a
230
+ * newline after the header keyword and between every subsequent statement.
231
+ * Semicolons inside `[label]` brackets are fine; semicolons OUTSIDE
232
+ * brackets that the model is using as statement separators are not.
233
+ *
234
+ * Detection: the source is "compact" (≤2 newlines) AND contains at least
235
+ * one top-level semicolon. When detected, we split on top-level semicolons
236
+ * (skipping ones inside `[...]`, `(...)`, `{...}`, or quotes), trim each
237
+ * part, and emit them as separate indented lines below the header.
238
+ *
239
+ * If the source is already multi-line, this is a no-op — we don't want to
240
+ * accidentally rewrite hand-authored Mermaid that legitimately uses
241
+ * semicolons inside node labels.
242
+ */
243
+ export function normalizeMermaidLayout(source: string): string {
244
+ const trimmed = source.trim();
245
+ const lines = trimmed.split(/\r?\n/);
246
+
247
+ // Match the diagram header. If we can't recognize one, we have no anchor
248
+ // for normalization — leave the source alone and let the validator
249
+ // produce a clear error.
250
+ const headerMatch = trimmed.match(/^(flowchart|graph|sequenceDiagram|stateDiagram(?:-v2)?|classDiagram|erDiagram|gantt|pie|journey|gitGraph|mindmap|timeline|quadrantChart|xychart-beta|sankey-beta|requirementDiagram)\b\s*([A-Z]{1,4})?\s*/i);
251
+ if (!headerMatch) return trimmed;
252
+ const header = headerMatch[0].trim();
253
+ const body = trimmed.slice(headerMatch[0].length).trim();
254
+ if (body.length === 0) return header;
255
+
256
+ // Process the body line by line so a multi-line input where each line
257
+ // already has just one statement passes through unchanged, while a line
258
+ // (possibly the only line) that has MULTIPLE statements glued together
259
+ // gets split into separate statements.
260
+ //
261
+ // The header may have been on its own line OR fused with the first body
262
+ // line. Either way, `body` is now everything after the header, joined as
263
+ // a single string. Re-split it on existing newlines so each pre-existing
264
+ // line is processed for in-line statement gluing independently.
265
+ const bodyLines = body.split(/\r?\n/);
266
+ const statements: string[] = [];
267
+ for (const line of bodyLines) {
268
+ const trimmedLine = line.trim();
269
+ if (!trimmedLine) continue;
270
+ // Each line: split on top-level semicolons first, then split each
271
+ // resulting chunk on adjacent-statement-without-separator boundaries.
272
+ const semiParts = splitOnTopLevelSemicolons(trimmedLine);
273
+ for (const part of semiParts) {
274
+ const trimmedPart = part.trim();
275
+ if (!trimmedPart) continue;
276
+ const adjacencyParts = splitOnAdjacentStatements(trimmedPart);
277
+ for (const stmt of adjacencyParts) {
278
+ const trimmedStmt = stmt.trim();
279
+ if (trimmedStmt) statements.push(trimmedStmt);
280
+ }
281
+ }
282
+ }
283
+
284
+ if (statements.length === 0) return header;
285
+
286
+ // Skip normalization entirely when nothing changed — this preserves
287
+ // exact whitespace for already-well-formed input. We compare against the
288
+ // (header on own line + each body line indented) shape we'd emit.
289
+ const emitted = [header, ...statements.map((s) => ` ${s}`)].join('\n');
290
+ return emitted;
291
+ }
292
+
293
+ /**
294
+ * Split a single body line on adjacent-statement boundaries. The failure
295
+ * mode this catches: a small model emits multiple statements glued together
296
+ * with whitespace instead of newlines.
297
+ *
298
+ * Three patterns trigger a boundary cut, all guarded to the top level
299
+ * (outside brackets, parens, braces, or quoted strings):
300
+ *
301
+ * 1. A closing bracket `]`, `)`, or `}` of a node label, followed by
302
+ * whitespace + identifier + (another bracket OR an arrow OR a pipe).
303
+ * Catches `A[X] --> B[Y] B --> C[Z]` → splits between `B[Y]` and `B`.
304
+ *
305
+ * 2. A bare identifier (no bracket label) that ends one statement,
306
+ * followed by whitespace + another identifier + (bracket or arrow).
307
+ * Catches `G -->|yes| F G -->|no| H[...]` → splits between `F` and
308
+ * the second `G`. Without this, the bare-identifier-end case slips
309
+ * through and Mermaid rejects the whole line.
310
+ *
311
+ * In all cases we advance `i` past JUST the leading whitespace (not past
312
+ * the identifier itself) so the next outer-loop iteration emits the
313
+ * identifier into the new statement.
314
+ */
315
+ function splitOnAdjacentStatements(source: string): string[] {
316
+ const ADJACENT_BOUNDARY_LOOKAHEAD = /^(\s+)[A-Za-z_][A-Za-z0-9_]*\s*(?=[\[\{\(]|--?>|---|-\.\-?>|==>|->>|--?>>|<--|\|)/;
317
+ const statements: string[] = [];
318
+ let current = '';
319
+ let depth = 0;
320
+ let inQuote: '"' | "'" | null = null;
321
+ for (let i = 0; i < source.length; i++) {
322
+ const ch = source[i];
323
+ current += ch;
324
+
325
+ if (inQuote) {
326
+ if (ch === inQuote && source[i - 1] !== '\\') inQuote = null;
327
+ continue;
328
+ }
329
+ if (ch === '"' || ch === "'") { inQuote = ch; continue; }
330
+ if (ch === '[' || ch === '(' || ch === '{') { depth++; continue; }
331
+ if (ch === ']' || ch === ')' || ch === '}') {
332
+ depth--;
333
+ if (depth !== 0) continue;
334
+ const remainder = source.slice(i + 1);
335
+ const m = remainder.match(ADJACENT_BOUNDARY_LOOKAHEAD);
336
+ if (m) {
337
+ statements.push(current);
338
+ current = '';
339
+ i += m[1].length;
340
+ }
341
+ continue;
342
+ }
343
+
344
+ // Bare-identifier-end boundary detection. Only fires at top-level,
345
+ // when the current character is the LAST character of an identifier
346
+ // (next character is whitespace or end-of-source) AND the identifier
347
+ // we just completed sits immediately after an arrow operator (so it
348
+ // was the destination of an edge, not the source of a new one).
349
+ // Without the "after-arrow" guard we'd false-cut on the SOURCE side
350
+ // of every edge (`A --> B` → would split between `A` and `-->`).
351
+ if (depth === 0 && /[A-Za-z0-9_]/.test(ch)) {
352
+ const nextCh = source[i + 1];
353
+ if (nextCh === undefined || /\s/.test(nextCh)) {
354
+ // Did this identifier follow an arrow? Look back through `current`
355
+ // for the last arrow operator before the trailing identifier.
356
+ // Pattern allows for an optional pipe-wrapped edge label between
357
+ // the arrow and the destination identifier (e.g. `--> |yes| F`).
358
+ const beforeIdentifier = /(-->|---|-\.->|==>|->>|<--|<-->)\s*(\|[^|]*\|)?\s*[A-Za-z_][A-Za-z0-9_]*$/;
359
+ if (beforeIdentifier.test(current)) {
360
+ const remainder = source.slice(i + 1);
361
+ const m = remainder.match(ADJACENT_BOUNDARY_LOOKAHEAD);
362
+ if (m) {
363
+ statements.push(current);
364
+ current = '';
365
+ i += m[1].length;
366
+ }
367
+ }
368
+ }
369
+ }
370
+ }
371
+ if (current.length > 0) statements.push(current);
372
+ return statements;
373
+ }
374
+
375
+ function countTopLevelSemicolons(source: string): number {
376
+ let count = 0;
377
+ let depth = 0;
378
+ let inQuote: '"' | "'" | null = null;
379
+ for (let i = 0; i < source.length; i++) {
380
+ const ch = source[i];
381
+ if (inQuote) {
382
+ if (ch === inQuote && source[i - 1] !== '\\') inQuote = null;
383
+ continue;
384
+ }
385
+ if (ch === '"' || ch === "'") { inQuote = ch; continue; }
386
+ if (ch === '[' || ch === '(' || ch === '{') depth++;
387
+ else if (ch === ']' || ch === ')' || ch === '}') depth--;
388
+ else if (ch === ';' && depth === 0) count++;
389
+ }
390
+ return count;
391
+ }
392
+
393
+ function splitOnTopLevelSemicolons(source: string): string[] {
394
+ const parts: string[] = [];
395
+ let buffer = '';
396
+ let depth = 0;
397
+ let inQuote: '"' | "'" | null = null;
398
+ for (let i = 0; i < source.length; i++) {
399
+ const ch = source[i];
400
+ if (inQuote) {
401
+ buffer += ch;
402
+ if (ch === inQuote && source[i - 1] !== '\\') inQuote = null;
403
+ continue;
404
+ }
405
+ if (ch === '"' || ch === "'") { inQuote = ch; buffer += ch; continue; }
406
+ if (ch === '[' || ch === '(' || ch === '{') { depth++; buffer += ch; continue; }
407
+ if (ch === ']' || ch === ')' || ch === '}') { depth--; buffer += ch; continue; }
408
+ if (ch === ';' && depth === 0) {
409
+ parts.push(buffer);
410
+ buffer = '';
411
+ continue;
412
+ }
413
+ buffer += ch;
414
+ }
415
+ if (buffer.length > 0) parts.push(buffer);
416
+ return parts;
417
+ }
@@ -0,0 +1,129 @@
1
+ /**
2
+ * LRU + TTL cache for `wiki_context` results.
3
+ *
4
+ * Process-local, in-memory, capped at 256 entries with a 30-minute TTL. Ported from
5
+ * dendrite-mcp's packet_cache.rs. Invalidated on any `wiki_write`, `memory_remember`,
6
+ * `memory_forget`, `memory_restore`, or `memory_promote` call so writes don't serve
7
+ * stale briefings.
8
+ *
9
+ * Explicit design trade-off: cache hits do NOT re-bump `recallCount` or `lastRecalledAt`
10
+ * for the surfaced memories. The 30-minute TTL keeps the staleness window tight, and the
11
+ * latency win on repeated `wiki_context` calls within the same task is the goal — perfect
12
+ * recall-count fidelity is not. If real-world usage shows recall counts meaningfully
13
+ * undercounting, revisit.
14
+ */
15
+
16
+ import type { WikiContextOptions, WikiContextResult } from './store.js';
17
+ import { onMemoryMutation } from '@rarusoft/dendrite-memory';
18
+
19
+ interface CacheEntry {
20
+ key: string;
21
+ result: WikiContextResult;
22
+ insertedAt: number;
23
+ lastHitAt: number;
24
+ hitCount: number;
25
+ }
26
+
27
+ const MAX_ENTRIES = 256;
28
+ const TTL_MS = 30 * 60 * 1000;
29
+
30
+ const entries = new Map<string, CacheEntry>();
31
+
32
+ export interface CacheStats {
33
+ size: number;
34
+ maxEntries: number;
35
+ ttlMs: number;
36
+ totalHits: number;
37
+ }
38
+
39
+ export function getCachedWikiContext(query: string, options: WikiContextOptions): WikiContextResult | undefined {
40
+ const key = buildCacheKey(query, options);
41
+ const entry = entries.get(key);
42
+ if (!entry) {
43
+ return undefined;
44
+ }
45
+
46
+ const now = Date.now();
47
+ if (now - entry.insertedAt > TTL_MS) {
48
+ entries.delete(key);
49
+ return undefined;
50
+ }
51
+
52
+ entry.lastHitAt = now;
53
+ entry.hitCount += 1;
54
+ return entry.result;
55
+ }
56
+
57
+ export function setCachedWikiContext(query: string, options: WikiContextOptions, result: WikiContextResult): void {
58
+ const key = buildCacheKey(query, options);
59
+
60
+ if (entries.size >= MAX_ENTRIES && !entries.has(key)) {
61
+ evictOldest();
62
+ }
63
+
64
+ const now = Date.now();
65
+ entries.set(key, {
66
+ key,
67
+ result,
68
+ insertedAt: now,
69
+ lastHitAt: now,
70
+ hitCount: 0
71
+ });
72
+ }
73
+
74
+ export function invalidateWikiContextCache(): void {
75
+ entries.clear();
76
+ }
77
+
78
+ // Phase 4 slice B wave 2: cache invalidation is now wiki-side and listens to brain
79
+ // mutation events rather than being called from inside memory-store.ts. The
80
+ // registration runs once at module load; the listener stays alive for the process
81
+ // lifetime (no unsubscribe needed in normal operation).
82
+ onMemoryMutation(invalidateWikiContextCache);
83
+
84
+ export function getWikiContextCacheStats(): CacheStats {
85
+ let totalHits = 0;
86
+ for (const entry of entries.values()) {
87
+ totalHits += entry.hitCount;
88
+ }
89
+ return {
90
+ size: entries.size,
91
+ maxEntries: MAX_ENTRIES,
92
+ ttlMs: TTL_MS,
93
+ totalHits
94
+ };
95
+ }
96
+
97
+ function buildCacheKey(query: string, options: WikiContextOptions): string {
98
+ // Stable JSON ordering: explicitly serialize keys so two calls with the same args but
99
+ // different option-property declaration order map to the same cache entry.
100
+ return JSON.stringify({
101
+ q: query,
102
+ mp: options.maxPages ?? null,
103
+ il: options.includeLint ?? null,
104
+ ml: options.maxLogEntries ?? null,
105
+ ms: options.maxSkills ?? null,
106
+ rf: normalizeOptionalArray(options.relatedFiles),
107
+ l: normalizeOptionalArray(options.languages),
108
+ fw: normalizeOptionalArray(options.frameworks)
109
+ });
110
+ }
111
+
112
+ function normalizeOptionalArray(value: string[] | undefined): string[] | null {
113
+ if (!Array.isArray(value) || value.length === 0) {
114
+ return null;
115
+ }
116
+ return [...value].map((v) => v.toLowerCase()).sort();
117
+ }
118
+
119
+ function evictOldest(): void {
120
+ let oldestEntry: CacheEntry | undefined;
121
+ for (const entry of entries.values()) {
122
+ if (!oldestEntry || entry.lastHitAt < oldestEntry.lastHitAt) {
123
+ oldestEntry = entry;
124
+ }
125
+ }
126
+ if (oldestEntry) {
127
+ entries.delete(oldestEntry.key);
128
+ }
129
+ }