@polycode-projects/the-mechanical-code-talker 0.2.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/server.mjs ADDED
@@ -0,0 +1,393 @@
1
+ // tmct tool layer — query-only tools over the deterministic typed code-graph
2
+ // artifact (<repo>/.tmct/graph.json). The graph source is a LOCAL file
3
+ // (src/source.mjs) and tmct_search is a LOCAL lexical lookup (no remote API,
4
+ // no LLM, no model calls anywhere). dispatchTool is the single internal switch
5
+ // the chat surface and the `cli <tool>` route call into.
6
+ //
7
+ // Tools (all query-only, bounded output): tmct_search, tmct_describe, tmct_snippet,
8
+ // tmct_impact, plus the §9 read-replacing tools tmct_members, tmct_subclasses,
9
+ // tmct_architecture, tmct_tests_for, tmct_untested, tmct_history, tmct_callers,
10
+ // tmct_callees. Each answers one question in ONE compact call so the caller need not
11
+ // Read/Grep. Errors reach the caller as clean tool errors — message only, never a stack.
12
+ //
13
+ // tmct_ask (hot tool): a mechanical, zero-model-call NL query over the graph —
14
+ // collapses the search+describe+traversal composition loop a caller would
15
+ // otherwise hand-compose into one deterministic round-trip. See ask.mjs.
16
+
17
+ import { readFile } from "node:fs/promises";
18
+ import { dirname, join } from "node:path";
19
+ import { ToolError } from "./config.mjs";
20
+ import * as defaultSource from "./source.mjs";
21
+ import {
22
+ parseEntities,
23
+ resolveSymbol,
24
+ renderDescribe,
25
+ renderImpact,
26
+ renderSearch,
27
+ siteOf,
28
+ renderMembers,
29
+ renderSubclasses,
30
+ renderArchitecture,
31
+ renderTestsFor,
32
+ renderUntested,
33
+ renderHistory,
34
+ renderCallers,
35
+ renderCallees,
36
+ renderCochanges,
37
+ renderExports,
38
+ renderSignature,
39
+ contextPlan,
40
+ sizeBundle,
41
+ bundleMask,
42
+ trimBundleMask,
43
+ renderContextMore,
44
+ renderCalls,
45
+ callHint,
46
+ renderFileHistory,
47
+ renderMethodHistory,
48
+ renderClassHistory,
49
+ } from "./codegraph.mjs";
50
+ import { ask } from "./ask.mjs";
51
+
52
+ const SNIPPET_MAX_LINES = 200;
53
+
54
+ // Tiered tool surface: the hot tools carry full descriptions/schemas in this
55
+ // catalog; every COLD tool (describe/members/impact/history/…) is still served
56
+ // by dispatchTool below and is reachable via the CLI `cli <tool>` route +
57
+ // the generated <repo>/.tmct/TOOLS.md catalog (renderToolsCatalog).
58
+ export const TOOLS = [
59
+ {
60
+ name: "tmct_context",
61
+ // Lean resident schema (re-billed every turn): the minimum that still steers the agent to
62
+ // ONE call → write, not Read.
63
+ description:
64
+ "START HERE to add/modify code: ONE call returns a sized edit bundle (exemplar source, sibling signatures, registration, insertion region) — then write directly, don't Read.",
65
+ inputSchema: {
66
+ type: "object",
67
+ required: ["symbol"],
68
+ properties: {
69
+ symbol: { type: "string", description: "Module path (django/utils/text.py) or a sibling function/class name in it (lower)." },
70
+ depth: { type: "string", enum: ["min", "auto", "full"], default: "auto", description: "auto (sized to the task) | min (leanest) | full (every section)." },
71
+ },
72
+ },
73
+ },
74
+ {
75
+ name: "tmct_snippet",
76
+ description: "EXACT source of one function/class/Class.method by name (its line span only) + a one-line in-repo call hint. Prefer over Read for a single symbol.",
77
+ inputSchema: {
78
+ type: "object",
79
+ required: ["symbol"],
80
+ properties: {
81
+ symbol: { type: "string", description: "function/class name (slugify, Truncator), Class.method, or fn:<path>#name." },
82
+ },
83
+ },
84
+ },
85
+ {
86
+ name: "tmct_ask",
87
+ description:
88
+ "Ask a structural question in plain English: \"which functions call X\", \"what uses X\", \"where is X defined\", \"when did X change\". One call, no model. A clean miss beats a guess.",
89
+ inputSchema: {
90
+ type: "object",
91
+ required: ["query"],
92
+ properties: {
93
+ query: { type: "string", description: "A free-text question, e.g. \"which functions explicitly couple to logging\"." },
94
+ },
95
+ },
96
+ },
97
+ ];
98
+
99
+ async function loadGraph(config, source) {
100
+ const payload = await source.fetchEntities(config);
101
+ const graph = parseEntities(payload);
102
+ if (!graph.individuals.length) {
103
+ // Honest miss, never a stack: a fresh repo simply has no graph yet (the chat
104
+ // session itself creates one as the conversation folds in).
105
+ throw new ToolError(
106
+ `the graph at ${config.graphFile} is empty — no entities to answer from yet ` +
107
+ "(this repo starts with no graph; the chat session folds the conversation into one).",
108
+ );
109
+ }
110
+ return graph;
111
+ }
112
+
113
+ function resolveOrThrow(graph, symbol, what) {
114
+ const { match, candidates } = resolveSymbol(graph, symbol);
115
+ if (!match) {
116
+ throw new ToolError(
117
+ `no entity matching ${what} "${symbol}" in the code-map graph. ` +
118
+ "Try a repo-relative path (e.g. django/utils/text.py), a basename, or tmct_search for a fuzzy lookup.",
119
+ );
120
+ }
121
+ return { match, candidates };
122
+ }
123
+
124
+ /**
125
+ * Build the tmct_context "edit bundle" for a symbol and return { text, tier, topup }.
126
+ * Shared by the tmct_context tool AND the `cli digest` arm (cli.mjs), so both
127
+ * benefit from the size-adaptive sizing for free. `trim:true` (B2) renders a SECONDARY,
128
+ * signatures-only bundle (no bodies/tails) for related-but-not-primary digest modules.
129
+ *
130
+ * Section ORDER is cache-stable (B7): the content that is identical across runs (anchor /
131
+ * registration / exemplar / siblings / __all__ / insertion region) comes FIRST; the more
132
+ * variable, history-derived tails (covering tests, co-change) come LAST, so a stable prefix
133
+ * maximises prompt-cache reuse.
134
+ */
135
+ export async function buildContextBundle(args, { config, source = defaultSource, trim = false } = {}) {
136
+ const symbol = String(args?.symbol || "").trim();
137
+ if (!symbol) throw new ToolError("symbol is required");
138
+ const depth = String(args?.depth || "auto").trim().toLowerCase();
139
+ // Tuning-flag contract: `min` forces the LEANEST bundle (TINY mask, no top-up) regardless of
140
+ // exemplar length; `untuned` reproduces the earlier escalation (tuning #1 bypassed). Neither
141
+ // → the tuned default (sizeBundle's anchor-gated escalation).
142
+ const min = Boolean(args?.min);
143
+ const untuned = Boolean(args?.untuned);
144
+ // `max` forces the injection CEILING — FULL tier (every section + inlined depth-1 callee
145
+ // bodies) with top-up, and it OVERRIDES trim so even secondary modules get the full bundle. Used
146
+ // by the tmct-max arm to test whether more injection re-bloats.
147
+ const max = Boolean(args?.max);
148
+ const graph = await loadGraph(config, source);
149
+ const { match } = resolveOrThrow(graph, symbol, "symbol");
150
+ const plan = contextPlan(graph, match);
151
+ // #6/B1/B6: pick the section mask by depth — min forces TINY, full/max forces everything, auto
152
+ // runs the size classifier (lean TINY default + one-tier top-up when the edit needs it).
153
+ let tier;
154
+ let mask;
155
+ let topup = false;
156
+ if (min || depth === "min") { tier = "TINY"; mask = bundleMask("TINY"); }
157
+ else if (max || depth === "full") { tier = "FULL"; mask = bundleMask("FULL"); topup = true; }
158
+ else ({ tier, mask, topup } = sizeBundle(plan, graph, { untuned }));
159
+ if (trim && !max) mask = trimBundleMask(mask); // B2: secondary digest module → signatures + region only (max keeps the full bundle)
160
+ const repoRoot = dirname(dirname(config.graphFile));
161
+ let lines = null;
162
+ if (plan.moduleLabel) {
163
+ try { lines = (await readFile(join(repoRoot, plan.moduleLabel), "utf8")).split("\n"); }
164
+ catch { lines = null; }
165
+ }
166
+ const lineAt = (n) => (lines && lines[n - 1] != null ? lines[n - 1].trim() : "");
167
+ const sliceBody = (start, end) => {
168
+ const e = Math.min(lines.length, Math.min(end, start + SNIPPET_MAX_LINES - 1));
169
+ return lines.slice(start - 1, e).map((l, i) => `${start + i}\t${l}`).join("\n");
170
+ };
171
+ const out = [
172
+ `Edit context for ${plan.moduleLabel} [${tier}${trim ? " secondary" : ""}] — assembled from the typed graph + that file. ` +
173
+ "You do NOT need to Read it; write the new code directly after reviewing this.",
174
+ ];
175
+ // ---- cache-stable prefix (B7): identical across runs ----
176
+ if (mask.anchor && plan.anchor?.site && lines) {
177
+ const { start, end } = plan.anchor.site;
178
+ out.push(`\n## anchor: ${plan.anchor.label} (${plan.anchor.class}) @ ${plan.moduleLabel}:${start}-${end}`);
179
+ out.push(sliceBody(start, end));
180
+ if (plan.callHint) out.push(plan.callHint);
181
+ }
182
+ if (mask.registration && plan.globals.length) {
183
+ out.push(`\n## registration / module globals (replicate this pattern):`);
184
+ for (const g of plan.globals) out.push(` ${g.label} = ${g.value}${g.site ? ` [:${g.site.start}]` : ""}`);
185
+ }
186
+ if (mask.exemplar && plan.exemplar?.site && lines) {
187
+ const { start, end } = plan.exemplar.site;
188
+ const dec = plan.exemplar.decorators ? ` @${plan.exemplar.decorators}` : "";
189
+ out.push(`\n## closest example (full body) — copy this style: ${plan.exemplar.label} (${plan.exemplar.class})${dec} @ ${plan.moduleLabel}:${start}-${end}`);
190
+ out.push(sliceBody(start, end));
191
+ if (plan.callHint) out.push(plan.callHint);
192
+ }
193
+ if (mask.inlinedCallees && plan.calleeBodies.length && lines) {
194
+ let budget = 120; // INLINE_CALLEE_LOC
195
+ for (const cb of plan.calleeBodies) {
196
+ if (budget <= 0) break;
197
+ const start = cb.site.start;
198
+ const end = Math.min(cb.site.end, start + budget - 1);
199
+ const fromThisFile = cb.site.path === plan.moduleLabel;
200
+ const bodyLines = fromThisFile && lines
201
+ ? lines
202
+ : await readFile(join(repoRoot, cb.site.path), "utf8").then((t) => t.split("\n")).catch(() => null);
203
+ if (!bodyLines) continue;
204
+ const e = Math.min(bodyLines.length, end);
205
+ out.push(`\n## inlined callee body (depth-1 in-repo call): ${cb.label} @ ${cb.site.path}:${start}-${cb.site.end}`);
206
+ out.push(bodyLines.slice(start - 1, e).map((l, i) => `${start + i}\t${l}`).join("\n"));
207
+ budget -= (e - start + 1);
208
+ }
209
+ }
210
+ if (mask.classMembers && plan.classMembers && plan.classMembers.members.length) {
211
+ out.push(`\n## members of ${plan.classMembers.className} (the edit likely lives INSIDE this class — copy a member's shape, do not read the class body):`);
212
+ for (const m of plan.classMembers.members) {
213
+ const short = String(m.label).split(".").pop();
214
+ const sig = m.params != null && m.params !== "" ? `(${m.params})${m.returns ? ` -> ${m.returns}` : ""}` : "";
215
+ const dec = m.decorators ? `@${m.decorators} ` : "";
216
+ const r = m.raises ? ` raises=${m.raises}` : "";
217
+ out.push(` ${m.class} ${short}${m.site ? ` :${m.site.start}` : ""} ${dec}${short}${sig}${r}`);
218
+ }
219
+ }
220
+ if (mask.siblings && plan.siblings.length) {
221
+ out.push(`\n## sibling symbols to copy the style of (most relevant first; ${plan.siblings.length} total):`);
222
+ for (const s of plan.siblings.slice(0, plan.siblingCap)) {
223
+ const sig = s.site ? lineAt(s.site.start) : "";
224
+ const dec = s.decorators ? `@${s.decorators} ` : "";
225
+ const r = s.raises ? ` raises=${s.raises}` : "";
226
+ out.push(` ${s.class} ${s.label}${s.site ? ` :${s.site.start}` : ""} ${dec}${sig}${r}`);
227
+ }
228
+ if (plan.siblings.length > plan.siblingCap) {
229
+ out.push(` …+${plan.siblings.length - plan.siblingCap} more (use tmct_search kind=function or tmct_snippet <name> for any of them)`);
230
+ }
231
+ }
232
+ if (mask.allExports && plan.allExports) {
233
+ out.push(`\n## module __all__ — this module curates its public API; ADD your new public symbol to this list so it is importable:\n ${plan.allExports}`);
234
+ }
235
+ if (mask.reexports && plan.exports && plan.exports.length) out.push(`\n## re-exported symbols (resolved __all__ → defining module): ${plan.exports.join(", ")}`);
236
+ // #2 + B4: the contiguous insertion region is part of the STABLE prefix — always present
237
+ // (even at TINY) so the agent never needs to Read the file to place the edit.
238
+ if (mask.insertionRegion && plan.insertionRegion && lines) {
239
+ const start = plan.insertionRegion.start;
240
+ const end = Math.min(lines.length, start + 40 - 1); // INSERTION_REGION_CAP
241
+ out.push(`\n## insertion region (write your new sibling here) — ${plan.moduleLabel}:${start}-${end}`);
242
+ out.push(lines.slice(start - 1, end).map((l, i) => `${start + i}\t${l}`).join("\n"));
243
+ } else if (plan.insertion) {
244
+ out.push(`\n## insert the new sibling after line ~${plan.insertion} (end of the last top-level definition).`);
245
+ }
246
+ // ---- variable tail (B7): history-derived, kept LAST so the prefix stays cache-stable ----
247
+ if (mask.tests && plan.tests.length) out.push(`\n## covering tests: ${plan.tests.join(", ")}`);
248
+ if (mask.cochange && plan.cochange && plan.cochange.length) {
249
+ out.push(`\n## usually changed together (consider editing these too): ${plan.cochange.map((c) => `${c.label} (×${c.weight})`).join(", ")}`);
250
+ }
251
+ out.push(`\nYou now have the snippet, the sibling style, the registration anchor and the tests. ` +
252
+ `Write the new code with Edit/Write — do NOT Read ${plan.moduleLabel}.`);
253
+ if (tier !== "FULL") {
254
+ out.push(`(bundle tier ${tier}; for any omitted sections run tmct_context_more {"symbol":"${symbol}"}, or tmct_context with depth="full".)`);
255
+ }
256
+ return { text: out.join("\n"), tier, topup };
257
+ }
258
+
259
+ export async function dispatchTool(name, args, { config, source = defaultSource } = {}) {
260
+ if (name === "tmct_context") {
261
+ return (await buildContextBundle(args, { config, source })).text;
262
+ }
263
+ if (name === "tmct_context_more") {
264
+ const symbol = String(args?.symbol || "").trim();
265
+ if (!symbol) throw new ToolError("symbol is required");
266
+ const graph = await loadGraph(config, source);
267
+ const { match } = resolveOrThrow(graph, symbol, "symbol");
268
+ return renderContextMore(contextPlan(graph, match));
269
+ }
270
+ if (name === "tmct_describe") {
271
+ const symbol = String(args?.symbol || "").trim();
272
+ if (!symbol) throw new ToolError("symbol is required");
273
+ const graph = await loadGraph(config, source);
274
+ const { match, candidates } = resolveOrThrow(graph, symbol, "symbol");
275
+ return renderDescribe(graph, match, { candidates });
276
+ }
277
+ if (name === "tmct_snippet") {
278
+ const symbol = String(args?.symbol || "").trim();
279
+ if (!symbol) throw new ToolError("symbol is required");
280
+ const graph = await loadGraph(config, source);
281
+ const { match, candidates } = resolveOrThrow(graph, symbol, "symbol");
282
+ const site = siteOf(match);
283
+ if (!site) {
284
+ throw new ToolError(
285
+ `"${match.label}" (${match.class || "Entity"}) has no source span in the graph — ` +
286
+ "it is likely a module. Use tmct_describe for its contents, then tmct_snippet one of the functions/classes it defines.",
287
+ );
288
+ }
289
+ // repo root = the dir containing .tmct/ (graphFile = <repo>/.tmct/graph.json)
290
+ const repoRoot = dirname(dirname(config.graphFile));
291
+ const abs = join(repoRoot, site.path);
292
+ let text;
293
+ try { text = await readFile(abs, "utf8"); }
294
+ catch (e) { throw new ToolError(`could not read ${site.path} (${e?.code || e?.message || e})`); }
295
+ const lines = text.split("\n");
296
+ const start = Math.max(1, site.start);
297
+ let end = Math.min(lines.length, site.end);
298
+ let truncated = false;
299
+ if (end - start + 1 > SNIPPET_MAX_LINES) { end = start + SNIPPET_MAX_LINES - 1; truncated = true; }
300
+ const body = lines.slice(start - 1, end).map((l, i) => `${start + i}\t${l}`).join("\n");
301
+ const span = site.end > site.start ? `${site.start}-${site.end}` : `${site.start}`;
302
+ const header = `${match.label} — ${match.class || "Entity"} @ ${site.path}:${span}`;
303
+ const note = truncated ? `\n… (truncated to ${SNIPPET_MAX_LINES} lines; full span ${span})` : "";
304
+ const cand = candidates.length ? `\n(other matches: ${candidates.map((c) => c.label).join(", ")})` : "";
305
+ const hint = callHint(graph, match); // #4: one-line "calls in-repo: …" so the agent sees in-repo deps inline
306
+ return `${header}\n${body}${note}${hint ? `\n${hint}` : ""}${cand}`;
307
+ }
308
+ if (name === "tmct_signature") {
309
+ const symbol = String(args?.symbol || "").trim();
310
+ if (!symbol) throw new ToolError("symbol is required");
311
+ const graph = await loadGraph(config, source);
312
+ const { match } = resolveOrThrow(graph, symbol, "symbol");
313
+ return renderSignature(graph, match);
314
+ }
315
+ if (name === "tmct_impact") {
316
+ const module = String(args?.module || "").trim();
317
+ if (!module) throw new ToolError("module is required");
318
+ const graph = await loadGraph(config, source);
319
+ const { match } = resolveOrThrow(graph, module, "module");
320
+ return renderImpact(graph, match);
321
+ }
322
+ if (name === "tmct_search") {
323
+ const query = String(args?.query || "").trim();
324
+ const kind = String(args?.kind || "").trim();
325
+ if (!query && !kind) throw new ToolError("query is required");
326
+ const graph = await loadGraph(config, source);
327
+ return renderSearch(graph, query, {
328
+ kind,
329
+ decorator: String(args?.decorator || "").trim(),
330
+ name: String(args?.name || "").trim(),
331
+ });
332
+ }
333
+ if (name === "tmct_members") {
334
+ const symbol = String(args?.class || "").trim();
335
+ if (!symbol) throw new ToolError("class is required");
336
+ const graph = await loadGraph(config, source);
337
+ const { match } = resolveOrThrow(graph, symbol, "class");
338
+ return renderMembers(graph, match);
339
+ }
340
+ if (name === "tmct_subclasses") {
341
+ const symbol = String(args?.class || "").trim();
342
+ if (!symbol) throw new ToolError("class is required");
343
+ const graph = await loadGraph(config, source);
344
+ const { match } = resolveOrThrow(graph, symbol, "class");
345
+ return renderSubclasses(graph, match);
346
+ }
347
+ if (name === "tmct_architecture") {
348
+ const graph = await loadGraph(config, source);
349
+ return renderArchitecture(graph, { pkg: String(args?.package || "").trim() });
350
+ }
351
+ if (name === "tmct_exports") {
352
+ const module = String(args?.module || "").trim();
353
+ if (!module) throw new ToolError("module is required");
354
+ const graph = await loadGraph(config, source);
355
+ const { match } = resolveOrThrow(graph, module, "module");
356
+ return renderExports(graph, match);
357
+ }
358
+ if (name === "tmct_untested") {
359
+ const graph = await loadGraph(config, source);
360
+ return renderUntested(graph);
361
+ }
362
+ if (name === "tmct_ask") {
363
+ const query = String(args?.query || "").trim();
364
+ if (!query) throw new ToolError("query is required");
365
+ const graph = await loadGraph(config, source);
366
+ const { content, tmct_ask } = ask(graph, query);
367
+ // Every dispatchTool caller (the chat surface, the CLI fallback) expects a plain string —
368
+ // append the structured envelope as a delimited, machine-parseable block rather than
369
+ // changing that shared contract for one tool. PLAN_MECHANICAL_CHAT.md §6.2.
370
+ return `${content}\n\n---tmct_ask---\n${JSON.stringify(tmct_ask, null, 2)}`;
371
+ }
372
+ if (
373
+ name === "tmct_tests_for" || name === "tmct_history" || name === "tmct_callers" ||
374
+ name === "tmct_callees" || name === "tmct_cochanges" || name === "tmct_calls" ||
375
+ name === "tmct_file_history" || name === "tmct_method_history" || name === "tmct_class_history"
376
+ ) {
377
+ const symbol = String(args?.symbol || "").trim();
378
+ if (!symbol) throw new ToolError("symbol is required");
379
+ const graph = await loadGraph(config, source);
380
+ const { match } = resolveOrThrow(graph, symbol, "symbol");
381
+ if (name === "tmct_tests_for") return renderTestsFor(graph, match);
382
+ if (name === "tmct_history") return renderHistory(graph, match);
383
+ if (name === "tmct_callers") return renderCallers(graph, match);
384
+ if (name === "tmct_cochanges") return renderCochanges(graph, match);
385
+ if (name === "tmct_calls") return renderCalls(graph, match);
386
+ if (name === "tmct_file_history") return renderFileHistory(graph, match);
387
+ if (name === "tmct_method_history") return renderMethodHistory(graph, match);
388
+ if (name === "tmct_class_history") return renderClassHistory(graph, match);
389
+ return renderCallees(graph, match);
390
+ }
391
+ throw new ToolError(`unknown tool: ${name}`);
392
+ }
393
+
@@ -0,0 +1,220 @@
1
+ // sessions.mjs — chat sessions as first-class temporal graph data, like commits.
2
+ //
3
+ // A `tmct chat` session leaves two artifacts under the target repo:
4
+ // .tmct/session-<uuidv7>.log — the human-readable transcript (chat.mjs)
5
+ // .tmct/sessions/session-<uuidv7>.jsonl — the STRUCTURED sidecar this module owns:
6
+ // {"type":"session", id, started, repo, tmctVersion} (header line)
7
+ // {"type":"turn", ts, query, resolvedIds, answeredIds, miss} (one per turn, flushed)
8
+ // {"type":"end", ts} (clean close marker)
9
+ //
10
+ // From the sidecar the session enters the typed graph twice:
11
+ // - READ TIME (chat.mjs, per turn): appendSessionToGraph() upserts one `Session`
12
+ // individual (`session:<uuidv7>`) + `mgx:asksAbout` edges into graph.json —
13
+ // atomically (temp + rename), re-reading the file first so a re-index that
14
+ // happened mid-session is tolerated: edges whose targets vanished are dropped,
15
+ // never left dangling (honest degradation).
16
+ // - REBUILD (any future graph writer): readSessionRecords() + foldInSessions()
17
+ // re-attach every recorded session to a FRESH graph, re-resolving each recorded
18
+ // entity id (by id first, then by unique label derived from the id shape);
19
+ // unresolvable references are dropped and counted on the session node
20
+ // (mgx:sessionDroppedEdges) — never a guessed edge.
21
+ //
22
+ // Sessions are runtime observations, not source derivations: they record what a human
23
+ // asked the graph about and which entities answered, so re-indexing re-attaches them
24
+ // rather than re-deriving them from source.
25
+
26
+ import { mkdir, readFile, readdir, rename, writeFile } from "node:fs/promises";
27
+ import { dirname, join } from "node:path";
28
+
29
+ export const SESSIONS_DIR_REL = join(".tmct", "sessions");
30
+
31
+ export const SESSION_CLASS = "Session";
32
+ export const ASKS_ABOUT_PREDICATE = "asksAbout";
33
+ export const ASKS_ABOUT_PROP = "mgx:asksAbout";
34
+
35
+ const QUERIES_ATTR_CAP = 500; // joined-queries attribute cap (mirrors commitMessage's cap idea)
36
+
37
+ /** Session labels mirror Commit's short-sha convention: the uuid's leading time-ordered hex. */
38
+ const sessionLabel = (id) => String(id).slice(0, 8);
39
+
40
+ /** Best-effort label a recorded entity id would carry, derived from the id shape —
41
+ * the "then by label" tier of fold-in re-resolution (ids are `mod:<path>`,
42
+ * `fn:<path>#<name>`, `commit:<sha>`; labels are path / name / short sha). */
43
+ function labelFromId(id) {
44
+ const s = String(id);
45
+ if (s.startsWith("mod:")) return s.slice(4);
46
+ const fn = s.match(/^fn:.*#(.+)$/);
47
+ if (fn) return fn[1];
48
+ if (s.startsWith("commit:")) return s.slice(7, 19);
49
+ return null;
50
+ }
51
+
52
+ /** Atomic JSON write: temp file in the same directory + rename, so a concurrent
53
+ * reader never sees a torn graph.json and a crash never destroys the old one. */
54
+ async function atomicWriteJson(file, obj) {
55
+ const tmp = `${file}.tmp-${process.pid}-${Math.random().toString(36).slice(2, 10)}`;
56
+ await writeFile(tmp, JSON.stringify(obj));
57
+ await rename(tmp, file);
58
+ }
59
+
60
+ /**
61
+ * Upsert one session record into an `entities` payload (mutates; pure of I/O).
62
+ * Shared by the read-time append AND the re-index fold-in, so both resolve and
63
+ * degrade identically:
64
+ * - a prior copy of the same session (per-turn re-appends) is replaced;
65
+ * - every recorded ref resolves by id, else by UNIQUE label (ambiguous → drop);
66
+ * - dropped refs are counted on the session node, never guessed into edges.
67
+ * Record shape: { id, started, ended?, turns: [{ts, query, resolvedIds, answeredIds, miss}] }.
68
+ * Returns { kept, dropped }.
69
+ */
70
+ export function upsertSession(entities, record) {
71
+ const sid = `session:${record.id}`;
72
+ entities.individuals ||= [];
73
+ entities.objectProperties ||= [];
74
+
75
+ // replace any prior copy of this session (read-time appends run once per turn)
76
+ entities.individuals = entities.individuals.filter((i) => i?.id !== sid);
77
+ let group = entities.objectProperties.find((g) => g?.prop === ASKS_ABOUT_PROP);
78
+ if (group) {
79
+ group.examples = (group.examples || []).filter((e) => e?.subject !== sid);
80
+ } else {
81
+ group = { predicate: ASKS_ABOUT_PREDICATE, prop: ASKS_ABOUT_PROP, count: 0, examples: [] };
82
+ entities.objectProperties.push(group);
83
+ }
84
+
85
+ // resolve refs against the CURRENT individuals — by id, then by unique label
86
+ const byId = new Map();
87
+ const byLabel = new Map(); // label -> id, or null when ambiguous
88
+ for (const i of entities.individuals) {
89
+ if (!i?.id) continue;
90
+ byId.set(i.id, i);
91
+ if (i.label) byLabel.set(i.label, byLabel.has(i.label) ? null : i.id);
92
+ }
93
+ const turns = Array.isArray(record.turns) ? record.turns : [];
94
+ const refs = [...new Set(turns.flatMap((t) => [...(t?.resolvedIds || []), ...(t?.answeredIds || [])]))];
95
+ const targets = [];
96
+ let dropped = 0;
97
+ for (const ref of refs) {
98
+ if (byId.has(ref)) { targets.push(ref); continue; }
99
+ const cand = labelFromId(ref);
100
+ const viaLabel = cand ? byLabel.get(cand) : undefined;
101
+ if (viaLabel) { targets.push(viaLabel); continue; }
102
+ dropped += 1; // vanished or ambiguous — an honest drop, never a dangling/guessed edge
103
+ }
104
+
105
+ const started = record.started || "";
106
+ const ended = record.ended || turns.at(-1)?.ts || started;
107
+ const queries = turns.map((t) => String(t?.query || "")).filter(Boolean);
108
+ const label = sessionLabel(record.id);
109
+ entities.individuals.push({
110
+ id: sid, label, class: SESSION_CLASS,
111
+ derived_from: [], mentions: [],
112
+ attributes: [
113
+ { prop: "mgx:sessionStarted", key: "started", value: started },
114
+ { prop: "mgx:sessionEnded", key: "ended", value: ended },
115
+ { prop: "mgx:sessionTurns", key: "turns", value: String(turns.length) },
116
+ ...(queries.length ? [{ prop: "mgx:sessionQueries", key: "queries", value: queries.join(" | ").slice(0, QUERIES_ATTR_CAP) }] : []),
117
+ ...(dropped ? [{ prop: "mgx:sessionDroppedEdges", key: "dropped", value: String(dropped) }] : []),
118
+ ],
119
+ });
120
+ for (const t of targets) {
121
+ group.examples.push({ subject: sid, object: t, subjectLabel: label, objectLabel: byId.get(t)?.label || t });
122
+ }
123
+ group.count = group.examples.length;
124
+
125
+ // classes[] + vocabulary[] entries appear ONLY once a session exists, so a
126
+ // session-less graph stays byte-identical to what buildEntities always produced.
127
+ const sessions = entities.individuals.filter((i) => i.class === SESSION_CLASS);
128
+ if (Array.isArray(entities.classes)) {
129
+ let cls = entities.classes.find((c) => c?.name === SESSION_CLASS);
130
+ if (!cls) { cls = { name: SESSION_CLASS, count: 0, sample: [] }; entities.classes.push(cls); }
131
+ cls.count = sessions.length;
132
+ cls.sample = sessions.slice(0, 3).map((i) => i.label);
133
+ }
134
+ if (Array.isArray(entities.vocabulary) && !entities.vocabulary.some((v) => v?.prop === ASKS_ABOUT_PROP)) {
135
+ entities.vocabulary.push({
136
+ prop: ASKS_ABOUT_PROP, predicate: ASKS_ABOUT_PREDICATE,
137
+ note: "chat Session → entity a turn resolved/answered with; runtime observation, owned (no SEON term)",
138
+ });
139
+ }
140
+ return { kept: targets.length, dropped };
141
+ }
142
+
143
+ /** Read-time append (chat.mjs, once per turn): re-read graph.json FRESH (a re-index
144
+ * may have replaced it mid-session), upsert, write back atomically. A MISSING
145
+ * artifact is the empty-graph bootstrap: seed a minimal valid payload so the
146
+ * conversation itself becomes the first graph write. Still throws on an invalid
147
+ * (unparseable) artifact — the caller treats the append as best-effort. */
148
+ export async function appendSessionToGraph(graphFile, record) {
149
+ let text = null;
150
+ try {
151
+ text = await readFile(graphFile, "utf8");
152
+ } catch (e) {
153
+ if (e?.code !== "ENOENT") throw e;
154
+ }
155
+ let entities;
156
+ if (text === null) {
157
+ entities = { generated_at: "", classes: [], vocabulary: [], objectProperties: [], individuals: [], proseIndex: {} };
158
+ await mkdir(dirname(graphFile), { recursive: true });
159
+ } else {
160
+ entities = JSON.parse(text);
161
+ }
162
+ const res = upsertSession(entities, record);
163
+ await atomicWriteJson(graphFile, entities);
164
+ return res;
165
+ }
166
+
167
+ /** Parse one sidecar .jsonl into a session record (null if no valid header).
168
+ * Torn/partial trailing lines (a killed session) are skipped, not fatal. */
169
+ export function parseSessionJsonl(text) {
170
+ let header = null;
171
+ let ended = "";
172
+ const turns = [];
173
+ const arr = (v) => (Array.isArray(v) ? v.filter((x) => typeof x === "string") : []);
174
+ for (const line of String(text).split("\n")) {
175
+ const s = line.trim();
176
+ if (!s) continue;
177
+ let rec;
178
+ try { rec = JSON.parse(s); } catch { continue; }
179
+ if (rec?.type === "session" && !header) header = rec;
180
+ else if (rec?.type === "turn") {
181
+ turns.push({
182
+ ts: String(rec.ts || ""), query: String(rec.query || ""),
183
+ resolvedIds: arr(rec.resolvedIds), answeredIds: arr(rec.answeredIds), miss: !!rec.miss,
184
+ });
185
+ } else if (rec?.type === "end") ended = String(rec.ts || "") || ended;
186
+ }
187
+ if (!header?.id) return null;
188
+ return { id: String(header.id), started: String(header.started || ""), ended, turns };
189
+ }
190
+
191
+ /** All recorded sessions under <rootDir>/.tmct/sessions/*.jsonl, oldest first
192
+ * (uuidv7 filenames sort chronologically). Best-effort: no dir → []. */
193
+ export async function readSessionRecords(rootDir) {
194
+ const dir = join(rootDir, SESSIONS_DIR_REL);
195
+ let names;
196
+ try { names = await readdir(dir); } catch { return []; }
197
+ const records = [];
198
+ for (const name of names.filter((n) => n.endsWith(".jsonl")).sort()) {
199
+ try {
200
+ const rec = parseSessionJsonl(await readFile(join(dir, name), "utf8"));
201
+ if (rec) records.push(rec);
202
+ } catch { /* unreadable sidecar — skip, never fail an index run */ }
203
+ }
204
+ return records;
205
+ }
206
+
207
+ /** Re-index fold-in: attach every recorded session to a FRESH entities payload.
208
+ * Sessions are runtime observations, not source derivations — they are re-attached
209
+ * after the source-derived build, with every reference re-resolved against the new
210
+ * graph (upsertSession's id-then-label tiers; unresolvable → dropped + counted). */
211
+ export function foldInSessions(entities, records) {
212
+ let kept = 0;
213
+ let dropped = 0;
214
+ for (const rec of records || []) {
215
+ const r = upsertSession(entities, rec);
216
+ kept += r.kept;
217
+ dropped += r.dropped;
218
+ }
219
+ return { sessions: (records || []).length, kept, dropped };
220
+ }
package/src/source.mjs ADDED
@@ -0,0 +1,54 @@
1
+ // Local graph source — the offline replacement for marginalia's HTTP/A2A `api`
2
+ // layer. The tool layer takes this as an injectable dependency (so tests can
3
+ // stub it); in production it reads the JSON artifact the deterministic indexer
4
+ // wrote to config.graphFile. No network, no model calls.
5
+
6
+ import { readFile } from "node:fs/promises";
7
+ import { ToolError } from "./config.mjs";
8
+
9
+ let cache = null; // { file, payload } — one artifact per process; cheap re-reads.
10
+
11
+ export function clearCache() {
12
+ cache = null;
13
+ }
14
+
15
+ /** The empty-graph bootstrap payload: what a repo with no artifact "contains".
16
+ * Shaped exactly like a buildEntities payload so parseEntities and the session
17
+ * upsert treat it as a normal (just empty) graph. `bootstrap: true` marks it. */
18
+ export function emptyEntities() {
19
+ return {
20
+ generated_at: "",
21
+ bootstrap: true,
22
+ classes: [],
23
+ vocabulary: [],
24
+ objectProperties: [],
25
+ individuals: [],
26
+ proseIndex: {},
27
+ };
28
+ }
29
+
30
+ /** Read + parse the local graph artifact. Cached per file for the process.
31
+ * A MISSING artifact (ENOENT) is not an error: the chat surface starts from an
32
+ * empty graph and the first session fold-in creates the file — so we return the
33
+ * bootstrap payload (uncached, so the freshly written file is picked up next
34
+ * fetch). Every other failure still throws a clean ToolError. */
35
+ export async function fetchEntities(config) {
36
+ if (cache && cache.file === config.graphFile) return cache.payload;
37
+ let text;
38
+ try {
39
+ text = await readFile(config.graphFile, "utf8");
40
+ } catch (e) {
41
+ if (e?.code === "ENOENT") return emptyEntities();
42
+ throw new ToolError(
43
+ `cannot read graph artifact at ${config.graphFile} (${e?.code || e?.message || e})`,
44
+ );
45
+ }
46
+ let payload;
47
+ try {
48
+ payload = JSON.parse(text);
49
+ } catch {
50
+ throw new ToolError(`graph artifact ${config.graphFile} is not valid JSON`);
51
+ }
52
+ cache = { file: config.graphFile, payload };
53
+ return payload;
54
+ }