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