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