@polycode-projects/the-mechanical-code-talker 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/README.md +77 -3
  2. package/ROADMAP.md +416 -3
  3. package/bin/tmct.mjs +308 -12
  4. package/corpus/README.md +52 -0
  5. package/corpus/conceptnet/LICENSE-NOTICE +37 -0
  6. package/corpus/conceptnet/README.md +103 -0
  7. package/corpus/conceptnet/fetch-slice.mjs +136 -0
  8. package/corpus/conceptnet/filter-dump.mjs +89 -0
  9. package/corpus/conceptnet/slice.jsonl +14258 -0
  10. package/data/phrasebook/software-phrases.txt +231 -0
  11. package/data/templates/grammar-rules.toml +89 -0
  12. package/data/templates/responses.jsonl +68 -0
  13. package/package.json +40 -3
  14. package/src/ask-nlp.mjs +22 -10
  15. package/src/ask-vocab.mjs +35 -1
  16. package/src/ask.mjs +171 -494
  17. package/src/chat.mjs +709 -81
  18. package/src/corpus/conceptnet-map.toml +251 -0
  19. package/src/corpus/conceptnet.mjs +167 -0
  20. package/src/corpus/templates.mjs +188 -0
  21. package/src/finish.mjs +443 -0
  22. package/src/grammar/ace.mjs +341 -0
  23. package/src/grammar/assert.mjs +40 -0
  24. package/src/grammar/lexicon-core.json +287 -0
  25. package/src/grammar/lexicon.mjs +202 -0
  26. package/src/hash.mjs +32 -0
  27. package/src/index.mjs +21 -5
  28. package/src/init.mjs +264 -0
  29. package/src/interpret/fuzzy.mjs +89 -0
  30. package/src/interpret/merge.mjs +148 -0
  31. package/src/interpret/normalize.mjs +151 -0
  32. package/src/interpret/pipeline.mjs +112 -0
  33. package/src/interpret/strategies/grammar.mjs +137 -0
  34. package/src/interpret/strategies/keywords.mjs +241 -0
  35. package/src/interpret/strategies/noise-strip.mjs +114 -0
  36. package/src/memory/blocks.mjs +221 -0
  37. package/src/memory/core.mjs +533 -0
  38. package/src/memory/fold.mjs +0 -0
  39. package/src/memory/inspect.mjs +141 -0
  40. package/src/memory/trust.mjs +113 -0
  41. package/src/prose-nlp.mjs +14 -16
  42. package/src/providers/bootstrap.mjs +24 -0
  43. package/src/providers/fixture.mjs +118 -0
  44. package/src/providers/graph-service.mjs +312 -0
  45. package/src/repository-interface.mjs +318 -0
  46. package/src/server.mjs +44 -28
  47. package/src/sessions.mjs +137 -4
  48. package/src/source.mjs +44 -5
  49. package/src/syllogise.mjs +0 -0
  50. package/src/toml-config.mjs +14 -0
  51. package/src/tui/app.mjs +173 -0
  52. package/src/wink-model.mjs +74 -0
  53. package/bin/cli.mjs +0 -226
package/src/server.mjs CHANGED
@@ -48,6 +48,7 @@ import {
48
48
  renderClassHistory,
49
49
  } from "./codegraph.mjs";
50
50
  import { ask } from "./ask.mjs";
51
+ import { createGraphService } from "./providers/graph-service.mjs";
51
52
 
52
53
  const SNIPPET_MAX_LINES = 200;
53
54
 
@@ -66,7 +67,7 @@ export const TOOLS = [
66
67
  type: "object",
67
68
  required: ["symbol"],
68
69
  properties: {
69
- symbol: { type: "string", description: "Module path (django/utils/text.py) or a sibling function/class name in it (lower)." },
70
+ symbol: { type: "string", description: "Module path (e.g. path/to/module) or a sibling function/class name defined in it." },
70
71
  depth: { type: "string", enum: ["min", "auto", "full"], default: "auto", description: "auto (sized to the task) | min (leanest) | full (every section)." },
71
72
  },
72
73
  },
@@ -78,7 +79,7 @@ export const TOOLS = [
78
79
  type: "object",
79
80
  required: ["symbol"],
80
81
  properties: {
81
- symbol: { type: "string", description: "function/class name (slugify, Truncator), Class.method, or fn:<path>#name." },
82
+ symbol: { type: "string", description: "function/class name, Class.method, or fn:<path>#name." },
82
83
  },
83
84
  },
84
85
  },
@@ -110,12 +111,17 @@ async function loadGraph(config, source) {
110
111
  return graph;
111
112
  }
112
113
 
113
- function resolveOrThrow(graph, symbol, what) {
114
- const { match, candidates } = resolveSymbol(graph, symbol);
114
+ // Resolution + the miss→ToolError bridge, threaded through the typed service
115
+ // object (createGraphService). The service is the named seam; tmct's own
116
+ // presentation (render*) reads its raw graph (svc.graph) and formats. A clean
117
+ // miss on the interface becomes the instructive ToolError the CLI/chat expect —
118
+ // message-only, never a stack, no fabricated entity names (generic placeholder).
119
+ function resolveOrThrow(svc, symbol, what) {
120
+ const { match, candidates } = resolveSymbol(svc.graph, symbol);
115
121
  if (!match) {
116
122
  throw new ToolError(
117
123
  `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.",
124
+ "Try a repo-relative path (e.g. path/to/module), a basename, or tmct_search for a fuzzy lookup.",
119
125
  );
120
126
  }
121
127
  return { match, candidates };
@@ -146,7 +152,8 @@ export async function buildContextBundle(args, { config, source = defaultSource,
146
152
  // by the tmct-max arm to test whether more injection re-bloats.
147
153
  const max = Boolean(args?.max);
148
154
  const graph = await loadGraph(config, source);
149
- const { match } = resolveOrThrow(graph, symbol, "symbol");
155
+ const svc = createGraphService(graph);
156
+ const { match } = resolveOrThrow(svc, symbol, "symbol");
150
157
  const plan = contextPlan(graph, match);
151
158
  // #6/B1/B6: pick the section mask by depth — min forces TINY, full/max forces everything, auto
152
159
  // runs the size classifier (lean TINY default + one-tier top-up when the edit needs it).
@@ -256,29 +263,48 @@ export async function buildContextBundle(args, { config, source = defaultSource,
256
263
  return { text: out.join("\n"), tier, topup };
257
264
  }
258
265
 
266
+ // The full set of tool names dispatchTool serves (hot catalog + cold tools). Used
267
+ // to reject an unknown tool before any graph load.
268
+ const DISPATCH_TOOLS = new Set([
269
+ "tmct_context", "tmct_context_more", "tmct_describe", "tmct_snippet", "tmct_signature",
270
+ "tmct_impact", "tmct_search", "tmct_members", "tmct_subclasses", "tmct_architecture",
271
+ "tmct_exports", "tmct_untested", "tmct_ask", "tmct_tests_for", "tmct_history",
272
+ "tmct_callers", "tmct_callees", "tmct_cochanges", "tmct_calls",
273
+ "tmct_file_history", "tmct_method_history", "tmct_class_history",
274
+ ]);
275
+
259
276
  export async function dispatchTool(name, args, { config, source = defaultSource } = {}) {
277
+ // tmct_context builds (and loads) its own edit bundle — return early so we don't
278
+ // double-load the graph for it.
260
279
  if (name === "tmct_context") {
261
280
  return (await buildContextBundle(args, { config, source })).text;
262
281
  }
282
+ // Reject an unknown tool BEFORE touching the graph — preserves the original
283
+ // ordering (an unknown name never triggers a load).
284
+ if (!DISPATCH_TOOLS.has(name)) throw new ToolError(`unknown tool: ${name}`);
285
+ // Every other tool reads graph truth: load once and build the typed service
286
+ // object (the Repository Interface). dispatchTool is the presentation adapter —
287
+ // it delegates resolution + the miss/error contract to the service and formats
288
+ // the result with tmct's own render* layer (which reads svc.graph). This is the
289
+ // switch's operations extracted into a named, typed seam without changing bytes.
290
+ const graph = await loadGraph(config, source);
291
+ const svc = createGraphService(graph);
263
292
  if (name === "tmct_context_more") {
264
293
  const symbol = String(args?.symbol || "").trim();
265
294
  if (!symbol) throw new ToolError("symbol is required");
266
- const graph = await loadGraph(config, source);
267
- const { match } = resolveOrThrow(graph, symbol, "symbol");
295
+ const { match } = resolveOrThrow(svc, symbol, "symbol");
268
296
  return renderContextMore(contextPlan(graph, match));
269
297
  }
270
298
  if (name === "tmct_describe") {
271
299
  const symbol = String(args?.symbol || "").trim();
272
300
  if (!symbol) throw new ToolError("symbol is required");
273
- const graph = await loadGraph(config, source);
274
- const { match, candidates } = resolveOrThrow(graph, symbol, "symbol");
301
+ const { match, candidates } = resolveOrThrow(svc, symbol, "symbol");
275
302
  return renderDescribe(graph, match, { candidates });
276
303
  }
277
304
  if (name === "tmct_snippet") {
278
305
  const symbol = String(args?.symbol || "").trim();
279
306
  if (!symbol) throw new ToolError("symbol is required");
280
- const graph = await loadGraph(config, source);
281
- const { match, candidates } = resolveOrThrow(graph, symbol, "symbol");
307
+ const { match, candidates } = resolveOrThrow(svc, symbol, "symbol");
282
308
  const site = siteOf(match);
283
309
  if (!site) {
284
310
  throw new ToolError(
@@ -308,22 +334,19 @@ export async function dispatchTool(name, args, { config, source = defaultSource
308
334
  if (name === "tmct_signature") {
309
335
  const symbol = String(args?.symbol || "").trim();
310
336
  if (!symbol) throw new ToolError("symbol is required");
311
- const graph = await loadGraph(config, source);
312
- const { match } = resolveOrThrow(graph, symbol, "symbol");
337
+ const { match } = resolveOrThrow(svc, symbol, "symbol");
313
338
  return renderSignature(graph, match);
314
339
  }
315
340
  if (name === "tmct_impact") {
316
341
  const module = String(args?.module || "").trim();
317
342
  if (!module) throw new ToolError("module is required");
318
- const graph = await loadGraph(config, source);
319
- const { match } = resolveOrThrow(graph, module, "module");
343
+ const { match } = resolveOrThrow(svc, module, "module");
320
344
  return renderImpact(graph, match);
321
345
  }
322
346
  if (name === "tmct_search") {
323
347
  const query = String(args?.query || "").trim();
324
348
  const kind = String(args?.kind || "").trim();
325
349
  if (!query && !kind) throw new ToolError("query is required");
326
- const graph = await loadGraph(config, source);
327
350
  return renderSearch(graph, query, {
328
351
  kind,
329
352
  decorator: String(args?.decorator || "").trim(),
@@ -333,36 +356,30 @@ export async function dispatchTool(name, args, { config, source = defaultSource
333
356
  if (name === "tmct_members") {
334
357
  const symbol = String(args?.class || "").trim();
335
358
  if (!symbol) throw new ToolError("class is required");
336
- const graph = await loadGraph(config, source);
337
- const { match } = resolveOrThrow(graph, symbol, "class");
359
+ const { match } = resolveOrThrow(svc, symbol, "class");
338
360
  return renderMembers(graph, match);
339
361
  }
340
362
  if (name === "tmct_subclasses") {
341
363
  const symbol = String(args?.class || "").trim();
342
364
  if (!symbol) throw new ToolError("class is required");
343
- const graph = await loadGraph(config, source);
344
- const { match } = resolveOrThrow(graph, symbol, "class");
365
+ const { match } = resolveOrThrow(svc, symbol, "class");
345
366
  return renderSubclasses(graph, match);
346
367
  }
347
368
  if (name === "tmct_architecture") {
348
- const graph = await loadGraph(config, source);
349
369
  return renderArchitecture(graph, { pkg: String(args?.package || "").trim() });
350
370
  }
351
371
  if (name === "tmct_exports") {
352
372
  const module = String(args?.module || "").trim();
353
373
  if (!module) throw new ToolError("module is required");
354
- const graph = await loadGraph(config, source);
355
- const { match } = resolveOrThrow(graph, module, "module");
374
+ const { match } = resolveOrThrow(svc, module, "module");
356
375
  return renderExports(graph, match);
357
376
  }
358
377
  if (name === "tmct_untested") {
359
- const graph = await loadGraph(config, source);
360
378
  return renderUntested(graph);
361
379
  }
362
380
  if (name === "tmct_ask") {
363
381
  const query = String(args?.query || "").trim();
364
382
  if (!query) throw new ToolError("query is required");
365
- const graph = await loadGraph(config, source);
366
383
  const { content, tmct_ask } = ask(graph, query);
367
384
  // Every dispatchTool caller (the chat surface, the CLI fallback) expects a plain string —
368
385
  // append the structured envelope as a delimited, machine-parseable block rather than
@@ -376,8 +393,7 @@ export async function dispatchTool(name, args, { config, source = defaultSource
376
393
  ) {
377
394
  const symbol = String(args?.symbol || "").trim();
378
395
  if (!symbol) throw new ToolError("symbol is required");
379
- const graph = await loadGraph(config, source);
380
- const { match } = resolveOrThrow(graph, symbol, "symbol");
396
+ const { match } = resolveOrThrow(svc, symbol, "symbol");
381
397
  if (name === "tmct_tests_for") return renderTestsFor(graph, match);
382
398
  if (name === "tmct_history") return renderHistory(graph, match);
383
399
  if (name === "tmct_callers") return renderCallers(graph, match);
package/src/sessions.mjs CHANGED
@@ -4,7 +4,7 @@
4
4
  // .tmct/session-<uuidv7>.log — the human-readable transcript (chat.mjs)
5
5
  // .tmct/sessions/session-<uuidv7>.jsonl — the STRUCTURED sidecar this module owns:
6
6
  // {"type":"session", id, started, repo, tmctVersion} (header line)
7
- // {"type":"turn", ts, query, resolvedIds, answeredIds, miss} (one per turn, flushed)
7
+ // {"type":"turn", ts, query, via, resolvedIds, answeredIds, miss} (one per turn, flushed)
8
8
  // {"type":"end", ts} (clean close marker)
9
9
  //
10
10
  // From the sidecar the session enters the typed graph twice:
@@ -24,7 +24,8 @@
24
24
  // rather than re-deriving them from source.
25
25
 
26
26
  import { mkdir, readFile, readdir, rename, writeFile } from "node:fs/promises";
27
- import { dirname, join } from "node:path";
27
+ import { basename, dirname, join } from "node:path";
28
+ import { appendUtterances, CREATED_AT_PROP } from "./memory/core.mjs";
28
29
 
29
30
  export const SESSIONS_DIR_REL = join(".tmct", "sessions");
30
31
 
@@ -72,6 +73,10 @@ export function upsertSession(entities, record) {
72
73
  entities.individuals ||= [];
73
74
  entities.objectProperties ||= [];
74
75
 
76
+ // capture the prior copy's createdAt BEFORE we drop it — first-write-wins so
77
+ // mgx:createdAt records when the session was FIRST seen, not last re-appended.
78
+ const priorSession = entities.individuals.find((i) => i?.id === sid);
79
+ const priorCreatedAt = priorSession?.attributes?.find((a) => a?.prop === CREATED_AT_PROP)?.value || "";
75
80
  // replace any prior copy of this session (read-time appends run once per turn)
76
81
  entities.individuals = entities.individuals.filter((i) => i?.id !== sid);
77
82
  let group = entities.objectProperties.find((g) => g?.prop === ASKS_ABOUT_PROP);
@@ -110,6 +115,8 @@ export function upsertSession(entities, record) {
110
115
  id: sid, label, class: SESSION_CLASS,
111
116
  derived_from: [], mentions: [],
112
117
  attributes: [
118
+ // referenced via the imported constant (single-sourced from memory/core.mjs)
119
+ { prop: CREATED_AT_PROP, key: "createdAt", value: priorCreatedAt || started || new Date().toISOString() },
113
120
  { prop: "mgx:sessionStarted", key: "started", value: started },
114
121
  { prop: "mgx:sessionEnded", key: "ended", value: ended },
115
122
  { prop: "mgx:sessionTurns", key: "turns", value: String(turns.length) },
@@ -144,8 +151,13 @@ export function upsertSession(entities, record) {
144
151
  * may have replaced it mid-session), upsert, write back atomically. A MISSING
145
152
  * artifact is the empty-graph bootstrap: seed a minimal valid payload so the
146
153
  * 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) {
154
+ * (unparseable) artifact — the caller treats the append as best-effort.
155
+ *
156
+ * Signature stays backward-compatible: chat.mjs passes (graphFile, record)
157
+ * exactly as before. The optional third param only tunes the MEMORY side-write
158
+ * (below): `repoDir` overrides the derived repo root, `memory: false` disables
159
+ * the side-write entirely. */
160
+ export async function appendSessionToGraph(graphFile, record, { memory = true, repoDir = null } = {}) {
149
161
  let text = null;
150
162
  try {
151
163
  text = await readFile(graphFile, "utf8");
@@ -161,9 +173,83 @@ export async function appendSessionToGraph(graphFile, record) {
161
173
  }
162
174
  const res = upsertSession(entities, record);
163
175
  await atomicWriteJson(graphFile, entities);
176
+ // ALSO record the turn(s) into tmct's OWN memory graph (.tmct/memory/ — item 9),
177
+ // and fold the transcript into the text-block corpus once the session has ended.
178
+ // Best-effort by design: memory must never degrade the graph append that already
179
+ // succeeded, so every failure here is swallowed (mirrors chat.mjs's own stance).
180
+ if (memory) {
181
+ try { await recordSessionMemory(graphFile, record, repoDir); } catch { /* best-effort */ }
182
+ }
164
183
  return res;
165
184
  }
166
185
 
186
+ /** Derive the repo root the memory store lives under from the graph artifact's
187
+ * location: the default layout is <repo>/.tmct/graph.json. A custom
188
+ * TMCT_GRAPH_FILE outside a .tmct dir has no discoverable repo (and no session
189
+ * transcript/sidecar layout to read), so the memory side-write is skipped —
190
+ * memory writes go ONLY under a real .tmct/, never beside arbitrary files. */
191
+ function repoDirFromGraphFile(graphFile) {
192
+ const tmctDir = dirname(graphFile);
193
+ return basename(tmctDir) === ".tmct" ? dirname(tmctDir) : null;
194
+ }
195
+
196
+ /** The memory side-write for one session append (item 9's chat wiring, placed
197
+ * HERE so chat.mjs needs no change — it already calls appendSessionToGraph
198
+ * every turn). Each recorded turn becomes an a-visitor-said Utterance; the
199
+ * response prose is recovered from the human transcript (the only artifact
200
+ * that carries answer TEXT — the sidecar records ids) and recorded alongside
201
+ * as a tmct Utterance replying to it. Deterministic utterance ids make the
202
+ * per-turn replay idempotent. Once the sidecar carries its end marker (chat
203
+ * writes it before the final graph upsert), the session is folded into the
204
+ * text-block corpus (memory/fold.mjs). */
205
+ async function recordSessionMemory(graphFile, record, repoDirOverride = null) {
206
+ const repoDir = repoDirOverride ?? repoDirFromGraphFile(graphFile);
207
+ if (!repoDir || !record?.id) return;
208
+
209
+ let answers = new Map();
210
+ try {
211
+ answers = parseSessionLog(await readFile(join(repoDir, ".tmct", `session-${record.id}.log`), "utf8"));
212
+ } catch { /* no transcript (direct API callers) — record the requests alone */ }
213
+
214
+ const utterances = [];
215
+ for (const t of record.turns || []) {
216
+ const query = String(t?.query || "");
217
+ const ts = String(t?.ts || "");
218
+ if (!query || !ts) continue;
219
+ // the structured parse the turn produced — stored on the visitor utterance
220
+ const parsed = {};
221
+ if (t.resolvedIds?.length) parsed.resolvedIds = t.resolvedIds;
222
+ if (t.answeredIds?.length) parsed.answeredIds = t.answeredIds;
223
+ if (t.command) parsed.command = t.command;
224
+ if (t.miss) parsed.miss = true;
225
+ if (t.via) parsed.via = t.via; // answer provenance (W1) — carried into memory
226
+ utterances.push({
227
+ role: "visitor", text: query, ts, sessionId: record.id, sessionStarted: record.started || "",
228
+ ...(Object.keys(parsed).length ? { parsed } : {}),
229
+ });
230
+ const answer = answers.get(turnKey(ts, query));
231
+ if (answer) {
232
+ utterances.push({
233
+ role: "tmct", text: answer, ts, sessionId: record.id,
234
+ replyTo: `utt:${record.id}#${ts}#visitor`,
235
+ });
236
+ }
237
+ }
238
+ await appendUtterances(repoDir, utterances);
239
+
240
+ // Session over? The sidecar's end marker is authoritative (chat.mjs writes it
241
+ // before the final upsert). Fold THIS session's transcript into the corpus.
242
+ let ended = false;
243
+ try {
244
+ const sidecar = await readFile(join(repoDir, SESSIONS_DIR_REL, `session-${record.id}.jsonl`), "utf8");
245
+ ended = Boolean(parseSessionJsonl(sidecar)?.ended);
246
+ } catch { /* no sidecar — nothing to fold from */ }
247
+ if (ended) {
248
+ const { foldSessionLogs } = await import("./memory/fold.mjs"); // lazy: fold imports this module
249
+ await foldSessionLogs(repoDir, { sessionId: record.id });
250
+ }
251
+ }
252
+
167
253
  /** Parse one sidecar .jsonl into a session record (null if no valid header).
168
254
  * Torn/partial trailing lines (a killed session) are skipped, not fatal. */
169
255
  export function parseSessionJsonl(text) {
@@ -181,6 +267,14 @@ export function parseSessionJsonl(text) {
181
267
  turns.push({
182
268
  ts: String(rec.ts || ""), query: String(rec.query || ""),
183
269
  resolvedIds: arr(rec.resolvedIds), answeredIds: arr(rec.answeredIds), miss: !!rec.miss,
270
+ // preserved for the memory fold (memory/fold.mjs): slash-command turns and
271
+ // conversational filler are recorded but never folded into the corpus.
272
+ ...(rec.command ? { command: String(rec.command) } : {}),
273
+ ...(rec.conversational ? { conversational: true } : {}),
274
+ // answer provenance (W1): composed|template|count|command|conversational|
275
+ // assert|recall|fact|corpus — carried through so the memory side-write and
276
+ // any re-fold keep the banding signal the Phase-5 bench reads.
277
+ ...(rec.via ? { via: String(rec.via) } : {}),
184
278
  });
185
279
  } else if (rec?.type === "end") ended = String(rec.ts || "") || ended;
186
280
  }
@@ -188,6 +282,45 @@ export function parseSessionJsonl(text) {
188
282
  return { id: String(header.id), started: String(header.started || ""), ended, turns };
189
283
  }
190
284
 
285
+ // A transcript turn opens with an ISO-8601 ms timestamp line followed by the
286
+ // echoed "> <query>" line (chat.mjs's logLines shape).
287
+ const LOG_TS_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
288
+
289
+ /** Key a transcript answer by its turn: ts + query (ts alone can collide when
290
+ * two instant turns land in the same millisecond). */
291
+ export const turnKey = (ts, query) => `${ts}${query}`;
292
+
293
+ /**
294
+ * Parse a human-readable session transcript (.tmct/session-<id>.log) into a
295
+ * Map of turnKey(ts, query) → answer text. The transcript is the ONLY session
296
+ * artifact that carries the answer PROSE (the structured sidecar records ids,
297
+ * not text), so the memory write-path recovers response text from here.
298
+ * Tolerant by design: a block is `ts` line + "> query" line + answer lines
299
+ * until the next block; header/footer and torn tails just don't match.
300
+ */
301
+ export function parseSessionLog(text) {
302
+ const lines = String(text ?? "").split("\n");
303
+ const answers = new Map();
304
+ let open = null; // { ts, query, answerLines }
305
+ const close = () => {
306
+ if (!open) return;
307
+ while (open.answerLines.length && !open.answerLines.at(-1).trim()) open.answerLines.pop();
308
+ answers.set(turnKey(open.ts, open.query), open.answerLines.join("\n"));
309
+ open = null;
310
+ };
311
+ for (let i = 0; i < lines.length; i += 1) {
312
+ if (LOG_TS_RE.test(lines[i]) && lines[i + 1]?.startsWith("> ")) {
313
+ close();
314
+ open = { ts: lines[i], query: lines[i + 1].slice(2), answerLines: [] };
315
+ i += 1;
316
+ } else if (open) {
317
+ open.answerLines.push(lines[i]);
318
+ }
319
+ }
320
+ close();
321
+ return answers;
322
+ }
323
+
191
324
  /** All recorded sessions under <rootDir>/.tmct/sessions/*.jsonl, oldest first
192
325
  * (uuidv7 filenames sort chronologically). Best-effort: no dir → []. */
193
326
  export async function readSessionRecords(rootDir) {
package/src/source.mjs CHANGED
@@ -2,16 +2,39 @@
2
2
  // layer. The tool layer takes this as an injectable dependency (so tests can
3
3
  // stub it); in production it reads the JSON artifact the deterministic indexer
4
4
  // wrote to config.graphFile. No network, no model calls.
5
+ //
6
+ // This module is the PROVIDER SEAM (ROADMAP item 14, docs/adapter-contract.md):
7
+ // any graph producer can feed tmct either by writing the entities-payload JSON
8
+ // where config.graphFile points, or by registering a custom loader with
9
+ // registerProvider() — no indexer is ever imported here. tmct only READS
10
+ // through this seam; its own writes go to .tmct/memory/ (src/memory/), never
11
+ // back into a provider's artifact.
5
12
 
6
13
  import { readFile } from "node:fs/promises";
7
14
  import { ToolError } from "./config.mjs";
8
15
 
9
16
  let cache = null; // { file, payload } — one artifact per process; cheap re-reads.
17
+ let provider = null; // registered custom loader (config) => entities payload | Promise
10
18
 
11
19
  export function clearCache() {
12
20
  cache = null;
13
21
  }
14
22
 
23
+ /** Register a custom graph provider: an async (or sync) `(config) => payload`
24
+ * returning the entities-payload shape documented in docs/adapter-contract.md.
25
+ * Pass `null` to restore the default file loader. Returns the PREVIOUS
26
+ * provider (so a caller can wrap or restore it). The read cache is cleared
27
+ * either way — a provider swap must never serve the old source's payload. */
28
+ export function registerProvider(fn) {
29
+ if (fn != null && typeof fn !== "function") {
30
+ throw new TypeError("registerProvider expects a function (config) => entities payload, or null");
31
+ }
32
+ const prev = provider;
33
+ provider = fn ?? null;
34
+ cache = null;
35
+ return prev;
36
+ }
37
+
15
38
  /** The empty-graph bootstrap payload: what a repo with no artifact "contains".
16
39
  * Shaped exactly like a buildEntities payload so parseEntities and the session
17
40
  * upsert treat it as a normal (just empty) graph. `bootstrap: true` marks it. */
@@ -27,12 +50,28 @@ export function emptyEntities() {
27
50
  };
28
51
  }
29
52
 
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. */
53
+ /** Fetch the entities payload through the provider seam. With a registered
54
+ * provider, its result is returned as-is (uncached a live provider owns its
55
+ * own caching/refresh policy); a non-object result is a clean ToolError.
56
+ * Default: read + parse the local graph artifact, cached per file for the
57
+ * process. A MISSING artifact (ENOENT) is not an error: the chat surface
58
+ * starts from an empty graph and the first session fold-in creates the file —
59
+ * so we return the bootstrap payload (uncached, so the freshly written file is
60
+ * picked up next fetch). Every other failure still throws a clean ToolError. */
35
61
  export async function fetchEntities(config) {
62
+ if (provider) {
63
+ let payload;
64
+ try {
65
+ payload = await provider(config);
66
+ } catch (e) {
67
+ if (e instanceof ToolError) throw e;
68
+ throw new ToolError(`graph provider failed (${e?.message || e})`);
69
+ }
70
+ if (!payload || typeof payload !== "object") {
71
+ throw new ToolError("graph provider returned no entities payload");
72
+ }
73
+ return payload;
74
+ }
36
75
  if (cache && cache.file === config.graphFile) return cache.payload;
37
76
  let text;
38
77
  try {
Binary file
@@ -87,6 +87,20 @@ export async function normalizeConfig(raw, { configDir } = {}) {
87
87
  cfg.outRoot = resolve(dir, String(src.out_root));
88
88
  }
89
89
 
90
+ // `tmct init` onboarding keys (ROADMAP Phase 8). Sparse like the rest: only a
91
+ // key actually present appears, so "unset" stays distinguishable from "set to
92
+ // the default". `graph_file` is resolved against configDir to match outRoot.
93
+ if (src.graph_file !== undefined) {
94
+ cfg.graphFile = resolve(dir, String(src.graph_file));
95
+ }
96
+ const corpus = src.corpus || {};
97
+ if (corpus.tier !== undefined) cfg.corpus = { tier: corpus.tier };
98
+ const seed = src.seed || {};
99
+ const seedCfg = {};
100
+ if (seed.enabled !== undefined) seedCfg.enabled = seed.enabled;
101
+ if (seed.limit !== undefined) seedCfg.limit = seed.limit;
102
+ if (Object.keys(seedCfg).length) cfg.seed = seedCfg;
103
+
90
104
  const idx = src.index || {};
91
105
  const index = {};
92
106
  if (idx.languages !== undefined) index.languages = idx.languages;
@@ -0,0 +1,173 @@
1
+ // tui/app.mjs — the Ink full-screen chat shell (the default on a TTY).
2
+ //
3
+ // The claude-code feel: an alternate-screen, full-height layout with a scrolling
4
+ // transcript pane (each visitor line echoed under the prompt it was typed at,
5
+ // the answer below it), a bottom input line carrying the live `tmct> ` /
6
+ // `tmct(label)> ` focus prompt, and a thin status bar (repo · module count ·
7
+ // session id · an honest "no graph — starting empty" when bootstrapping).
8
+ //
9
+ // EVERY turn goes through the SAME createSession sink (src/chat.mjs) the plain
10
+ // readline shell uses — the transcript log, structured sidecar, per-turn graph
11
+ // upsert and memory side-write are byte-identical to `--plain`; only the
12
+ // screen drawing differs. Slash-commands work unchanged (they're session.turn's
13
+ // job); `/exit` and a conversational "bye" end the session; Ctrl+C exits
14
+ // cleanly through the same close() (Ink's exitOnCtrlC → waitUntilExit → close).
15
+ //
16
+ // Library decision (ROADMAP Phase 1 shell work): Ink 7 + React 19 — plain Node
17
+ // ESM, no JSX/build step (React.createElement throughout). OpenTUI was ruled
18
+ // out for now: @opentui/core depends on Bun FFI (bun-ffi-structs / a native Zig
19
+ // renderer), so it doesn't run under plain Node; revisit when it does.
20
+ //
21
+ // The view-model is PURE and exported (statusText, appendTurn, transcriptLines,
22
+ // wrapLines) so node:test exercises it without a terminal; the component tree
23
+ // is thin glue over it.
24
+
25
+ import React, { useEffect, useState } from "react";
26
+ import { render, Box, Text, useApp, useInput, useStdout } from "ink";
27
+ import { createSession } from "../chat.mjs";
28
+
29
+ const h = React.createElement;
30
+
31
+ /** The thin status-bar text: repo · module count · session id (short, like the
32
+ * Session graph label) — plus the honest bootstrap note when the graph is empty. */
33
+ export function statusText({ repo, moduleCount, sessionId, empty }) {
34
+ const parts = [String(repo), `${moduleCount} module(s)`, `session ${String(sessionId).slice(0, 8)}`];
35
+ if (empty) parts.push("no graph — starting empty");
36
+ return parts.join(" · ");
37
+ }
38
+
39
+ /** Append one completed turn to the transcript model (pure — returns a new array).
40
+ * `prompt` is the prompt the question was typed at (so a focus label is preserved
41
+ * in the echo, exactly like a scrolled readline session reads). */
42
+ export function appendTurn(items, { prompt, query, answer }) {
43
+ return [...items, { kind: "q", prompt: String(prompt), text: String(query) }, { kind: "a", text: String(answer) }];
44
+ }
45
+
46
+ /** Flatten the transcript model to display lines: the echoed `tmct> question`
47
+ * line, the answer's own lines, and a blank separator after each turn —
48
+ * mirroring the transcript log's visual rhythm. */
49
+ export function transcriptLines(items) {
50
+ const lines = [];
51
+ for (const item of items) {
52
+ if (item.kind === "q") lines.push(`${item.prompt}${item.text}`);
53
+ else { lines.push(...String(item.text).split("\n")); lines.push(""); }
54
+ }
55
+ return lines;
56
+ }
57
+
58
+ /** Hard-wrap display lines at `width` columns so the pane's line budget is honest
59
+ * (Ink would soft-wrap and overflow the fixed-height layout otherwise). */
60
+ export function wrapLines(lines, width) {
61
+ const w = Math.max(1, Number(width) || 80);
62
+ const out = [];
63
+ for (const line of lines) {
64
+ let s = String(line);
65
+ if (s.length <= w) { out.push(s); continue; }
66
+ while (s.length > w) { out.push(s.slice(0, w)); s = s.slice(w); }
67
+ out.push(s);
68
+ }
69
+ return out;
70
+ }
71
+
72
+ /** Terminal size, live across resizes (falls back to 80×24 off-TTY). */
73
+ function useTerminalSize() {
74
+ const { stdout } = useStdout();
75
+ const size = () => ({ columns: stdout?.columns || 80, rows: stdout?.rows || 24 });
76
+ const [dim, setDim] = useState(size);
77
+ useEffect(() => {
78
+ if (!stdout) return undefined;
79
+ const onResize = () => setDim(size());
80
+ stdout.on("resize", onResize);
81
+ return () => stdout.off("resize", onResize);
82
+ }, [stdout]);
83
+ return dim;
84
+ }
85
+
86
+ /** The Ink app over one createSession sink. Owns only VIEW state — the session
87
+ * (focus, files, records) lives in the sink, exactly as in the readline shell. */
88
+ export function App({ session }) {
89
+ const { exit } = useApp();
90
+ const { columns, rows } = useTerminalSize();
91
+ const [items, setItems] = useState([]);
92
+ const [input, setInput] = useState("");
93
+ const [prompt, setPrompt] = useState(session.promptFor());
94
+ const [busy, setBusy] = useState(false);
95
+
96
+ const submit = async (line) => {
97
+ if (line === "/exit") { exit(); return; }
98
+ setBusy(true);
99
+ const echoedAt = prompt; // the prompt the question was typed at, kept in the echo
100
+ try {
101
+ const { answer, end, prompt: nextPrompt } = await session.turn(line);
102
+ setItems((prev) => appendTurn(prev, { prompt: echoedAt, query: line, answer }));
103
+ setPrompt(nextPrompt);
104
+ if (end) { exit(); return; } // a conversational "bye" — same clean end as /exit
105
+ } finally {
106
+ setBusy(false);
107
+ }
108
+ };
109
+
110
+ const trySubmit = (raw) => {
111
+ if (busy) return; // one turn at a time — the engine is deterministic and fast
112
+ const line = String(raw).trim();
113
+ setInput("");
114
+ if (line) void submit(line);
115
+ };
116
+
117
+ useInput((ch, key) => {
118
+ if (key.return) { trySubmit(input); return; }
119
+ if (key.backspace || key.delete) { setInput((s) => s.slice(0, -1)); return; }
120
+ if (key.ctrl && ch === "u") { setInput(""); return; }
121
+ if (key.ctrl || key.meta || key.escape || key.tab || key.upArrow || key.downArrow || key.leftArrow || key.rightArrow) return;
122
+ if (!ch) return;
123
+ // A PASTED chunk arrives as one multi-char event; a newline inside it means
124
+ // "submit this line" (one line per turn — the readline shell's per-line read).
125
+ const nl = ch.search(/[\r\n]/);
126
+ if (nl === -1) { setInput((s) => s + ch); return; }
127
+ trySubmit(input + ch.slice(0, nl));
128
+ });
129
+
130
+ // The pane's line budget: full height minus the input line and the status bar.
131
+ const paneRows = Math.max(1, rows - 2);
132
+ const banner = { kind: "a", text: session.bannerLines.join("\n") }; // one block, one separator
133
+ const allLines = wrapLines(transcriptLines([banner, ...items]), columns);
134
+ const visible = allLines.slice(-paneRows);
135
+
136
+ return h(Box, { flexDirection: "column", height: rows, width: columns },
137
+ h(Box, { flexDirection: "column", height: paneRows, overflow: "hidden" },
138
+ ...visible.map((line, i) =>
139
+ h(Text, { key: `l${i}`, wrap: "truncate-end" }, line === "" ? " " : line)),
140
+ ),
141
+ h(Box, { height: 1 },
142
+ h(Text, { wrap: "truncate-end" },
143
+ h(Text, { bold: true }, prompt),
144
+ input,
145
+ busy ? h(Text, { dimColor: true }, "…") : h(Text, { inverse: true }, " "),
146
+ ),
147
+ ),
148
+ h(Box, { height: 1 },
149
+ h(Text, { dimColor: true, wrap: "truncate-end" },
150
+ statusText(session), " · /help commands · /exit leaves"),
151
+ ),
152
+ );
153
+ }
154
+
155
+ /** Run the full-screen TUI over a fresh session sink: alternate screen in, Ink
156
+ * render, wait for exit (Ctrl+C / /exit / bye), alternate screen out, then the
157
+ * SAME session close the readline shell performs (end lines, final upsert —
158
+ * which also triggers the memory fold — stream flush). Returns
159
+ * { logFile, sidecarFile, turns } exactly like runChat. */
160
+ export async function runTui({ repoPath, stdout = process.stdout, stdin = process.stdin, ...sessionOpts } = {}) {
161
+ const session = await createSession({ repoPath, ...sessionOpts });
162
+ stdout.write("\x1b[?1049h\x1b[H"); // alternate screen buffer + home — a clean full-screen canvas
163
+ const app = render(h(App, { session }), { stdout, stdin, exitOnCtrlC: true });
164
+ try {
165
+ await app.waitUntilExit();
166
+ } finally {
167
+ app.unmount();
168
+ stdout.write("\x1b[?1049l"); // restore the primary screen (shell scrollback intact)
169
+ await session.close();
170
+ stdout.write(`session ended — log ${session.logFile}\n`);
171
+ }
172
+ return { logFile: session.logFile, sidecarFile: session.sidecarFile, turns: session.turns };
173
+ }