@specship/specship-win32-arm64 0.11.5 → 0.11.7
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/lib/dist/server/chat/classify.js +110 -0
- package/lib/dist/server/routes/chat-answer.js +488 -0
- package/lib/dist/server/routes/chat.js +144 -0
- package/lib/dist/server/routes/claude.js +2 -3
- package/lib/dist/server/routes/events.js +2 -3
- package/lib/dist/server/routes/projects.js +2 -3
- package/lib/dist/server/routes/sse.js +23 -0
- package/lib/dist/server/routes/workflow.js +2 -3
- package/lib/dist/server/server.js +2 -0
- package/lib/dist/web/{chunk-H2TR6BKW.js → chunk-7KWBINA7.js} +1 -1
- package/lib/dist/web/{chunk-BZ5SMVXG.js → chunk-7MUNFW56.js} +1 -1
- package/lib/dist/web/{chunk-Z23KACD6.js → chunk-AZUUBUEJ.js} +1 -1
- package/lib/dist/web/{chunk-BDF4LLKR.js → chunk-CYXQILZI.js} +1 -1
- package/lib/dist/web/{chunk-PAO2624Y.js → chunk-DTWIJ3GJ.js} +1 -1
- package/lib/dist/web/{chunk-G2FTQVMJ.js → chunk-E27QAKAB.js} +1 -1
- package/lib/dist/web/{chunk-YPWBUJH7.js → chunk-EXBUKHH2.js} +1 -1
- package/lib/dist/web/{chunk-YVAUYHOB.js → chunk-F5TBUYDH.js} +1 -1
- package/lib/dist/web/{chunk-C6I5R6UZ.js → chunk-FC3R7YHR.js} +1 -1
- package/lib/dist/web/{chunk-IXQIWN6Z.js → chunk-G7RXFHYO.js} +1 -1
- package/lib/dist/web/{chunk-NY5CBIVU.js → chunk-HPKNOV45.js} +1 -1
- package/lib/dist/web/{chunk-PBRCKHEQ.js → chunk-JGDK6YKG.js} +1 -1
- package/lib/dist/web/{chunk-5CR7TMVN.js → chunk-JPSYO24K.js} +1 -1
- package/lib/dist/web/{chunk-FYXVIWM2.js → chunk-K7UD24Z4.js} +1 -1
- package/lib/dist/web/{chunk-QJSQ3ORB.js → chunk-LIFS4XH2.js} +1 -1
- package/lib/dist/web/chunk-MADMRWIK.js +3 -0
- package/lib/dist/web/{chunk-7ALHQYYG.js → chunk-MNFTHCSJ.js} +1 -1
- package/lib/dist/web/{chunk-7ULUC3DC.js → chunk-N5WSFE4J.js} +1 -1
- package/lib/dist/web/{chunk-G2MHCJUR.js → chunk-PCW6R7OP.js} +1 -1
- package/lib/dist/web/{chunk-EAWW6PQJ.js → chunk-POCVU4VK.js} +1 -1
- package/lib/dist/web/chunk-TWVVIWKO.js +1 -0
- package/lib/dist/web/{chunk-HZYJAXHL.js → chunk-U67LRNER.js} +1 -1
- package/lib/dist/web/{chunk-KXMWAQNB.js → chunk-USVLLFVR.js} +1 -1
- package/lib/dist/web/{chunk-U6CWVOZO.js → chunk-UYOKO7C5.js} +1 -1
- package/lib/dist/web/{chunk-7V4MGZCG.js → chunk-XAUQEKZX.js} +1 -1
- package/lib/dist/web/{chunk-D7HQMX7C.js → chunk-YXYPTZ6E.js} +1 -1
- package/lib/dist/web/index.html +1 -1
- package/lib/dist/web/{main-FHGJXJSF.js → main-5B4RR3BC.js} +1 -1
- package/lib/node_modules/.package-lock.json +1 -1
- package/lib/package.json +1 -1
- package/package.json +1 -1
- package/lib/dist/web/chunk-3H2EZLZN.js +0 -1
- package/lib/dist/web/chunk-VPUORMLB.js +0 -3
|
@@ -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
|
+
}
|