@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/README.md +24 -114
- package/bin/cli.mjs +6 -9
- package/package.json +3 -4
- package/src/ask-browser-entry.mjs +43 -0
- package/src/ask-browser.bundle.js +3040 -0
- package/src/chat-shim.mjs +37 -0
- package/src/server.mjs +6 -6
- package/src/tmct-provider.mjs +37 -0
- package/src/viz.mjs +10 -15
- package/src/ask-nlp.mjs +0 -73
- package/src/ask-vocab.mjs +0 -687
- package/src/ask.mjs +0 -2419
- package/src/chat.mjs +0 -850
- package/src/nlp-bundle.mjs +0 -120
- package/src/prose-nlp.mjs +0 -52
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
|
-
}
|