@specship/specship-win32-arm64 0.11.4 → 0.11.6

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.
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Deterministic intent classifier for the dashboard chat (REQ-DASH-CHAT-002).
3
+ *
4
+ * A **pure** rule-based router: given the raw message text it returns the intent
5
+ * and the query to run — **no language model, no I/O, no `cg`** (A4). The chat
6
+ * route feeds the result to a dispatcher (`answerForIntent` in
7
+ * `../routes/chat-answer.ts`) that runs the matching knowledge-base query; this
8
+ * module never touches the graph itself, so it is unit-testable in isolation.
9
+ *
10
+ * Routing order (first match wins, most specific first):
11
+ * 1. **Slash commands** (A1) — `/ss-spec <ID>` → spec, `/ss-explore …` →
12
+ * explore, `/ss-check …` → drift (the launch set has no separate
13
+ * gate/health intent, so the check family routes to drift).
14
+ * 2. **Free-form keyword/pattern** (A2 / A5) — regexes on the lowercased
15
+ * message pick callers / callees / impact / explore / drift / domain, or a
16
+ * bare spec-id token → spec.
17
+ * 3. **Fallback** (A3) — `{ intent: 'search', confident: false }`. Never null,
18
+ * never a guessed intent: an unmatched message runs full-text search.
19
+ *
20
+ * The subject/query is derived with `extractSubject` (shared with the answer
21
+ * core) so tokenization — slash-token stripping, stopword removal — is
22
+ * identical to what the search path expects.
23
+ */
24
+ import { extractSubject } from '../routes/chat-answer.js';
25
+ /** A spec-id-shaped token — `REQ-DASH-CHAT-002`, `DOMAIN-LEDGER`, `DASH-CHAT-DOC`. */
26
+ const SPEC_ID = /\b[A-Za-z][A-Za-z0-9]*(?:-[A-Za-z0-9]+)+\b/;
27
+ /**
28
+ * Intent-signalling words that survive the shared `extractSubject` stopword
29
+ * pass (which stays conservative so the generic search path doesn't drop real
30
+ * symbol names). Stripped here — inside the classifier — so a routed query like
31
+ * "impact of recordEntry" or "define Ledger" reduces to just its subject.
32
+ */
33
+ const INTENT_WORDS = new Set([
34
+ 'impact', 'define', 'definition', 'flow', 'trace',
35
+ 'drift', 'drifted', 'drifting', 'drifts',
36
+ 'caller', 'callers', 'callee', 'callees',
37
+ ]);
38
+ /** The extracted subject with intent-signalling words removed. */
39
+ function subjectOf(message) {
40
+ const { query } = extractSubject(message);
41
+ return query
42
+ .split(' ')
43
+ .filter((t) => t.length > 0 && !INTENT_WORDS.has(t.toLowerCase()))
44
+ .join(' ');
45
+ }
46
+ /**
47
+ * Classify a chat message into an intent + query. Pure function of the text
48
+ * (A4): identical input always yields identical output, and nothing here reads
49
+ * a file, a database, or a model.
50
+ */
51
+ export function classifyIntent(message) {
52
+ const msg = message.trim();
53
+ const subject = subjectOf(msg);
54
+ // --- 1. Slash commands (A1) ----------------------------------------------
55
+ const slash = msg.match(/^\/(\S+)\s*([\s\S]*)$/);
56
+ if (slash) {
57
+ const cmd = (slash[1] ?? '').toLowerCase();
58
+ const rest = (slash[2] ?? '').trim();
59
+ if (cmd === 'ss-spec' || cmd === 'spec') {
60
+ return { intent: 'spec', query: rest, subject: rest || undefined, confident: true };
61
+ }
62
+ if (cmd === 'ss-explore' || cmd === 'explore') {
63
+ return { intent: 'explore', query: rest, subject: rest || undefined, confident: true };
64
+ }
65
+ if (cmd === 'ss-check' || cmd === 'check') {
66
+ return { intent: 'drift', query: rest, subject: rest || undefined, confident: true };
67
+ }
68
+ // Unknown slash command → fall through to keyword matching on the subject
69
+ // (extractSubject has already stripped the leading slash token).
70
+ }
71
+ const lower = msg.toLowerCase();
72
+ const withSubject = (intent) => ({
73
+ intent,
74
+ query: subject,
75
+ subject: subject || undefined,
76
+ confident: true,
77
+ });
78
+ // --- 2. Free-form keyword / pattern (A2, A5) ------------------------------
79
+ // "who calls X" / "what calls X" → callers.
80
+ if (/\b(who|what)\s+calls\b/.test(lower))
81
+ return withSubject('callers');
82
+ // "what does X call" / "callees of X" → callees (checked before explore so a
83
+ // "call" phrasing never falls through to the generic how-does path).
84
+ if (/\bwhat\s+does\b[\s\S]*\bcalls?\b/.test(lower) || /\bcallees?\b/.test(lower)) {
85
+ return withSubject('callees');
86
+ }
87
+ // "what breaks if I change X" / "impact of X" → impact.
88
+ if (/\bwhat\s+breaks\b/.test(lower) || /\bimpact\s+of\b/.test(lower) || /\bif\s+i\s+change\b/.test(lower)) {
89
+ return withSubject('impact');
90
+ }
91
+ // "how does X work" / "the flow" / "trace X" → explore.
92
+ if (/\bhow\s+does\b/.test(lower) || /\bflow\b/.test(lower) || /\btrace\b/.test(lower)) {
93
+ return withSubject('explore');
94
+ }
95
+ // "what's drifted" / "drift" → drift queue (before domain so "what's drifted"
96
+ // doesn't get read as a "what is …" definition lookup).
97
+ if (/\bdrift(ed|ing|s)?\b/.test(lower))
98
+ return withSubject('drift');
99
+ // "what is <term>" / "define X" → domain lookup.
100
+ if (/\bwhat\s+is\b/.test(lower) || /\bwhat\s+are\b/.test(lower) || /\bdefine\b/.test(lower) || /\bdefinition\s+of\b/.test(lower)) {
101
+ return withSubject('domain');
102
+ }
103
+ // A bare spec-id token (e.g. `REQ-DASH-CHAT-002`) → spec lookup for that id.
104
+ const specId = msg.match(SPEC_ID);
105
+ if (specId && specId[0]) {
106
+ return { intent: 'spec', query: specId[0], subject: specId[0], confident: true };
107
+ }
108
+ // --- 3. Fallback (A3) — never null, never a guessed intent ----------------
109
+ return { intent: 'search', query: subject, subject: subject || undefined, confident: false };
110
+ }
@@ -0,0 +1,488 @@
1
+ /**
2
+ * Deterministic answer engine for the dashboard chat (REQ-DASH-CHAT-001).
3
+ *
4
+ * This is the pure, Fastify-free core the `/api/chat` route delegates to. It
5
+ * answers a free-form question **entirely from the project's own indexed data**
6
+ * — the code knowledge graph (symbols), specs and their links, and domain facts
7
+ * — by running database queries through the instance and composing prose from
8
+ * the real rows returned. It is emphatically **not** a chatbot:
9
+ *
10
+ * - **No LLM, no network (A2 / DOM-SPECSHIP-004).** Nothing here calls a
11
+ * language model or any external service. This module imports nothing
12
+ * LLM-shaped and reads no model credentials — answers are produced by
13
+ * deterministic subject extraction + queries + templated composition.
14
+ * - **Deterministic (A3).** Given the same index and question, the produced
15
+ * answer is byte-identical: every result set is sorted by a stable key
16
+ * before rendering, and there is no `Date.now()` / `Math.random()` / any
17
+ * insertion-order dependence in the output path.
18
+ * - **Traceable (A1).** Every fact rendered into the answer carries a matching
19
+ * `ChatSource` pointing at the exact row it came from (a symbol's qualified
20
+ * name, a spec id, a domain fact id).
21
+ * - **Honest (A4).** When the subject matches nothing indexed, the engine
22
+ * returns `{ found: false }` with a plain not-found sentence — it never
23
+ * invents a symbol, path, or fact.
24
+ *
25
+ * Driven only through instance query methods (`searchNodes`, `getSpecQueries()`)
26
+ * so the server never runtime-imports the `@specship/specship` package (which
27
+ * would silently serve a stale build).
28
+ */
29
+ import { readFileSync } from 'node:fs';
30
+ import { join } from 'node:path';
31
+ /**
32
+ * Per-store caps for the **prose** sections only — keep the composed gloss
33
+ * bounded and readable. The full-detail set below is a separate, larger cap.
34
+ */
35
+ const MAX_SYMBOLS = 5;
36
+ const MAX_SPECS = 3;
37
+ const MAX_DOMAIN = 3;
38
+ /**
39
+ * Cap on the ranked full-detail match set (REQ-DASH-CHAT-005.A3). The concise
40
+ * prose stays bounded by the small per-store caps above; the detail array —
41
+ * each source's verbatim body / links, rendered as one expandable per match in
42
+ * the UI — is the top matches by rank up to this cap, so a broad query can't
43
+ * return unbounded content. Raised well above the old five-per-store limit;
44
+ * every dispatch helper takes it as a parameter so it stays configurable.
45
+ */
46
+ const DETAIL_CAP = 15;
47
+ /**
48
+ * Words that carry no subject signal — dropped before building the search
49
+ * query. Deterministic, intentionally small (question-shaping words + the
50
+ * verbs the launch intents phrase themselves with). Matching is lowercase.
51
+ */
52
+ const STOPWORDS = new Set([
53
+ 'how', 'does', 'do', 'did', 'what', 'whats', 'is', 'are', 'the', 'a', 'an',
54
+ 'who', 'where', 'why', 'when', 'which', 'this', 'that', 'these', 'those',
55
+ 'of', 'to', 'in', 'on', 'for', 'and', 'or', 'i', 'we', 'you', 'it',
56
+ 'change', 'changing', 'breaks', 'break', 'work', 'works', 'working',
57
+ 'reach', 'reaches', 'become', 'becomes', 'call', 'calls', 'called',
58
+ 'about', 'with', 'from', 'into', 'me', 'my', 'show', 'tell', 'explain',
59
+ 'find', 'get', 'look', 'up', 'be', 'was', 'were', 'has', 'have', 'had',
60
+ ]);
61
+ /**
62
+ * Extract the deterministic search subject from a raw question.
63
+ *
64
+ * Strips a leading slash-command token, splits on non-identifier characters
65
+ * (keeping `_`, `.` and `:` so `Foo.bar` / `a::b` survive as one token),
66
+ * drops stopwords, and joins the remainder. Identifier-shaped tokens
67
+ * (CamelCase, snake_case, dotted, or ≥3-char words) are preferred; if none
68
+ * survive, every non-stopword token is used so a plain-English question still
69
+ * searches. Returns the joined subject plus the raw token list. Pure.
70
+ */
71
+ export function extractSubject(question) {
72
+ const withoutCommand = question.trim().replace(/^\/\S+\s*/, '');
73
+ const rawTokens = withoutCommand
74
+ .split(/[^A-Za-z0-9_.:]+/)
75
+ .map((t) => t.replace(/^[.:]+|[.:]+$/g, '')) // trim stray separators
76
+ .filter((t) => t.length > 0);
77
+ const kept = rawTokens.filter((t) => !STOPWORDS.has(t.toLowerCase()));
78
+ const identifierLike = kept.filter((t) => /[A-Z]/.test(t.slice(1)) || t.includes('_') || t.includes('.') || t.includes(':') || t.length >= 3);
79
+ const subjectTokens = identifierLike.length > 0 ? identifierLike : kept;
80
+ return { query: subjectTokens.join(' '), tokens: subjectTokens };
81
+ }
82
+ /** Stable symbol ordering: score desc, then qualified name asc (tie-break). */
83
+ function bySymbolRank(a, b) {
84
+ if (b.score !== a.score)
85
+ return b.score - a.score;
86
+ return a.node.qualifiedName < b.node.qualifiedName ? -1 : a.node.qualifiedName > b.node.qualifiedName ? 1 : 0;
87
+ }
88
+ /** Stable spec ordering: score desc, then spec id asc (tie-break). */
89
+ function bySpecRank(a, b) {
90
+ if (b.score !== a.score)
91
+ return b.score - a.score;
92
+ return a.spec.id < b.spec.id ? -1 : a.spec.id > b.spec.id ? 1 : 0;
93
+ }
94
+ /** First non-empty line of a spec/domain body, trimmed — the one-line gloss. */
95
+ function firstLine(body) {
96
+ for (const raw of body.split('\n')) {
97
+ const line = raw.replace(/^#+\s*/, '').trim();
98
+ if (line.length > 0)
99
+ return line;
100
+ }
101
+ return '';
102
+ }
103
+ // =============================================================================
104
+ // Full retrieved detail per match (REQ-DASH-CHAT-005)
105
+ // =============================================================================
106
+ /**
107
+ * Read a symbol's verbatim source body from disk — the exact `startLine..endLine`
108
+ * slice of its file (mirrors `src/mcp/tools.ts`). Real bytes, never synthesized
109
+ * (A4); returns `''` if the file can't be read so a missing file is honest, not
110
+ * fabricated.
111
+ */
112
+ function readNodeBody(cg, node) {
113
+ try {
114
+ const abs = join(cg.getProjectRoot(), node.filePath);
115
+ const lines = readFileSync(abs, 'utf-8').split('\n');
116
+ return lines.slice(node.startLine - 1, node.endLine).join('\n');
117
+ }
118
+ catch {
119
+ return '';
120
+ }
121
+ }
122
+ /** Map 1-hop neighbours to deduped, stable-sorted `SymbolRef`s (self excluded). */
123
+ function neighbourRefs(raw, selfId) {
124
+ const seen = new Set();
125
+ return raw
126
+ .filter((r) => r.node.id !== selfId && !seen.has(r.node.id) && (seen.add(r.node.id), true))
127
+ .map((r) => ({ name: r.node.name, qualifiedName: r.node.qualifiedName, filePath: r.node.filePath, line: r.node.startLine }))
128
+ .sort((a, b) => byName(a.qualifiedName, b.qualifiedName));
129
+ }
130
+ /**
131
+ * Full symbol detail (A1): verbatim body + signature + immediate callers/callees.
132
+ * Read-equivalent depth so the user never has to open the file to see the match.
133
+ */
134
+ function buildSymbolDetail(cg, node) {
135
+ return {
136
+ signature: node.signature,
137
+ body: readNodeBody(cg, node),
138
+ callers: neighbourRefs(cg.getCallers(node.id, 1), node.id),
139
+ callees: neighbourRefs(cg.getCallees(node.id, 1), node.id),
140
+ };
141
+ }
142
+ /**
143
+ * Full spec detail (A2): the spec's full body + the code it links to, each with
144
+ * its current link state. Links are stable-sorted by target (then state) so the
145
+ * detail is byte-identical across repeat calls (A4).
146
+ */
147
+ function buildSpecDetail(cg, spec) {
148
+ const links = cg
149
+ .getSpecQueries()
150
+ .getLinksBySpec(spec.id)
151
+ .map((l) => ({ target: l.targetQualifiedName, state: l.state }))
152
+ .sort((a, b) => (a.target !== b.target ? byName(a.target, b.target) : byName(a.state, b.state)));
153
+ return { body: spec.body, links };
154
+ }
155
+ /** A symbol `ChatSource` carrying its full detail (A1 + REQ-005). */
156
+ function symbolSource(cg, node) {
157
+ return {
158
+ kind: 'symbol',
159
+ ref: node.qualifiedName,
160
+ label: node.name,
161
+ filePath: node.filePath,
162
+ line: node.startLine,
163
+ detail: buildSymbolDetail(cg, node),
164
+ };
165
+ }
166
+ /**
167
+ * Answer a question deterministically from the indexed knowledge base.
168
+ *
169
+ * Runs a symbol search and a spec search (domain facts are specs of kind
170
+ * `domain`, split out into their own section), sorts every result set by a
171
+ * stable key, and composes a templated prose answer from the real rows. When
172
+ * nothing matches, returns an honest not-found answer with no sources.
173
+ */
174
+ export function answerFromKnowledgeBase(cg, question, detailCap = DETAIL_CAP) {
175
+ const { query } = extractSubject(question);
176
+ const subjectLabel = query.length > 0 ? query : question.trim();
177
+ // No usable subject → nothing to search; honest empty answer (A4).
178
+ if (query.length === 0) {
179
+ return notFound(subjectLabel);
180
+ }
181
+ // --- Ranked hits per store (wider than the prose caps to feed the detail
182
+ // set; each list is stable-sorted so repeat calls are byte-identical). --
183
+ const symbolHits = cg
184
+ .searchNodes(query, { limit: detailCap * 4 })
185
+ .slice()
186
+ .sort(bySymbolRank)
187
+ .slice(0, detailCap);
188
+ const specHits = cg
189
+ .getSpecQueries()
190
+ .searchSpecs(query, detailCap * 4)
191
+ .slice()
192
+ .sort(bySpecRank);
193
+ const domainHits = specHits.filter((r) => r.spec.kind === 'domain').slice(0, detailCap);
194
+ const nonDomainSpecHits = specHits.filter((r) => r.spec.kind !== 'domain').slice(0, detailCap);
195
+ if (symbolHits.length === 0 && domainHits.length === 0 && nonDomainSpecHits.length === 0) {
196
+ return notFound(subjectLabel);
197
+ }
198
+ // --- Concise prose: the small per-store caps, gloss only (no detail). ------
199
+ const proseSymbols = symbolHits.slice(0, MAX_SYMBOLS);
200
+ const proseSpecs = nonDomainSpecHits.slice(0, MAX_SPECS);
201
+ const proseDomain = domainHits.slice(0, MAX_DOMAIN);
202
+ const sections = [];
203
+ if (proseSymbols.length > 0) {
204
+ const parts = proseSymbols.map((r) => `\`${r.node.name}\` (${r.node.kind} in ${r.node.filePath}:${r.node.startLine})`);
205
+ const noun = proseSymbols.length === 1 ? 'symbol' : 'symbols';
206
+ sections.push(`Found ${proseSymbols.length} ${noun} matching “${subjectLabel}”: ${parts.join(', ')}.`);
207
+ }
208
+ if (proseSpecs.length > 0) {
209
+ const parts = proseSpecs.map((r) => `${r.spec.id} — ${r.spec.title}`);
210
+ const noun = proseSpecs.length === 1 ? 'spec' : 'specs';
211
+ sections.push(`Related ${noun}: ${parts.join('; ')}.`);
212
+ }
213
+ if (proseDomain.length > 0) {
214
+ const parts = proseDomain.map((r) => {
215
+ const gloss = firstLine(r.spec.body);
216
+ return gloss ? `${r.spec.title} — ${gloss}` : r.spec.title;
217
+ });
218
+ const noun = proseDomain.length === 1 ? 'domain fact' : 'domain facts';
219
+ sections.push(`Related ${noun}: ${parts.join('; ')}.`);
220
+ }
221
+ // --- Full-detail sources: prose-mentioned first (so every rendered fact has
222
+ // its source ref, A1), then extra ranked matches fill the detail budget so
223
+ // the user sees everything retrieved — bounded at detailCap (A3, A5). -----
224
+ const sources = [];
225
+ const specSource = (r) => ({
226
+ kind: 'spec', ref: r.spec.id, label: r.spec.title, detail: buildSpecDetail(cg, r.spec),
227
+ });
228
+ const domainSource = (r) => ({
229
+ kind: 'domain', ref: r.spec.id, label: r.spec.title, detail: { body: r.spec.body },
230
+ });
231
+ const add = (src) => { if (sources.length < detailCap)
232
+ sources.push(src); };
233
+ for (const r of proseSymbols)
234
+ add(symbolSource(cg, r.node));
235
+ for (const r of proseSpecs)
236
+ add(specSource(r));
237
+ for (const r of proseDomain)
238
+ add(domainSource(r));
239
+ for (const r of symbolHits.slice(MAX_SYMBOLS))
240
+ add(symbolSource(cg, r.node));
241
+ for (const r of nonDomainSpecHits.slice(MAX_SPECS))
242
+ add(specSource(r));
243
+ for (const r of domainHits.slice(MAX_DOMAIN))
244
+ add(domainSource(r));
245
+ return { found: true, answer: sections.join('\n\n'), sources };
246
+ }
247
+ // =============================================================================
248
+ // Faux-streaming chunker (REQ-DASH-CHAT-003)
249
+ // =============================================================================
250
+ /**
251
+ * Longest slice `chunkAnswer` emits. Sized to a word-group (not a whole
252
+ * sentence) so even a one-sentence answer streams in several chunks — the
253
+ * progressive-reveal "typing" effect (REQ-DASH-CHAT-003.A2) rather than one
254
+ * atomic drop.
255
+ */
256
+ const MAX_CHUNK_CHARS = 48;
257
+ /**
258
+ * Split a fully-composed answer into ordered presentation chunks.
259
+ *
260
+ * The stream route computes the whole answer first, then paces these chunks so
261
+ * the reply renders progressively like a model typing — but pacing is cosmetics
262
+ * only, so the **one invariant is `chunkAnswer(answer).join('') === answer`**
263
+ * (REQ-DASH-CHAT-003.A2/A3): chunk boundaries never add, drop, or reorder a
264
+ * character. Boundaries prefer a sentence end (`.`/`!`/`?`/`;` before a space or
265
+ * newline), then a newline, then any space, and finally a hard cut at
266
+ * `MAX_CHUNK_CHARS` so an unbroken token still streams. Pure and deterministic —
267
+ * same input always yields the same chunks, no `Date.now()`/`Math.random()`.
268
+ */
269
+ export function chunkAnswer(answer) {
270
+ const chunks = [];
271
+ const n = answer.length;
272
+ let i = 0;
273
+ while (i < n) {
274
+ const hardEnd = Math.min(i + MAX_CHUNK_CHARS, n);
275
+ if (hardEnd >= n) {
276
+ chunks.push(answer.slice(i));
277
+ break;
278
+ }
279
+ // Scan the window (i, hardEnd) for the best break, preferring a sentence
280
+ // end, then a newline, then any space. Each candidate is the index just
281
+ // AFTER the boundary char so the separator rides the current chunk.
282
+ let sentenceBreak = -1;
283
+ let spaceBreak = -1;
284
+ for (let j = i; j < hardEnd; j++) {
285
+ const c = answer[j];
286
+ const next = answer[j + 1];
287
+ if ((c === '.' || c === '!' || c === '?' || c === ';') && (next === ' ' || next === '\n')) {
288
+ sentenceBreak = j + 1;
289
+ }
290
+ else if (c === '\n') {
291
+ sentenceBreak = j + 1;
292
+ }
293
+ else if (c === ' ') {
294
+ spaceBreak = j + 1;
295
+ }
296
+ }
297
+ const end = sentenceBreak > i ? sentenceBreak : spaceBreak > i ? spaceBreak : hardEnd;
298
+ chunks.push(answer.slice(i, end));
299
+ i = end;
300
+ }
301
+ return chunks;
302
+ }
303
+ // =============================================================================
304
+ // Intent dispatcher (REQ-DASH-CHAT-002)
305
+ // =============================================================================
306
+ /**
307
+ * Route a classified message to the matching knowledge-base query.
308
+ *
309
+ * The classifier (`../chat/classify.ts`) is pure and I/O-free; this is the thin
310
+ * glue that turns its `{ intent, query }` into a concrete instance query and
311
+ * composes the deterministic answer. Each intent maps to an existing instance
312
+ * method — callers/callees/impact traverse the graph, spec/domain hit the spec
313
+ * layer, drift reads the out-of-sync links, and explore/search fall through to
314
+ * the generic knowledge-base composition. Every rendered fact still carries a
315
+ * `ChatSource` (A1) and every result set is stable-sorted (A3).
316
+ */
317
+ export function answerForIntent(cg, classified, detailCap = DETAIL_CAP) {
318
+ const query = classified.query.trim();
319
+ switch (classified.intent) {
320
+ case 'callers':
321
+ return answerNeighbours(cg, query, 'callers', detailCap);
322
+ case 'callees':
323
+ return answerNeighbours(cg, query, 'callees', detailCap);
324
+ case 'impact':
325
+ return answerImpact(cg, query, detailCap);
326
+ case 'spec':
327
+ return answerSpecs(cg, query, 'spec', detailCap);
328
+ case 'domain':
329
+ return answerSpecs(cg, query, 'domain', detailCap);
330
+ case 'drift':
331
+ return answerDrift(cg, detailCap);
332
+ case 'explore':
333
+ case 'search':
334
+ default:
335
+ return answerFromKnowledgeBase(cg, query, detailCap);
336
+ }
337
+ }
338
+ /** Stable string comparison — the tie-break every dispatch sort shares. */
339
+ function byName(a, b) {
340
+ return a < b ? -1 : a > b ? 1 : 0;
341
+ }
342
+ /** Resolve a free-form subject to its single best-matching graph node (or null). */
343
+ function resolveNode(cg, query) {
344
+ if (query.length === 0)
345
+ return null;
346
+ const hits = cg.searchNodes(query, { limit: MAX_SYMBOLS * 4 }).slice().sort(bySymbolRank);
347
+ return hits[0]?.node ?? null;
348
+ }
349
+ /** Render a symbol reference the same way across every dispatch answer. */
350
+ function symbolRef(node) {
351
+ return `\`${node.name}\` (${node.kind} in ${node.filePath}:${node.startLine})`;
352
+ }
353
+ /** callers / callees: the subject's 1-hop neighbours in the call graph. */
354
+ function answerNeighbours(cg, query, dir, detailCap = DETAIL_CAP) {
355
+ const node = resolveNode(cg, query);
356
+ if (!node)
357
+ return notFound(query);
358
+ const raw = dir === 'callers' ? cg.getCallers(node.id, 1) : cg.getCallees(node.id, 1);
359
+ const seen = new Set();
360
+ const neighbours = raw
361
+ .filter((r) => r.node.id !== node.id && !seen.has(r.node.id) && (seen.add(r.node.id), true))
362
+ .sort((a, b) => byName(a.node.qualifiedName, b.node.qualifiedName));
363
+ // Subject first, then its neighbours — each with full detail, bounded (A3).
364
+ const sources = [symbolSource(cg, node)];
365
+ for (const r of neighbours.slice(0, detailCap - 1))
366
+ sources.push(symbolSource(cg, r.node));
367
+ if (neighbours.length === 0) {
368
+ const rel = dir === 'callers' ? 'has no known callers' : 'makes no known calls';
369
+ return { found: true, answer: `${symbolRef(node)} ${rel} in the index.`, sources };
370
+ }
371
+ const shown = neighbours.slice(0, MAX_SYMBOLS);
372
+ const parts = shown.map((r) => symbolRef(r.node));
373
+ const more = neighbours.length > shown.length ? ` (+${neighbours.length - shown.length} more)` : '';
374
+ const verb = dir === 'callers' ? 'is called by' : 'calls';
375
+ const noun = dir === 'callers'
376
+ ? (neighbours.length === 1 ? 'caller' : 'callers')
377
+ : (neighbours.length === 1 ? 'callee' : 'callees');
378
+ return { found: true, answer: `\`${node.name}\` ${verb} ${neighbours.length} ${noun}${more}: ${parts.join(', ')}.`, sources };
379
+ }
380
+ /** impact: the subject's downstream blast radius. */
381
+ function answerImpact(cg, query, detailCap = DETAIL_CAP) {
382
+ const node = resolveNode(cg, query);
383
+ if (!node)
384
+ return notFound(query);
385
+ const sub = cg.getImpactRadius(node.id, 3);
386
+ const impacted = [...sub.nodes.values()]
387
+ .filter((n) => n.id !== node.id)
388
+ .sort((a, b) => byName(a.qualifiedName, b.qualifiedName));
389
+ // Subject first, then impacted symbols — each with full detail, bounded (A3).
390
+ const sources = [symbolSource(cg, node)];
391
+ for (const n of impacted.slice(0, detailCap - 1))
392
+ sources.push(symbolSource(cg, n));
393
+ if (impacted.length === 0) {
394
+ return { found: true, answer: `Changing ${symbolRef(node)} has no known downstream impact in the index.`, sources };
395
+ }
396
+ const shown = impacted.slice(0, MAX_SYMBOLS);
397
+ const parts = shown.map((n) => symbolRef(n));
398
+ const more = impacted.length > shown.length ? ` (+${impacted.length - shown.length} more)` : '';
399
+ const noun = impacted.length === 1 ? 'symbol' : 'symbols';
400
+ return { found: true, answer: `Changing \`${node.name}\` could affect ${impacted.length} ${noun}${more}: ${parts.join(', ')}.`, sources };
401
+ }
402
+ /** spec / domain: a lookup over the spec layer (exact id first for spec). */
403
+ function answerSpecs(cg, query, which, detailCap = DETAIL_CAP) {
404
+ if (query.length === 0)
405
+ return notFound(query);
406
+ const specDetailSource = (spec) => which === 'domain'
407
+ ? { kind: 'domain', ref: spec.id, label: spec.title, detail: { body: spec.body } }
408
+ : { kind: 'spec', ref: spec.id, label: spec.title, detail: buildSpecDetail(cg, spec) };
409
+ // A spec intent with an exact id resolves directly — deterministic and precise.
410
+ if (which === 'spec') {
411
+ const exact = cg.getSpecQueries().getSpecById(query);
412
+ if (exact) {
413
+ const gloss = firstLine(exact.body);
414
+ return {
415
+ found: true,
416
+ answer: gloss ? `${exact.id} — ${exact.title}: ${gloss}` : `${exact.id} — ${exact.title}`,
417
+ sources: [specDetailSource(exact)],
418
+ };
419
+ }
420
+ }
421
+ const hits = cg
422
+ .getSpecQueries()
423
+ .searchSpecs(query, detailCap * 4)
424
+ .slice()
425
+ .sort(bySpecRank);
426
+ const matched = which === 'domain'
427
+ ? hits.filter((r) => r.spec.kind === 'domain').slice(0, detailCap)
428
+ : hits.filter((r) => r.spec.kind !== 'domain').slice(0, detailCap);
429
+ if (matched.length === 0)
430
+ return notFound(query);
431
+ // Full detail for every match, bounded (A2/A3); prose lists the small cap.
432
+ const sources = matched.map((r) => specDetailSource(r.spec));
433
+ const prose = matched.slice(0, which === 'domain' ? MAX_DOMAIN : MAX_SPECS);
434
+ const parts = prose.map((r) => {
435
+ if (which === 'domain') {
436
+ const gloss = firstLine(r.spec.body);
437
+ return gloss ? `${r.spec.title} — ${gloss}` : r.spec.title;
438
+ }
439
+ return `${r.spec.id} — ${r.spec.title}`;
440
+ });
441
+ const more = matched.length > prose.length ? ` (+${matched.length - prose.length} more)` : '';
442
+ const noun = which === 'domain'
443
+ ? (matched.length === 1 ? 'domain fact' : 'domain facts')
444
+ : (matched.length === 1 ? 'spec' : 'specs');
445
+ return { found: true, answer: `Found ${matched.length} ${noun} matching “${query}”${more}: ${parts.join('; ')}.`, sources };
446
+ }
447
+ /** drift: the spec ↔ code links that are currently out of sync. */
448
+ function answerDrift(cg, detailCap = DETAIL_CAP) {
449
+ const links = cg
450
+ .getSpecQueries()
451
+ .getLinksByState(['drifted', 'broken', 'orphaned'])
452
+ .slice()
453
+ .sort((a, b) => (a.specId !== b.specId ? byName(a.specId, b.specId) : byName(a.targetQualifiedName, b.targetQualifiedName)));
454
+ if (links.length === 0) {
455
+ return {
456
+ found: true,
457
+ answer: 'No specs are currently drifted, broken, or orphaned — the spec ↔ code links are all in sync.',
458
+ sources: [],
459
+ };
460
+ }
461
+ // One source per distinct drifted spec, each carrying the spec's full detail
462
+ // (body + all its links with state), bounded at detailCap (A2/A3).
463
+ const sources = [];
464
+ const sq = cg.getSpecQueries();
465
+ const seenSpecs = new Set();
466
+ for (const l of links) {
467
+ if (sources.length >= detailCap || seenSpecs.has(l.specId))
468
+ continue;
469
+ seenSpecs.add(l.specId);
470
+ const spec = sq.getSpecById(l.specId);
471
+ sources.push(spec
472
+ ? { kind: 'spec', ref: spec.id, label: spec.title, detail: buildSpecDetail(cg, spec) }
473
+ : { kind: 'spec', ref: l.specId, label: l.specId });
474
+ }
475
+ const shown = links.slice(0, MAX_SPECS + MAX_DOMAIN);
476
+ const parts = shown.map((l) => `${l.specId} → ${l.targetQualifiedName} (${l.state})`);
477
+ const more = links.length > shown.length ? ` (+${links.length - shown.length} more)` : '';
478
+ const noun = links.length === 1 ? 'link' : 'links';
479
+ return { found: true, answer: `${links.length} spec ${noun} out of sync${more}: ${parts.join('; ')}.`, sources };
480
+ }
481
+ /** The honest not-found answer — no invented symbols, paths, or facts (A4). */
482
+ function notFound(subjectLabel) {
483
+ return {
484
+ found: false,
485
+ answer: `I couldn't find anything about “${subjectLabel}” in this project's knowledge base — no matching symbols, specs, or domain facts are indexed.`,
486
+ sources: [],
487
+ };
488
+ }