@polycode-projects/seonix 0.3.0 → 0.4.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/src/chat.mjs CHANGED
@@ -1,26 +1,48 @@
1
- // chat.mjs — `seonix chat`: an interactive prompt over the mechanical seonix_ask
2
- // engine (PLAN_MECHANICAL_CHAT.md). Every non-`/exit` line is a query dispatched
3
- // through dispatchTool("seonix_ask", …) the EXACT path bin/cli.mjs's `cli
4
- // <toolName>` fallback already exposes — so chat is the same engine as
5
- // `seonix cli seonix_ask '{"query":"…"}'` with a readline shell around it, zero
6
- // new coupling to ask.mjs internals.
1
+ // chat.mjs — `seonix chat`: a full interactive client over the seonix code-graph.
2
+ // Any BARE line is a plain-English question dispatched through the mechanical
3
+ // seonix_ask engine (the EXACT path bin/cli.mjs's `cli seonix_ask` fallback uses),
4
+ // so chat is the same zero-model engine with a readline shell around it, plus:
7
5
  //
8
- // Sessions are logged to <repo>/SESSION_LOG_DIR/session-<uuidv7>.log, appended
9
- // and flushed per turn so a killed session keeps everything up to its last turn.
10
- // Alongside it, a STRUCTURED sidecar (.seonix/sessions/session-<uuidv7>.jsonl,
11
- // sessions.mjs) records each turn's resolved/answered entity ids, and the session
12
- // is upserted into graph.json per turn as a first-class `Session` individual with
13
- // mgx:asksAbout edges chat sessions are temporal graph data, like commits.
6
+ // - SLASH-COMMANDS to reach every richer tool dispatchTool (server.mjs) serves —
7
+ // /find /context /snippet /describe /signature /members /subclasses /impact
8
+ // /callers /callees /tests /untested /history /exports /arch — each mapped to
9
+ // the right tool name + arg key (never invented). Unknown /command → a short
10
+ // "/help" nudge, never a crash; a bad symbol the tool's own clean error.
11
+ // - a CONVERSATIONAL layer (deterministic, zero-model) that recognises a small
12
+ // closed set of human expressions BEFORE any graph dispatch — greetings, thanks,
13
+ // help/orientation, farewell (ends the session), and why/say-more (re-renders the
14
+ // last answer verbosely, with the ask envelope's traversal receipt + matches).
15
+ // These resolve no entity, so they never become asksAbout graph edges (like /help).
16
+ // - an OPT-IN LLM FALLBACK (--with-claude / --with-copilot, default OFF, chat-only):
17
+ // ONLY when a flag is set AND the mechanical engine misses a bare question does chat
18
+ // shell an external LLM in the target-repo cwd, asking it to COMPILE one seonix
19
+ // command it then runs mechanically (labelled LLM-assisted); any failure degrades to
20
+ // the honest miss. The benchmark/digest/MCP paths never reach this — it is chat-only.
21
+ // - MULTI-TURN CONTEXT: a current FOCUS entity. A command or an answer that
22
+ // resolves a primary entity remembers it; a bare `it`/`this`/`that` (threaded
23
+ // to ask() as contextId) or a no-arg entity command then reuse it, so "what
24
+ // calls it" works after `/describe Foo`. `/focus <symbol>` sets it explicitly;
25
+ // the prompt shows it (`seon(walk.mjs)>`). No focus set → single-shot as before.
14
26
  //
15
- // Future work (deliberately NOT wired for v1): the ask engine's structured
16
- // envelope carries selected-node context hooks (the viewer's contextId); chat is
17
- // single-shot stateless queries only no cross-turn state, no context id.
27
+ // Sessions are logged to <repo>/SESSION_LOG_DIR/session-<uuidv7>.log, appended and
28
+ // flushed per turn so a killed session keeps everything up to its last turn.
29
+ // Alongside it a STRUCTURED sidecar (.seonix/sessions/session-<uuidv7>.jsonl,
30
+ // sessions.mjs) records each turn's query, the command used, and the resolved/
31
+ // answered entity ids — for SLASH-COMMAND turns too — and the session is upserted
32
+ // into graph.json per turn as a first-class `Session` individual with mgx:asksAbout
33
+ // edges wherever a turn resolved an entity: chat is temporal graph data, like commits.
34
+ //
35
+ // runTurn(input, …) is a PURE function (query|command -> { answer, logLines, record,
36
+ // focus }) so tests exercise it directly; every ask.mjs import is LAZY and
37
+ // failure-tolerated, so concurrent evolution of the engine can never crash a turn
38
+ // (worst case a turn records fewer ids / a fallback hint, never wrong data).
18
39
 
19
40
  import { join } from "node:path";
20
41
  import { createWriteStream } from "node:fs";
21
42
  import { mkdir, readFile } from "node:fs/promises";
22
43
  import { createInterface } from "node:readline/promises";
23
44
  import { randomBytes } from "node:crypto";
45
+ import { spawnSync } from "node:child_process";
24
46
  import { dispatchTool } from "./server.mjs";
25
47
  import { loadConfig, DEFAULT_GRAPH_REL } from "./config.mjs";
26
48
  import { parseEntities } from "./codegraph.mjs";
@@ -32,12 +54,390 @@ import * as defaultSource from "./source.mjs";
32
54
  * if the operator prefers a different location. */
33
55
  export const SESSION_LOG_DIR = ".seonix";
34
56
 
57
+ /** The base (no-focus) prompt. With a focus set the shell shows `seon(label)>`. */
35
58
  export const PROMPT = "seon> ";
36
59
 
37
60
  /** dispatchTool("seonix_ask", …) returns the prose answer plus a delimited
38
- * machine-readable envelope (server.mjs §6.2); the TUI shows the prose only. */
61
+ * machine-readable envelope (server.mjs §6.2); the TUI shows the prose only.
62
+ * Reused verbatim when chat builds the same string from a direct ask() call
63
+ * (the focus/contextId path), so runTurn parses one envelope shape either way. */
39
64
  const ASK_ENVELOPE_DELIM = "\n\n---seonix_ask---\n";
40
65
 
66
+ /** The context pronouns a focus can stand in for — a bare `it`/`this`/`that`/`here`
67
+ * as a command arg reuses the focus, and the ask engine resolves the same words
68
+ * in a question against the contextId we thread through. */
69
+ const CONTEXT_WORDS = new Set(["it", "this", "that", "here"]);
70
+ const isPronoun = (s) => CONTEXT_WORDS.has(String(s || "").trim().toLowerCase());
71
+
72
+ /** Slash-command → (dispatchTool name, arg key). Arg keys are the EXACT ones the
73
+ * server.mjs dispatchTool switch reads (members/subclasses take `class`;
74
+ * impact/exports take `module`; architecture takes `package`; search takes
75
+ * `query`; the rest take `symbol`) — never invented. `arg:null` → no-argument
76
+ * tool; `optional:true` → the arg may be omitted (whole-repo architecture). */
77
+ export const COMMANDS = {
78
+ find: { tool: "seonix_search", arg: "query", help: "lexical search across the graph" },
79
+ search: { tool: "seonix_search", arg: "query", help: "alias of /find" },
80
+ context: { tool: "seonix_context", arg: "symbol", help: "the sized edit bundle for a symbol (start here to change code)" },
81
+ snippet: { tool: "seonix_snippet", arg: "symbol", help: "exact source of one function/class/method" },
82
+ describe: { tool: "seonix_describe", arg: "symbol", help: "a symbol's definition, kind and relations" },
83
+ signature: { tool: "seonix_signature", arg: "symbol", help: "a symbol's signature only" },
84
+ members: { tool: "seonix_members", arg: "class", help: "the methods/attributes of a class" },
85
+ subclasses: { tool: "seonix_subclasses", arg: "class", help: "the subclasses of a class" },
86
+ impact: { tool: "seonix_impact", arg: "module", help: "what a change to this module reaches (impact closure)" },
87
+ callers: { tool: "seonix_callers", arg: "symbol", help: "functions that call this symbol" },
88
+ callees: { tool: "seonix_callees", arg: "symbol", help: "functions this symbol calls" },
89
+ tests: { tool: "seonix_tests_for", arg: "symbol", help: "the tests covering this symbol" },
90
+ untested: { tool: "seonix_untested", arg: null, help: "symbols with no covering test" },
91
+ history: { tool: "seonix_history", arg: "symbol", help: "the commit history of this symbol" },
92
+ exports: { tool: "seonix_exports", arg: "module", help: "a module's public exports" },
93
+ arch: { tool: "seonix_architecture", arg: "package", help: "the architecture overview (optional package filter)", optional: true },
94
+ };
95
+
96
+ /** Args that name a single graph entity — the ones a no-arg/pronoun command falls
97
+ * back to the focus for, and that update the focus on a successful resolve. */
98
+ const ENTITY_ARGS = new Set(["symbol", "module", "class"]);
99
+
100
+ // ---- aggregate / count queries — answered MECHANICALLY off the loaded graph
101
+ // header (individuals grouped by class, relation groups by predicate), not by
102
+ // dispatching to the ask engine. Deterministic, fully in-ethos. ----
103
+
104
+ /** singular+plural nouns a user might count → the individual `class` they map to. */
105
+ const COUNT_NOUNS = {
106
+ class: "Class", classes: "Class",
107
+ function: "Function", functions: "Function", func: "Function", funcs: "Function",
108
+ module: "Module", modules: "Module", file: "Module", files: "Module",
109
+ method: "Method", methods: "Method",
110
+ attribute: "Attribute", attributes: "Attribute",
111
+ variable: "GlobalVariable", variables: "GlobalVariable", global: "GlobalVariable", globals: "GlobalVariable",
112
+ commit: "Commit", commits: "Commit",
113
+ session: "Session", sessions: "Session",
114
+ };
115
+
116
+ /** class → [singular, plural] display noun, for echoing a count back in English. */
117
+ const CLASS_LABELS = {
118
+ Class: ["class", "classes"], Function: ["function", "functions"],
119
+ Module: ["module", "modules"], Method: ["method", "methods"],
120
+ Attribute: ["attribute", "attributes"], GlobalVariable: ["variable", "variables"],
121
+ Commit: ["commit", "commits"], Session: ["session", "sessions"],
122
+ };
123
+ const classNoun = (cls, n) => { const [s, p] = CLASS_LABELS[cls] || [cls, `${cls}s`]; return n === 1 ? s : p; };
124
+
125
+ /** Count individuals of a class in the loaded graph (live, not the header field). */
126
+ const countClass = (graph, cls) => graph.individuals.filter((i) => (i.class || "") === cls).length;
127
+
128
+ /** The classes this graph can actually count, as a human list ("classes, functions, …"). */
129
+ function countableKinds(graph) {
130
+ const present = new Set(graph.individuals.map((i) => i.class).filter(Boolean));
131
+ return Object.keys(CLASS_LABELS).filter((c) => present.has(c)).map((c) => CLASS_LABELS[c][1]);
132
+ }
133
+
134
+ /** Recognise a count/aggregate question and answer it from the graph header, or
135
+ * null if it isn't one (→ fall through to seonix_ask). "how many X [are there]",
136
+ * "count [the] X", "number of X". An unknown kind lists what it CAN count. */
137
+ export function answerCount(graph, query) {
138
+ if (!graph) return null;
139
+ const m = String(query).match(/\b(?:how many|number of|count(?:\s+the)?)\s+([a-z]+)\b/i);
140
+ if (!m) return null;
141
+ const noun = m[1].toLowerCase();
142
+ const cls = COUNT_NOUNS[noun];
143
+ if (!cls) {
144
+ return `I can't count "${noun}". I count: ${countableKinds(graph).join(", ")}. ` +
145
+ `Try "how many classes are there".`;
146
+ }
147
+ const n = countClass(graph, cls);
148
+ return `${n} ${classNoun(cls, n)}.`;
149
+ }
150
+
151
+ /** `/stats`: a one-screen overview of the graph — class counts, relationship
152
+ * (predicate) counts, and module/package totals — read straight off the header. */
153
+ export function renderStats(graph) {
154
+ const byClass = new Map();
155
+ for (const i of graph.individuals) { const c = i.class || "(unclassified)"; byClass.set(c, (byClass.get(c) || 0) + 1); }
156
+ const pad = (n) => String(n).padStart(6);
157
+ const classLines = [...byClass.entries()].sort((a, b) => b[1] - a[1]).map(([c, n]) => ` ${pad(n)} ${c}`);
158
+ const edgeLines = graph.relations.slice()
159
+ .filter((g) => g.count > 0)
160
+ .sort((a, b) => b.count - a.count)
161
+ .map((g) => ` ${pad(g.count)} ${g.predicate || g.prop || "(edge)"}`);
162
+ const modules = graph.individuals.filter((i) => (i.class || "") === "Module");
163
+ const pkgs = new Set(modules.map((m) => String(m.label).split("/")[0]));
164
+ return [
165
+ `graph overview — ${graph.individuals.length} entities.`,
166
+ `entities by class:`,
167
+ ...classLines,
168
+ `relationships by predicate:`,
169
+ ...(edgeLines.length ? edgeLines : [" (none recorded)"]),
170
+ `${modules.length} module(s) across ${pkgs.size} top-level package(s).`,
171
+ ].join("\n");
172
+ }
173
+
174
+ // ---- friendly handling of non-structural / conversational input ----
175
+
176
+ /** Greetings and small-talk openers that should get a friendly orientation line,
177
+ * never the raw grammar-miss hint. */
178
+ const GREETINGS = new Set([
179
+ "hi", "hello", "hey", "yo", "sup", "hiya", "howdy", "hey there", "hi there", "hello there",
180
+ "thanks", "thank you", "thankyou", "thx", "ty", "cheers", "ok", "okay", "cool",
181
+ ]);
182
+ const HELP_PHRASES = [
183
+ /^what can (you|u) do\??$/i, /^what do you do\??$/i, /^help( me)?\??$/i, /^\?+$/,
184
+ /^who are you\??$/i, /^what (is|are|r) (this|you)\??$/i, /^how do (i|you) work\??$/i,
185
+ ];
186
+ /** The structural verbs/nouns that mark a near-miss code question (→ keep the
187
+ * precise grammar hint, not the friendly nudge). */
188
+ const STRUCT_WORDS = new Set([
189
+ "import", "imports", "call", "calls", "use", "uses", "define", "defines", "defined",
190
+ "class", "classes", "function", "functions", "module", "modules", "method", "methods",
191
+ "subclass", "subclasses", "inherit", "inherits", "test", "tests", "touch", "touches",
192
+ "commit", "commits", "export", "exports", "caller", "callers", "callee", "callees",
193
+ "history", "where", "mentioned", "signature", "impact",
194
+ ]);
195
+
196
+ /** Does this look like small-talk / an orientation request rather than a
197
+ * (near-miss) structural question? Greetings & help-phrases always qualify; a
198
+ * very short input with no code-ish token (dotted/pathed/CamelCase name, "()",
199
+ * or a structural keyword) does too. */
200
+ export function isConversational(query) {
201
+ const raw = String(query).trim();
202
+ const q = raw.toLowerCase().replace(/[.!?]+$/, "").trim();
203
+ if (GREETINGS.has(q)) return true;
204
+ if (HELP_PHRASES.some((re) => re.test(raw))) return true;
205
+ const codeish = /[a-z][A-Z]|[_./]|\(\)/.test(raw) || q.split(/\s+/).some((w) => STRUCT_WORDS.has(w));
206
+ return q.split(/\s+/).filter(Boolean).length <= 3 && !codeish;
207
+ }
208
+
209
+ /** The short friendly orientation shown for conversational input. */
210
+ const FRIENDLY = [
211
+ "I answer questions about THIS codebase's structure — imports, calls, definitions,",
212
+ "history and counts. For example:",
213
+ " which modules import walk.mjs",
214
+ " what calls buildContextBundle",
215
+ " how many classes are there",
216
+ "/help for commands, /stats for an overview of the graph.",
217
+ ].join("\n");
218
+
219
+ // ---- conversational (ELIZA/Zork-manners) templated layer ----
220
+ // A small CLOSED set of human expressions handled with a TEMPLATED response BEFORE
221
+ // any graph dispatch — deterministic, zero-model. These resolve no code entity, so
222
+ // they record as plain turns with empty resolvedIds and never become mgx:asksAbout
223
+ // graph edges (same as /help). Register stays plain and short: this is a code tool.
224
+
225
+ /** Greetings → a short friendly line + one nudge. A couple carry a tasteful nod. */
226
+ const GREET = new Set([
227
+ "hi", "hello", "hey", "yo", "hiya", "howdy", "sup", "greetings",
228
+ "g'day", "gday", "hey there", "hi there", "hello there",
229
+ "good morning", "good afternoon", "good evening", "morning",
230
+ ]);
231
+ /** Acknowledgements → an "any time" style reply. */
232
+ const THANKS = new Set([
233
+ "thanks", "thank you", "thankyou", "thx", "ty", "ta", "cheers", "nice one",
234
+ "much appreciated", "cool thanks",
235
+ ]);
236
+ /** Farewells → a goodbye AND a clean end of session (same path as /exit). */
237
+ const BYE = new Set([
238
+ "bye", "goodbye", "quit", "exit", "see ya", "see you", "cya", "later", "farewell",
239
+ ]);
240
+ /** Elaboration asks → RE-RENDER the last answer verbosely (traversal + matches). */
241
+ const WHY = new Set([
242
+ "why", "how", "how so", "how come", "explain", "say more", "go on",
243
+ "elaborate", "tell me more", "more detail", "expand",
244
+ ]);
245
+
246
+ const GREETING = "Hi. Ask me about this codebase — imports, calls, definitions, history — or /help.";
247
+ /** A couple of expression-specific lines (a light Zork nod for "hello there"). */
248
+ const GREETING_LINES = {
249
+ "hello there": 'Hello there. (A hollow voice says, "fool.") Ask me about this codebase, or /help.',
250
+ "good morning": "Good morning. Ask me about this codebase, or /help.",
251
+ "good afternoon": "Good afternoon. Ask me about this codebase, or /help.",
252
+ "good evening": "Good evening. Ask me about this codebase, or /help.",
253
+ };
254
+ const ACK = "Any time. Ask another, or /help for what I can do.";
255
+ const FAREWELL = "Bye — flushing the session log. Come back with a question any time.";
256
+
257
+ /** Re-render the last answer in verbose form: the previous query + its full answer
258
+ * plus the ask envelope's traversal receipt and the matched entities (the detail a
259
+ * terse render trims). `empty:true` when there's no previous answer to expand. */
260
+ export function renderVerbose(last) {
261
+ if (!last || !last.answer) {
262
+ return { text: "No previous answer to expand yet — ask me a question first, then say \"why\" or \"say more\".", empty: true };
263
+ }
264
+ const lines = [`(expanding: ${last.query})`, last.answer];
265
+ const d = last.detail || null;
266
+ if (d?.traversal) lines.push(`traversal: ${d.traversal}`);
267
+ if (Array.isArray(d?.matches) && d.matches.length) {
268
+ lines.push(`matches (${d.matches.length}):`);
269
+ for (const m of d.matches) {
270
+ const type = m.type ? ` [${m.type}]` : "";
271
+ const mod = m.module ? ` — ${m.module}` : "";
272
+ lines.push(` ${m.label}${type}${mod}`);
273
+ }
274
+ }
275
+ return { text: lines.join("\n"), empty: false };
276
+ }
277
+
278
+ /** Recognise a conversational expression and return a templated turn result, or null
279
+ * to fall through to counts/ask. Handled BEFORE slash-commands' non-slash siblings:
280
+ * greetings, thanks, help/orientation, farewell (ends the session via `end:true`),
281
+ * and why/say-more (re-renders `ctx.last`). Turns carry empty resolvedIds so they
282
+ * never pollute the session graph with asksAbout edges. `ctx.last` is preserved —
283
+ * a conversational turn is not itself a "last answer" to expand. */
284
+ function conversationalTurn(line, ctx) {
285
+ const raw = String(line);
286
+ const q = raw.toLowerCase().replace(/[.!?]+$/, "").replace(/\s+/g, " ").trim();
287
+ const mk = (answer, { end = false, miss = false } = {}) => {
288
+ const ts = new Date().toISOString();
289
+ return {
290
+ answer,
291
+ logLines: [ts, `> ${raw}`, answer, ""],
292
+ record: { type: "turn", ts, query: raw, conversational: true, resolvedIds: [], answeredIds: [], miss },
293
+ focus: ctx.focus,
294
+ last: ctx.last, // a conversational turn never overwrites the last real answer
295
+ ...(end ? { end: true } : {}),
296
+ };
297
+ };
298
+ if (BYE.has(q)) return mk(FAREWELL, { end: true });
299
+ if (WHY.has(q)) { const v = renderVerbose(ctx.last); return mk(v.text, { miss: v.empty }); }
300
+ if (GREET.has(q)) return mk(GREETING_LINES[q] || GREETING);
301
+ if (THANKS.has(q)) return mk(ACK);
302
+ if (q === "help" || q === "?" || HELP_PHRASES.some((re) => re.test(raw))) return mk(FRIENDLY);
303
+ return null;
304
+ }
305
+
306
+ // ---- opt-in LLM fallback (chat-only; default OFF; miss-gated) ----
307
+ // ONLY fires when a chat flag (--with-claude / --with-copilot) is set AND the
308
+ // mechanical engine returns a genuine MISS on a bare question (after the
309
+ // conversational layer and slash-commands have had their turn). The subprocess runs
310
+ // in the target repo cwd with a timeout; ANY failure (tool absent, non-zero exit,
311
+ // unparseable output, timeout) degrades to the engine's honest miss — never a crash
312
+ // or a hang. The benchmark/digest/MCP paths never reach here (this is chat-only).
313
+
314
+ /** How long a fallback subprocess may run before it's killed (ms). */
315
+ export const LLM_TIMEOUT_MS = 60_000;
316
+
317
+ /** Provider specs: the binary, a cheap probe, and the argv for a non-interactive
318
+ * prompt on the CHEAPEST model. `claude -p` is a headless one-shot; the GitHub
319
+ * Copilot CLI (`gh copilot -- -p`) is an AGENTIC shell assistant oriented at running
320
+ * commands, so it's a poorer fit for graph Q&A — wired best-effort, limitation noted
321
+ * in the README. Both are asked to COMPILE one seonix command (see buildLlmPrompt). */
322
+ export const LLM_PROVIDERS = {
323
+ claude: {
324
+ label: "claude",
325
+ bin: "claude",
326
+ probe: ["--version"],
327
+ argv: (prompt) => ["-p", prompt, "--model", "haiku"],
328
+ },
329
+ copilot: {
330
+ label: "copilot",
331
+ bin: "gh",
332
+ probe: ["copilot", "--", "--version"],
333
+ argv: (prompt) => ["copilot", "--", "-p", prompt, "--allow-all-tools", "--no-color"],
334
+ },
335
+ };
336
+
337
+ /** The tool names a compiled fallback command may name — the exact dispatchTool tools
338
+ * chat already fronts (COMMANDS' targets) plus seonix_ask. A command naming anything
339
+ * else is rejected (→ the model's raw text is shown instead, clearly labelled). */
340
+ const FALLBACK_TOOLS = new Set([...Object.values(COMMANDS).map((c) => c.tool), "seonix_ask"]);
341
+
342
+ /** Default spawn: a thin spawnSync wrapper normalised to {status,stdout,stderr,error}.
343
+ * Injectable everywhere so tests never shell out to a real model. */
344
+ export function defaultSpawn(bin, args, { cwd, timeout, input = "" } = {}) {
345
+ const r = spawnSync(bin, args, { cwd, timeout, input, encoding: "utf8" });
346
+ return { status: r.status, stdout: r.stdout || "", stderr: r.stderr || "", error: r.error || null };
347
+ }
348
+
349
+ /** Build a fallback config from a provider name + an (injectable) spawn. Returns null
350
+ * for an unknown provider. `cwd` is the CHAT'S TARGET REPO — the subprocess runs there. */
351
+ export function makeFallback(provider, spawn = defaultSpawn, { cwd = process.cwd(), timeoutMs = LLM_TIMEOUT_MS } = {}) {
352
+ const spec = LLM_PROVIDERS[provider];
353
+ if (!spec) return null;
354
+ return { provider, label: spec.label, bin: spec.bin, probe: spec.probe, argv: spec.argv, spawn, cwd, timeoutMs };
355
+ }
356
+
357
+ /** One cheap probe (`--version`) to decide if the tool binary is present. A missing
358
+ * binary / non-zero exit / spawn error → false (chat then says so once and stays
359
+ * mechanical-only). */
360
+ export function probeLlm(fallback) {
361
+ try {
362
+ const r = fallback.spawn(fallback.bin, fallback.probe, { cwd: fallback.cwd, timeout: 5000, input: "" });
363
+ return !!r && !r.error && r.status === 0;
364
+ } catch { return false; }
365
+ }
366
+
367
+ /** The compile prompt: the user's question + the seonix query surface, asking for ONE
368
+ * command so the answer stays graph-grounded (we execute it mechanically). */
369
+ export function buildLlmPrompt(query) {
370
+ const toolLines = [
371
+ ` seonix_ask {"query":"<plain-English structural question>"} — the general NL graph query`,
372
+ ...Object.entries(COMMANDS).map(([, s]) => ` ${s.tool} {"${s.arg || "…"}":"…"} — ${s.help}`),
373
+ ];
374
+ return [
375
+ "You translate a question about a code graph into ONE seonix CLI command that answers it.",
376
+ "The graph is already indexed. Available tools and their JSON argument:",
377
+ ...toolLines,
378
+ "",
379
+ "Reply with EXACTLY one line and nothing else:",
380
+ ` seonix cli <tool> '<json-args>'`,
381
+ 'e.g. seonix cli seonix_ask \'{"query":"what calls fetchEntities"}\'',
382
+ "If no tool fits, reply with the single word: NONE",
383
+ "",
384
+ `Question: ${query}`,
385
+ ].join("\n");
386
+ }
387
+
388
+ /** Parse a compiled `seonix cli <tool> '<json>'` command out of the model's text.
389
+ * Returns {tool,args} on a valid, known-tool command with parseable JSON, else null. */
390
+ export function parseSeonixCommand(text) {
391
+ const m = String(text || "").match(/seonix\s+cli\s+(\w+)\s+['"]?(\{[\s\S]*?\})['"]?/);
392
+ if (!m) return null;
393
+ const tool = m[1];
394
+ if (!FALLBACK_TOOLS.has(tool)) return null;
395
+ let args;
396
+ try { args = JSON.parse(m[2]); } catch { return null; }
397
+ if (!args || typeof args !== "object") return null;
398
+ return { tool, args };
399
+ }
400
+
401
+ /** Run the opt-in fallback for a MISSED query. Preferred shape: the model COMPILES one
402
+ * seonix command, which we execute MECHANICALLY and show graph-grounded, labelled
403
+ * LLM-assisted. If it can't produce a valid command, its raw text is shown, clearly
404
+ * marked as coming from the external model (never silently blended with mechanical
405
+ * answers). ANY spawn failure/timeout/absence → null, so the caller keeps the honest
406
+ * miss. Async only to await dispatchTool. */
407
+ export async function runLlmFallback(query, { fallback, config, source }) {
408
+ if (!fallback) return null;
409
+ let res;
410
+ try {
411
+ res = fallback.spawn(fallback.bin, fallback.argv(buildLlmPrompt(query)), {
412
+ cwd: fallback.cwd, timeout: fallback.timeoutMs, input: "",
413
+ });
414
+ } catch { return null; } // spawn threw (e.g. ENOENT) — degrade to honest miss
415
+ if (!res || res.error || res.status !== 0) return null; // timeout/non-zero/absent
416
+ const text = String(res.stdout || "").trim();
417
+ if (!text) return null;
418
+ const cmd = parseSeonixCommand(text);
419
+ if (cmd) {
420
+ try {
421
+ const out = await dispatchTool(cmd.tool, cmd.args, { config, source });
422
+ return `[LLM-assisted via ${fallback.label}] compiled: seonix cli ${cmd.tool} '${JSON.stringify(cmd.args)}'\n${out}`;
423
+ } catch { /* the compiled command didn't run — fall through to the raw text */ }
424
+ }
425
+ // No usable command: show the model's own words, clearly NOT the deterministic engine.
426
+ return `[answer from external model ${fallback.label} — NOT the deterministic seonix engine]\n${text}`;
427
+ }
428
+
429
+ // ---- repo-root resolution: default the target to the GIT ROOT, not raw cwd ----
430
+
431
+ /** The git top-level for `cwd`, or null if not in a repo (or git is unavailable).
432
+ * Injected into runChat so tests exercise the fallback without a real git tree. */
433
+ export function gitToplevel(cwd = process.cwd()) {
434
+ try {
435
+ const r = spawnSync("git", ["rev-parse", "--show-toplevel"], { cwd, encoding: "utf8" });
436
+ if (r.status === 0) { const p = String(r.stdout || "").trim(); return p || null; }
437
+ } catch { /* git missing / not a repo — fall back to cwd */ }
438
+ return null;
439
+ }
440
+
41
441
  /** UUID v7 (RFC 9562): 48-bit big-endian unix-ms timestamp, then version/variant
42
442
  * bits over crypto-random tail — time-sortable, unlike crypto.randomUUID()'s v4. */
43
443
  export function uuidv7(now = Date.now()) {
@@ -55,24 +455,67 @@ function configFor(repoPath) {
55
455
  return repoPath ? { graphFile: join(repoPath, DEFAULT_GRAPH_REL) } : loadConfig();
56
456
  }
57
457
 
58
- /**
59
- * One chat turn: query { answer, logLines, record }. Pure of any TTY/stream
60
- * concerns so tests exercise it directly. A grammar miss is a normal answer (the
61
- * engine's own hint text); a thrown ToolError becomes the answer too a turn
62
- * never crashes the session.
63
- *
64
- * `record` is the structured sidecar entry (sessions.mjs): `answeredIds` are the
65
- * entity ids the answer cited, straight from the engine's machine envelope;
66
- * `resolvedIds` is the engine's own resolveObject hit for the parsed object term
67
- * (public ask.mjs surface, lazily imported and failure-tolerated so concurrent
68
- * evolution of the engine can never crash a turn — worst case the sidecar records
69
- * fewer ids, never wrong ones).
70
- */
71
- export async function runTurn(query, { config, source = defaultSource, graph = null } = {}) {
458
+ /** Resolve a free-text term to a single graph entity via the ask engine's own
459
+ * tiered resolver {id,label} on a UNIQUE hit, null on a miss/ambiguity/no graph.
460
+ * Lazy + failure-tolerated (see the file docblock): the worst case is a turn that
461
+ * records fewer ids / does not update the focus, never a crash or a wrong id. */
462
+ async function resolveEntity(graph, term) {
463
+ if (!graph || !term) return null;
464
+ try {
465
+ const { resolveObject } = await import("./ask.mjs");
466
+ const r = resolveObject(graph, term);
467
+ if (r?.match?.id && !r.ambiguous) return { id: r.match.id, label: r.match.label };
468
+ } catch { /* tolerated */ }
469
+ return null;
470
+ }
471
+
472
+ /** The `/help` body: the bare-question default + every command (from COMMANDS, so
473
+ * it can't drift) + the seonix_ask question shapes, pulled from the engine's own
474
+ * rephraseHint() so the shapes are exactly the ones the grammar supports. */
475
+ export async function helpText() {
476
+ const rows = [
477
+ ["<question>", "ask the graph in plain English (the default for any non-slash line)"],
478
+ ...Object.entries(COMMANDS).map(([name, s]) => [`/${name}${s.arg ? (s.optional ? ` [${s.arg}]` : ` <${s.arg}>`) : ""}`, s.help]),
479
+ ["/stats", "a one-screen overview: entity counts, relationship counts, packages"],
480
+ ["/focus <symbol>", "set the current focus (reused by 'it'/'this' and no-arg entity commands)"],
481
+ ["/help", "this list"],
482
+ ["/exit", "leave the session (also Ctrl+C / Ctrl+D)"],
483
+ ];
484
+ const w = Math.max(...rows.map(([a]) => a.length));
485
+ const lines = rows.map(([a, b]) => ` ${a.padEnd(w)} ${b}`);
486
+ let shapes;
487
+ try { const { rephraseHint } = await import("./ask.mjs"); shapes = rephraseHint(); }
488
+ catch {
489
+ shapes = '"which <functions|classes|modules> <import|call|use|test|touch> <name>", ' +
490
+ '"what does <name> <import|export>", "what uses <name>", "where is <name> defined", ' +
491
+ '"when did <name> change"';
492
+ }
493
+ return [
494
+ "commands:", ...lines, "",
495
+ "question shapes for a bare line (seonix_ask):", ` ${shapes}`,
496
+ 'plus counts: "how many <classes|functions|modules|methods|commits> are there".',
497
+ ].join("\n");
498
+ }
499
+
500
+ /** A bare question → seonix_ask. When a focus is set AND the graph is in hand we
501
+ * call ask() directly to thread the focus as contextId (so a pronoun like "it"
502
+ * resolves to the focus) — building the SAME delimited string dispatchTool emits;
503
+ * otherwise the unchanged dispatchTool path (which also yields the no-graph error).
504
+ * A hit updates the focus to the resolved object. Grammar miss / ToolError → a
505
+ * normal answer, never a crash. */
506
+ async function runAsk(query, { config, source, graph, focus, fallback = null }) {
507
+ const ts = new Date().toISOString();
72
508
  let answer;
73
509
  let envelope = null;
74
510
  try {
75
- const text = await dispatchTool("seonix_ask", { query }, { config, source });
511
+ let text;
512
+ if (graph && focus?.id) {
513
+ const { ask } = await import("./ask.mjs");
514
+ const r = ask(graph, query, { contextId: focus.id });
515
+ text = `${r.content}${ASK_ENVELOPE_DELIM}${JSON.stringify(r.seonix_ask, null, 2)}`;
516
+ } else {
517
+ text = await dispatchTool("seonix_ask", { query }, { config, source });
518
+ }
76
519
  const [content, envJson] = text.split(ASK_ENVELOPE_DELIM);
77
520
  answer = content;
78
521
  if (envJson) { try { envelope = JSON.parse(envJson); } catch { envelope = null; } }
@@ -80,22 +523,140 @@ export async function runTurn(query, { config, source = defaultSource, graph = n
80
523
  answer = String(e?.message || e);
81
524
  }
82
525
  // A grammar miss has parsed:null → stays []. An empty-RESULT query (object resolved,
83
- // no edges) still records the resolved subject — that IS the asksAbout signal.
526
+ // no edges) still records the resolved subject — that IS the asksAbout signal, and
527
+ // it becomes the new focus so a follow-up "what calls it" can reuse it.
84
528
  let resolvedIds = [];
529
+ let newFocus = focus;
85
530
  if (graph && envelope?.parsed?.object) {
86
- try {
87
- const { resolveObject } = await import("./ask.mjs");
88
- const r = resolveObject(graph, envelope.parsed.object);
89
- if (r?.match?.id && !r.ambiguous) resolvedIds = [r.match.id];
90
- } catch { /* tolerated — see docblock */ }
531
+ const ent = await resolveEntity(graph, envelope.parsed.object);
532
+ if (ent) { resolvedIds = [ent.id]; newFocus = ent; }
91
533
  }
92
534
  const answeredIds = (envelope?.matches || []).map((m) => m?.id).filter(Boolean);
93
- const ts = new Date().toISOString();
94
- const record = { type: "turn", ts, query, resolvedIds, answeredIds, miss: envelope ? !!envelope.miss : true };
535
+ const miss = envelope ? !!envelope.miss : true;
536
+ // On a MISS: an opt-in LLM fallback (flag set) gets first refusal on a genuine
537
+ // (non-conversational, bare) question — it degrades to the honest miss on any
538
+ // failure, so `answer` stays the engine's own hint when it returns null. A
539
+ // conversational miss (a greeting, "what can you do", a very short non-code line)
540
+ // gets the friendly orientation instead of the raw grammar hint. A near-miss
541
+ // STRUCTURAL question keeps the precise hint the engine already produced.
542
+ if (miss) {
543
+ if (fallback && !isConversational(query)) {
544
+ const fb = await runLlmFallback(query, { fallback, config, source });
545
+ if (fb != null) answer = fb;
546
+ } else if (isConversational(query)) {
547
+ answer = FRIENDLY;
548
+ }
549
+ }
550
+ const record = { type: "turn", ts, query, resolvedIds, answeredIds, miss };
95
551
  const logLines = [ts, `> ${query}`, answer, ""];
96
- return { answer, logLines, record };
552
+ // `detail` feeds why/say-more's verbose re-render: the traversal receipt + the
553
+ // matched entities the terse render trims (see renderVerbose).
554
+ const detail = envelope ? { traversal: envelope.traversal || null, matches: envelope.matches || [] } : null;
555
+ return { answer, logLines, record, focus: newFocus, detail };
556
+ }
557
+
558
+ /** A non-ask, non-dispatch chat turn (count answer, /stats) — the same
559
+ * { answer, logLines, record, focus } shape, recorded like any other turn. */
560
+ function plainTurn(query, answer, { command, miss = false, focus = null } = {}) {
561
+ const ts = new Date().toISOString();
562
+ return {
563
+ answer,
564
+ logLines: [ts, `> ${query}`, answer, ""],
565
+ record: { type: "turn", ts, query, ...(command ? { command } : {}), resolvedIds: [], answeredIds: [], miss },
566
+ focus,
567
+ };
568
+ }
569
+
570
+ /** A slash-command → the mapped tool (or the /help, /focus, unknown cases). Returns
571
+ * the same { answer, logLines, record, focus } shape as runAsk; the record carries
572
+ * the command name and the resolved entity id (for entity commands) so a
573
+ * slash-command turn becomes asksAbout graph data wherever it resolves an entity. */
574
+ async function runCommand(line, { config, source, graph, focus }) {
575
+ const ts = new Date().toISOString();
576
+ const sp = line.indexOf(" ");
577
+ const name = (sp === -1 ? line.slice(1) : line.slice(1, sp)).toLowerCase();
578
+ const argText = (sp === -1 ? "" : line.slice(sp + 1)).trim();
579
+ const mk = (answer, { resolvedIds = [], miss = false, newFocus = focus } = {}) => ({
580
+ answer,
581
+ logLines: [ts, `> ${line}`, answer, ""],
582
+ record: { type: "turn", ts, query: line, command: name, resolvedIds, answeredIds: [], miss },
583
+ focus: newFocus,
584
+ });
585
+
586
+ if (name === "help") return mk(await helpText());
587
+ if (name === "stats") return graph ? mk(renderStats(graph)) : mk("no graph loaded — /stats needs an index.", { miss: true });
588
+
589
+ if (name === "focus") {
590
+ if (!argText) return mk(focus ? `focus is ${focus.label}` : "no focus set — /focus <symbol> to set one.");
591
+ const ent = await resolveEntity(graph, isPronoun(argText) ? focus?.label : argText);
592
+ if (!ent) return mk(`could not resolve "${argText}" to a single entity — focus unchanged${focus ? ` (still ${focus.label})` : ""}.`, { miss: true });
593
+ return mk(`focus set to ${ent.label}.`, { resolvedIds: [ent.id], newFocus: ent });
594
+ }
595
+
596
+ const spec = COMMANDS[name];
597
+ if (!spec) return mk(`unknown command /${name} — type /help for the list of commands.`, { miss: true });
598
+
599
+ const entityArg = ENTITY_ARGS.has(spec.arg);
600
+ let value = argText;
601
+ if (entityArg && (!value || isPronoun(value))) value = focus?.label || "";
602
+ if (spec.arg && !spec.optional && !value) {
603
+ const need = entityArg ? `${spec.arg} (none given and no focus set — /focus <x> or pass one)` : spec.arg;
604
+ return mk(`/${name} needs a ${need}.`, { miss: true });
605
+ }
606
+
607
+ let answer;
608
+ try {
609
+ answer = await dispatchTool(spec.tool, spec.arg ? { [spec.arg]: value } : {}, { config, source });
610
+ } catch (e) {
611
+ return mk(String(e?.message || e), { miss: true }); // the tool's own clean error, never a stack
612
+ }
613
+ // Entity commands resolve their subject for the sidecar/graph AND set the focus so a
614
+ // follow-up ("what calls it", a no-arg /context) reuses it.
615
+ if (entityArg) {
616
+ const ent = await resolveEntity(graph, value);
617
+ if (ent) return mk(answer, { resolvedIds: [ent.id], newFocus: ent });
618
+ }
619
+ return mk(answer);
97
620
  }
98
621
 
622
+ /**
623
+ * One chat turn: input → { answer, logLines, record, focus }. Pure of any
624
+ * TTY/stream concerns so tests exercise it directly. A leading `/` routes to a
625
+ * slash-command; anything else is a bare seonix_ask question. `focus` in is the
626
+ * current focus entity ({id,label}) or null; `focus` out is the (possibly updated)
627
+ * focus the caller should carry into the next turn. A turn never crashes the
628
+ * session — a grammar miss, an unknown command and a bad-symbol ToolError all come
629
+ * back as ordinary answers.
630
+ *
631
+ * `record` is the structured sidecar entry (sessions.mjs): `resolvedIds` is the
632
+ * primary entity the turn resolved (the ask object term, or a slash-command's
633
+ * subject), `answeredIds` the entity ids an ask answer cited; a slash-command turn
634
+ * also carries its `command` name. Both drive the mgx:asksAbout graph append.
635
+ */
636
+ export async function runTurn(input, { config, source = defaultSource, graph = null, focus = null, last = null, fallback = null } = {}) {
637
+ const line = String(input ?? "").trim();
638
+ const ctx = { config, source, graph, focus, last, fallback };
639
+ // A DISPATCHED turn (count / slash-command / ask) becomes the new "last answer"
640
+ // that why/say-more re-renders; a conversational turn does not (it preserves it).
641
+ const withLast = (result) => ({ ...result, last: { query: line, answer: result.answer, detail: result.detail ?? null } });
642
+
643
+ // Conversational layer first (greetings, thanks, help, bye, why/say-more) — these
644
+ // resolve no entity and carry their own preserved `last`.
645
+ const convo = conversationalTurn(line, ctx);
646
+ if (convo) return convo;
647
+
648
+ if (line.startsWith("/")) return withLast(await runCommand(line, ctx));
649
+ // Aggregate/count questions are answered mechanically off the loaded graph header,
650
+ // BEFORE falling through to the ask engine (focus unchanged — a count names no entity).
651
+ const count = answerCount(graph, line);
652
+ if (count != null) return withLast(plainTurn(line, count, { focus }));
653
+ return withLast(await runAsk(line, ctx));
654
+ }
655
+
656
+ /** Trim a focus label for the prompt so a long module path can't run the line off. */
657
+ const shortLabel = (l) => { const s = String(l); return s.length > 40 ? "…" + s.slice(-39) : s; };
658
+ const promptFor = (focus) => (focus ? `seon(${shortLabel(focus.label)})> ` : PROMPT);
659
+
99
660
  /**
100
661
  * The interactive shell. Streams are injectable so tests run scripted sessions
101
662
  * without a TTY. Throws the source layer's own ToolError (pointing at
@@ -108,13 +669,27 @@ export async function runChat({
108
669
  output = process.stdout,
109
670
  source = defaultSource,
110
671
  env = process.env,
672
+ cwd = process.cwd(),
673
+ gitRoot = gitToplevel,
674
+ withClaude = false,
675
+ withCopilot = false,
676
+ spawn = defaultSpawn,
111
677
  } = {}) {
112
- const repo = repoPath || process.cwd();
113
- const config = configFor(repoPath);
678
+ // Default the target to the GIT ROOT, not raw cwd: running from a nested package
679
+ // dir (npm sets cwd there) would otherwise index only that package's ~few modules
680
+ // instead of the whole repo. --repo stays the explicit override.
681
+ let repo;
682
+ let config;
683
+ if (repoPath) { repo = repoPath; config = configFor(repoPath); }
684
+ else {
685
+ const root = gitRoot(cwd);
686
+ if (root) { repo = root; config = configFor(root); }
687
+ else { repo = cwd; config = loadConfig(env, cwd); } // not a git repo — cwd/env default
688
+ }
114
689
 
115
- // Load the graph once up front — the banner needs the module count, and a
116
- // missing/empty artifact should fail HERE with the server's own helpful error,
117
- // not on the first question.
690
+ // Load the graph once up front — the banner needs the module count, focus/`it`
691
+ // resolution and contextId threading need it in hand, and a missing/empty
692
+ // artifact should fail HERE with the server's own helpful error, not on turn one.
118
693
  const graph = parseEntities(await source.fetchEntities(config));
119
694
  const moduleCount = graph.individuals.filter((i) => (i.class || "") === "Module").length;
120
695
  const { version } = JSON.parse(await readFile(new URL("../package.json", import.meta.url), "utf8"));
@@ -152,7 +727,23 @@ export async function runChat({
152
727
 
153
728
  const dim = (s) => (env.NO_COLOR || !output.isTTY ? s : `\x1b[2m${s}\x1b[0m`);
154
729
  output.write(dim(`seonix chat — ${repo} — ${moduleCount} module(s) — log ${logFile}`) + "\n");
155
- output.write(dim("ask a question about this codebase /exit to leave") + "\n");
730
+ output.write(dim("pass --repo <path> to target a different repo") + "\n");
731
+ output.write(dim("ask a question, or /help for commands (/stats for an overview) — /exit to leave") + "\n");
732
+
733
+ // Opt-in LLM fallback (chat-only, default OFF). Probe the tool ONCE at startup: an
734
+ // absent binary is reported and the flag disabled, so the session stays mechanical-
735
+ // only and never hangs on a missing tool. --with-claude wins if both flags are set.
736
+ let fallback = null;
737
+ const wanted = withClaude ? "claude" : withCopilot ? "copilot" : null;
738
+ if (wanted) {
739
+ const cand = makeFallback(wanted, spawn, { cwd: repo });
740
+ if (probeLlm(cand)) {
741
+ fallback = cand;
742
+ output.write(dim(`LLM fallback: ${cand.label} — used ONLY when the mechanical engine misses a bare question`) + "\n");
743
+ } else {
744
+ output.write(dim(`LLM fallback requested (${wanted}) but its binary was not found — continuing mechanical-only`) + "\n");
745
+ }
746
+ }
156
747
 
157
748
  const rl = createInterface({ input, output, prompt: PROMPT });
158
749
  rl.on("SIGINT", () => rl.close()); // Ctrl+C behaves like /exit (clean close, log flushed)
@@ -161,18 +752,24 @@ export async function runChat({
161
752
  const prompt = () => { if (!closed) rl.prompt(); }; // input may end while a turn is in flight
162
753
 
163
754
  let turns = 0;
755
+ let focus = null; // the current focus entity ({id,label}) — threaded turn to turn
756
+ let last = null; // the last dispatched answer ({query,answer,detail}) — why/say-more re-renders it
164
757
  prompt();
165
758
  for await (const raw of rl) { // Ctrl+D / closed stdin ends the iteration cleanly
166
759
  const line = raw.trim();
167
760
  if (line === "/exit") break;
168
761
  if (line) {
169
- const { answer, logLines, record } = await runTurn(line, { config, source, graph });
762
+ const { answer, logLines, record, focus: nextFocus, last: nextLast, end } = await runTurn(line, { config, source, graph, focus, last, fallback });
763
+ focus = nextFocus;
764
+ last = nextLast;
170
765
  output.write(answer + "\n");
171
766
  await writeLog(logLines.join("\n") + "\n");
172
767
  await writeSidecar(record);
173
768
  turnRecords.push(record);
174
769
  await upsertGraph(record.ts);
175
770
  turns += 1;
771
+ rl.setPrompt(promptFor(focus));
772
+ if (end) break; // a conversational "bye"/"goodbye" — clean end, same as /exit
176
773
  }
177
774
  prompt();
178
775
  }