@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.
- package/README.md +77 -3
- package/ROADMAP.md +416 -3
- package/bin/tmct.mjs +308 -12
- package/corpus/README.md +52 -0
- package/corpus/conceptnet/LICENSE-NOTICE +37 -0
- package/corpus/conceptnet/README.md +103 -0
- package/corpus/conceptnet/fetch-slice.mjs +136 -0
- package/corpus/conceptnet/filter-dump.mjs +89 -0
- package/corpus/conceptnet/slice.jsonl +14258 -0
- package/data/phrasebook/software-phrases.txt +231 -0
- package/data/templates/grammar-rules.toml +89 -0
- package/data/templates/responses.jsonl +68 -0
- package/package.json +40 -3
- package/src/ask-nlp.mjs +22 -10
- package/src/ask-vocab.mjs +35 -1
- package/src/ask.mjs +171 -494
- package/src/chat.mjs +709 -81
- package/src/corpus/conceptnet-map.toml +251 -0
- package/src/corpus/conceptnet.mjs +167 -0
- package/src/corpus/templates.mjs +188 -0
- package/src/finish.mjs +443 -0
- package/src/grammar/ace.mjs +341 -0
- package/src/grammar/assert.mjs +40 -0
- package/src/grammar/lexicon-core.json +287 -0
- package/src/grammar/lexicon.mjs +202 -0
- package/src/hash.mjs +32 -0
- package/src/index.mjs +21 -5
- package/src/init.mjs +264 -0
- package/src/interpret/fuzzy.mjs +89 -0
- package/src/interpret/merge.mjs +148 -0
- package/src/interpret/normalize.mjs +151 -0
- package/src/interpret/pipeline.mjs +112 -0
- package/src/interpret/strategies/grammar.mjs +137 -0
- package/src/interpret/strategies/keywords.mjs +241 -0
- package/src/interpret/strategies/noise-strip.mjs +114 -0
- package/src/memory/blocks.mjs +221 -0
- package/src/memory/core.mjs +533 -0
- package/src/memory/fold.mjs +0 -0
- package/src/memory/inspect.mjs +141 -0
- package/src/memory/trust.mjs +113 -0
- package/src/prose-nlp.mjs +14 -16
- package/src/providers/bootstrap.mjs +24 -0
- package/src/providers/fixture.mjs +118 -0
- package/src/providers/graph-service.mjs +312 -0
- package/src/repository-interface.mjs +318 -0
- package/src/server.mjs +44 -28
- package/src/sessions.mjs +137 -4
- package/src/source.mjs +44 -5
- package/src/syllogise.mjs +0 -0
- package/src/toml-config.mjs +14 -0
- package/src/tui/app.mjs +173 -0
- package/src/wink-model.mjs +74 -0
- 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 (
|
|
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
|
|
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
|
-
|
|
114
|
-
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
/**
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
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
|
package/src/toml-config.mjs
CHANGED
|
@@ -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;
|
package/src/tui/app.mjs
ADDED
|
@@ -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
|
+
}
|