@polycode-projects/the-mechanical-code-talker 0.3.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.
@@ -0,0 +1,318 @@
1
+ // The Repository Interface — tmct's OWNED, versioned contract between "interpret
2
+ // the query" (tmct, the brittle side) and "ask the graph for truth" (a provider,
3
+ // the stable side). PLAN_REPOSITORY_INTERFACE.md.
4
+ //
5
+ // tmct defines and versions this shape; a provider (seonix, a fixture, a browser
6
+ // page) IMPLEMENTS it over its native graph. Both sides already agree on the
7
+ // TYPES — every Individual.class is a `tmct:` class and every Edge.predicate a
8
+ // `tmct:` object property (ontology/tmct-core.ttl is the type dictionary) — so
9
+ // the interface only names the OPERATIONS over those shared types.
10
+ //
11
+ // The error contract is the load-bearing rule: a clean miss is a first-class
12
+ // RETURN VALUE from a small CLOSED set of reasons (MISS_REASONS), never a throw.
13
+ // Presentation (the render* layer, the chat surface) lives on tmct's side of the
14
+ // seam and turns these typed results into prose; the interface returns data.
15
+ //
16
+ // This module is pure data + tiny constructors — no fs, no graph, no LLM.
17
+
18
+ /** SemVer of the interface. Additive-by-default: new services / optional args are
19
+ * minor bumps; the suite for version N stays green under N+1. A breaking change
20
+ * is a new MAJOR with its own suite (see the versioning policy in the plan). */
21
+ export const INTERFACE_VERSION = "1.0.0";
22
+
23
+ /** The OWL vocabulary the types are grounded in. */
24
+ export const ONTOLOGY_IRI = "urn:tmct:core";
25
+
26
+ /**
27
+ * The CLOSED set of miss reasons. A service that cannot answer returns
28
+ * `miss(reason, detail)` with `reason` drawn from exactly these — the interpreter
29
+ * renders the reason, never free-text error prose.
30
+ *
31
+ * - UNRESOLVED_TERM the term named no individual in the graph.
32
+ * - CAPABILITY_ABSENT the provider does not implement this service (negotiated away).
33
+ * - TRUNCATED_GRAPH the answer exists but the provider shipped a truncated sample.
34
+ * - NO_SOURCE a source-reaching service, but the provider exposes no working tree.
35
+ */
36
+ export const MISS_REASONS = Object.freeze({
37
+ UNRESOLVED_TERM: "UNRESOLVED_TERM",
38
+ CAPABILITY_ABSENT: "CAPABILITY_ABSENT",
39
+ TRUNCATED_GRAPH: "TRUNCATED_GRAPH",
40
+ NO_SOURCE: "NO_SOURCE",
41
+ });
42
+
43
+ /** The closed vocabulary of edge kinds `edges(id, kind)` traverses, each aligned
44
+ * to a `tmct:` object property (ontology/tmct-core.ttl). Symbol-granular kinds
45
+ * (callsSymbol/touchesSymbol) stay separate from module-coarse calls/touches. */
46
+ export const EDGE_KINDS = Object.freeze([
47
+ "imports", "calls", "callsSymbol", "defines", "tests",
48
+ "touches", "touchesSymbol", "contains", "inherits", "cochange", "reexports",
49
+ ]);
50
+
51
+ /** edge-kind → the `tmct:` object property it realizes (the OWL grounding). */
52
+ export const EDGE_KIND_TO_TMCT = Object.freeze({
53
+ imports: "tmct:imports",
54
+ calls: "tmct:calls",
55
+ callsSymbol: "tmct:calls",
56
+ defines: "tmct:defines",
57
+ tests: "tmct:covers",
58
+ touches: "tmct:touches",
59
+ touchesSymbol: "tmct:touches",
60
+ contains: "tmct:contains",
61
+ inherits: "tmct:extends",
62
+ cochange: "tmct:dependsOn",
63
+ reexports: "tmct:exports",
64
+ });
65
+
66
+ /** The named services, grouped as in the plan's six-group inventory. Names are
67
+ * the interface's stable identifiers; a provider advertises which it implements
68
+ * as its `capabilities`. `source: true` marks the working-tree-reaching services
69
+ * a graph-only provider may satisfy with an honest NO_SOURCE miss. */
70
+ export const SERVICE_GROUPS = Object.freeze({
71
+ resolution: ["resolve", "describe", "members", "subclasses", "exports", "signature"],
72
+ traversal: ["edges", "impact"],
73
+ source: ["snippet", "context"],
74
+ aggregate: ["architecture", "untested", "stats"],
75
+ temporal: ["history"],
76
+ search: ["search", "ask"],
77
+ });
78
+
79
+ /** Flat list of every service name. */
80
+ export const SERVICES = Object.freeze(
81
+ Object.values(SERVICE_GROUPS).flat(),
82
+ );
83
+
84
+ /** The subset that reaches past the graph into a working tree. */
85
+ export const SOURCE_SERVICES = Object.freeze(new Set(SERVICE_GROUPS.source));
86
+
87
+ // ---- result constructors — the honest-miss ethos as tiny values --------------
88
+
89
+ /** A successful result carrying typed `value`. */
90
+ export function hit(value) {
91
+ return { ok: true, value };
92
+ }
93
+
94
+ /** A first-class miss: `reason` ∈ MISS_REASONS, optional `detail` (free text for
95
+ * the human render) and `term` (what was looked up). Never thrown. */
96
+ export function miss(reason, { detail = "", term = null } = {}) {
97
+ if (!MISS_REASONS[reason]) {
98
+ throw new TypeError(`miss(): unknown reason "${reason}" (not in MISS_REASONS)`);
99
+ }
100
+ return { ok: false, miss: { reason, detail, term } };
101
+ }
102
+
103
+ export const isHit = (r) => Boolean(r && r.ok === true);
104
+ export const isMiss = (r) => Boolean(r && r.ok === false);
105
+
106
+ // ---- type projections — raw graph rows → the interface's shared shapes --------
107
+
108
+ /**
109
+ * Project a raw graph individual to the interface `Individual`:
110
+ * { id, label, class, attributes: [{ key, value, prop }] }
111
+ * `class` is a `tmct:` class token; `attributes[].prop` is the SEON/mgx token the
112
+ * value was asserted under (OWL grounding preserved). Pure; tolerant of partials.
113
+ * @returns {Individual|null}
114
+ */
115
+ export function toIndividual(ind) {
116
+ if (!ind || !ind.id) return null;
117
+ return {
118
+ id: String(ind.id),
119
+ label: ind.label != null ? String(ind.label) : String(ind.id),
120
+ class: ind.class || "Entity",
121
+ attributes: (Array.isArray(ind.attributes) ? ind.attributes : []).map((a) => ({
122
+ key: a.key,
123
+ value: a.value,
124
+ prop: a.prop || null,
125
+ })),
126
+ };
127
+ }
128
+
129
+ /**
130
+ * Project a raw relation edge to the interface `Edge`:
131
+ * { subject, object, predicate, prop, subjectLabel?, objectLabel?, weight? }
132
+ * The required quartet is subject/object/predicate/prop; labels/weight are
133
+ * additive conveniences a provider MAY carry.
134
+ * @returns {Edge}
135
+ */
136
+ export function toEdge(rawEdge, { predicate = "", prop = null } = {}) {
137
+ const e = {
138
+ subject: rawEdge.subject,
139
+ object: rawEdge.object,
140
+ predicate,
141
+ prop: prop || null,
142
+ };
143
+ if (rawEdge.subjectLabel != null) e.subjectLabel = rawEdge.subjectLabel;
144
+ if (rawEdge.objectLabel != null) e.objectLabel = rawEdge.objectLabel;
145
+ if (rawEdge.weight != null) e.weight = rawEdge.weight;
146
+ return e;
147
+ }
148
+
149
+ // ---- capability negotiation — CAPABILITY_ABSENT as a value, not a wall --------
150
+
151
+ /**
152
+ * Invoke a service through capability negotiation. If the provider does not
153
+ * advertise `service` in `svc.capabilities`, return `miss(CAPABILITY_ABSENT)`
154
+ * rather than throwing — the interpreter degrades the query, never errors. When
155
+ * present, calls `svc[service](...args)` and returns its result verbatim.
156
+ *
157
+ * This is how a host calls an interface it cannot assume is complete; the
158
+ * reference providers implement every service, so they never trip it.
159
+ */
160
+ export function invoke(svc, service, ...args) {
161
+ const caps = svc && svc.capabilities;
162
+ const has = Array.isArray(caps) ? caps.includes(service) : caps instanceof Set ? caps.has(service) : false;
163
+ if (!has || typeof svc[service] !== "function") {
164
+ return miss(MISS_REASONS.CAPABILITY_ABSENT, { detail: `service "${service}" is not implemented by this provider` });
165
+ }
166
+ return svc[service](...args);
167
+ }
168
+
169
+ // ---- the MACHINE-READABLE SHAPE ----------------------------------------------
170
+ // A single JSON-serializable object enumerating every service, its args, result
171
+ // type, and possible misses. tmct OWNS and versions this; docs/repository-
172
+ // interface.md is the prose peer and docs/repository-interface.schema.json is the
173
+ // committed serialization (the contract suite asserts they agree — no drift).
174
+
175
+ const IND = "Individual";
176
+ const EDGE = "Edge";
177
+
178
+ /** Concurrency note shared by every read service: safe across handles — the graph
179
+ * is read-only truth and the only mutable state is the caller-owned session
180
+ * handle, never touched here. Proven by the suite's concurrent-session cases. */
181
+ const CONCURRENT_SAFE = "concurrent-safe: reads immutable graph truth; no shared mutable state";
182
+
183
+ export const REPOSITORY_INTERFACE = Object.freeze({
184
+ version: INTERFACE_VERSION,
185
+ ontology: ONTOLOGY_IRI,
186
+ missReasons: Object.values(MISS_REASONS),
187
+ edgeKinds: [...EDGE_KINDS],
188
+ types: {
189
+ Individual: {
190
+ fields: {
191
+ id: "string (opaque provider id)",
192
+ label: "string (human/display name)",
193
+ class: "string — a tmct: class token (module|class|function|method|attribute|variable|commit|test|…)",
194
+ attributes: "Array<{ key: string, value: string, prop: string|null }> — prop is the SEON/mgx token grounding the value",
195
+ },
196
+ },
197
+ Edge: {
198
+ fields: {
199
+ subject: "string (individual id)",
200
+ object: "string (individual id)",
201
+ predicate: "string — the relation predicate",
202
+ prop: "string|null — the SEON/mgx property token; see edgeKinds → tmct: mapping",
203
+ subjectLabel: "string? (additive)",
204
+ objectLabel: "string? (additive)",
205
+ weight: "number? (additive; e.g. cochange coupling)",
206
+ },
207
+ },
208
+ Result: { shape: "{ ok: true, value: T } | { ok: false, miss: Miss }" },
209
+ Miss: { shape: "{ reason: MISS_REASONS, detail: string, term: string|null }" },
210
+ },
211
+ capabilitiesModel:
212
+ "A provider advertises `capabilities: string[]` (service names it implements). " +
213
+ "A service outside the set is negotiated away to miss(CAPABILITY_ABSENT) — never an error. " +
214
+ "Source services (snippet, context) may be advertised yet answer miss(NO_SOURCE) when no working tree exists.",
215
+ services: {
216
+ resolve: {
217
+ group: "resolution", args: { term: "string" },
218
+ result: `{ match: ${IND}, candidates: ${IND}[] }`,
219
+ misses: ["UNRESOLVED_TERM"], concurrency: CONCURRENT_SAFE,
220
+ purpose: "The resolveSymbol seam: map a free term to an individual + runner-up candidates. Every id-taking service consumes a resolved id.",
221
+ },
222
+ describe: {
223
+ group: "resolution", args: { id: "string" },
224
+ result: `{ individual: ${IND}, out: ${EDGE}[], incoming: ${EDGE}[] }`,
225
+ misses: ["UNRESOLVED_TERM"], concurrency: CONCURRENT_SAFE,
226
+ purpose: "Full typed portrait of one individual: its attributes and its outgoing/incoming edges.",
227
+ },
228
+ members: {
229
+ group: "resolution", args: { classId: "string" },
230
+ result: `{ methods: ${IND}[], attributes: ${IND}[] }`,
231
+ misses: ["UNRESOLVED_TERM"], concurrency: CONCURRENT_SAFE,
232
+ purpose: "A class's methods + attributes (via the contains relation) — replaces reading the class body.",
233
+ },
234
+ subclasses: {
235
+ group: "resolution", args: { classId: "string" },
236
+ result: `{ bases: ${IND}[], subclasses: ${IND}[] }`,
237
+ misses: ["UNRESOLVED_TERM"], concurrency: CONCURRENT_SAFE,
238
+ purpose: "Forward bases + the transitive reverse inheritance closure (who extends this).",
239
+ },
240
+ exports: {
241
+ group: "resolution", args: { moduleId: "string" },
242
+ result: `{ exports: ${IND}[] }`,
243
+ misses: ["UNRESOLVED_TERM"], concurrency: CONCURRENT_SAFE,
244
+ purpose: "A module's curated public API (resolved __all__ / re-exports).",
245
+ },
246
+ signature: {
247
+ group: "resolution", args: { id: "string" },
248
+ result: "{ id, label, class, params, returns, raises, decorators, doc, selfFields, flags }",
249
+ misses: ["UNRESOLVED_TERM"], concurrency: CONCURRENT_SAFE,
250
+ purpose: "The compact API surface of a symbol without its body.",
251
+ },
252
+ edges: {
253
+ group: "traversal", args: { id: "string", kind: "EDGE_KINDS member" },
254
+ result: `{ kind: string, edges: ${EDGE}[] }`,
255
+ misses: ["UNRESOLVED_TERM"], concurrency: CONCURRENT_SAFE,
256
+ purpose: "Outgoing edges of one closed kind from an individual (an honest empty array is not a miss).",
257
+ note: "An unknown kind (outside EDGE_KINDS) is a programming error and throws TypeError, not a miss.",
258
+ },
259
+ impact: {
260
+ group: "traversal", args: { moduleId: "string" },
261
+ result: `{ total: number, levels: Array<Array<{ id, label, via, tests }>> }`,
262
+ misses: ["UNRESOLVED_TERM"], concurrency: CONCURRENT_SAFE,
263
+ purpose: "The transitive dependent closure the interpreter cannot compute without provider truth.",
264
+ },
265
+ snippet: {
266
+ group: "source", args: { id: "string" },
267
+ result: "{ path: string, span: { start, end }, body: string|null }",
268
+ misses: ["UNRESOLVED_TERM", "NO_SOURCE"], concurrency: CONCURRENT_SAFE,
269
+ purpose: "The exact source span of a symbol. A provider with no working tree returns miss(NO_SOURCE) honestly.",
270
+ },
271
+ context: {
272
+ group: "source", args: { symbol: "string", depth: "min|auto|full?" },
273
+ result: "{ text: string, tier: string } (a sized edit bundle)",
274
+ misses: ["UNRESOLVED_TERM", "NO_SOURCE"], concurrency: CONCURRENT_SAFE,
275
+ purpose: "The composed edit bundle (exemplar, siblings, registration, insertion region). Source-reaching.",
276
+ },
277
+ architecture: {
278
+ group: "aggregate", args: { package: "string?" },
279
+ result: "{ modules: number, packages: Array<[dir, count]>, hubs: Array<{ id, label, importers }> }",
280
+ misses: [], concurrency: CONCURRENT_SAFE,
281
+ purpose: "Package/module shape + the most-imported hub modules. Optional package prefix scopes it.",
282
+ },
283
+ untested: {
284
+ group: "aggregate", args: {},
285
+ result: `{ modules: ${IND}[] }`,
286
+ misses: [], concurrency: CONCURRENT_SAFE,
287
+ purpose: "Modules with no covering test module (via the tests relation).",
288
+ },
289
+ stats: {
290
+ group: "aggregate", args: {},
291
+ result: "{ total: number, classes: Array<{ class: string, count: number }>, truncated: boolean }",
292
+ misses: [], concurrency: CONCURRENT_SAFE,
293
+ purpose: "Per-tmct:class individual counts, read straight from the payload.",
294
+ },
295
+ history: {
296
+ group: "temporal", args: { id: "string" },
297
+ result: "{ commits: Array<{ id, label, author, date, message }> }",
298
+ misses: ["UNRESOLVED_TERM"], concurrency: CONCURRENT_SAFE,
299
+ purpose: "The commits that touched an individual (tmct:commit individuals via tmct:touches).",
300
+ },
301
+ search: {
302
+ group: "search", args: { query: "string", kind: "string?", name: "string?", decorator: "string?" },
303
+ result: `{ results: ${IND}[] }`,
304
+ misses: [], concurrency: CONCURRENT_SAFE,
305
+ purpose: "Lexical, provider-local locate. An empty result set is honest, not a miss.",
306
+ },
307
+ ask: {
308
+ group: "search", args: { query: "string" },
309
+ result: "{ content: string, tmct_ask: object } (the composed NL round-trip envelope)",
310
+ misses: [], concurrency: CONCURRENT_SAFE,
311
+ purpose: "The mechanical, zero-model NL query over the graph — tmct's whole reason to exist.",
312
+ },
313
+ },
314
+ });
315
+
316
+ /** Freeze-deep helper is unnecessary; REPOSITORY_INTERFACE is treated as read-only
317
+ * data. Exported so the contract suite and docs generator share one source. */
318
+ export default REPOSITORY_INTERFACE;
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:
@@ -25,7 +25,7 @@
25
25
 
26
26
  import { mkdir, readFile, readdir, rename, writeFile } from "node:fs/promises";
27
27
  import { basename, dirname, join } from "node:path";
28
- import { appendUtterances } from "./memory/core.mjs";
28
+ import { appendUtterances, CREATED_AT_PROP } from "./memory/core.mjs";
29
29
 
30
30
  export const SESSIONS_DIR_REL = join(".tmct", "sessions");
31
31
 
@@ -73,6 +73,10 @@ export function upsertSession(entities, record) {
73
73
  entities.individuals ||= [];
74
74
  entities.objectProperties ||= [];
75
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 || "";
76
80
  // replace any prior copy of this session (read-time appends run once per turn)
77
81
  entities.individuals = entities.individuals.filter((i) => i?.id !== sid);
78
82
  let group = entities.objectProperties.find((g) => g?.prop === ASKS_ABOUT_PROP);
@@ -111,6 +115,8 @@ export function upsertSession(entities, record) {
111
115
  id: sid, label, class: SESSION_CLASS,
112
116
  derived_from: [], mentions: [],
113
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() },
114
120
  { prop: "mgx:sessionStarted", key: "started", value: started },
115
121
  { prop: "mgx:sessionEnded", key: "ended", value: ended },
116
122
  { prop: "mgx:sessionTurns", key: "turns", value: String(turns.length) },
@@ -216,6 +222,7 @@ async function recordSessionMemory(graphFile, record, repoDirOverride = null) {
216
222
  if (t.answeredIds?.length) parsed.answeredIds = t.answeredIds;
217
223
  if (t.command) parsed.command = t.command;
218
224
  if (t.miss) parsed.miss = true;
225
+ if (t.via) parsed.via = t.via; // answer provenance (W1) — carried into memory
219
226
  utterances.push({
220
227
  role: "visitor", text: query, ts, sessionId: record.id, sessionStarted: record.started || "",
221
228
  ...(Object.keys(parsed).length ? { parsed } : {}),
@@ -264,6 +271,10 @@ export function parseSessionJsonl(text) {
264
271
  // conversational filler are recorded but never folded into the corpus.
265
272
  ...(rec.command ? { command: String(rec.command) } : {}),
266
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) } : {}),
267
278
  });
268
279
  } else if (rec?.type === "end") ended = String(rec.ts || "") || ended;
269
280
  }
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,74 @@
1
+ // wink-model.mjs — the ONE place tmct loads the wink-nlp engine + model.
2
+ //
3
+ // Two adapters sit on top of this leaf loader: ask-nlp.mjs (lemma/POS tier for the
4
+ // ask engine) and prose-nlp.mjs (lemma layer for the prose index). They used to
5
+ // each carry their own `createRequire(import.meta.url)` block — the same ~six lines
6
+ // twice, and both Node-only. That duplication is single-sourced here, and the
7
+ // Node-only limitation is lifted with a browser seam, WITHOUT eagerly bundling the
8
+ // ~1 MB model into anything.
9
+ //
10
+ // Why a registration seam instead of a static `import "wink-nlp"`:
11
+ // - The whole architecture keeps the model OUT of the base/viewer bundle; a static
12
+ // import would drag it in. `wink-eng-lite-web-model` is already the *browser*
13
+ // build, so the model can run in the page — what was missing is a load path a
14
+ // bundler can satisfy without a Node `require`. That path is `registerWinkModel`:
15
+ // a browser/bundler entry imports wink with its own `import` and hands the pair
16
+ // in ONCE, before any lemma/POS use. Node needs nothing — it falls back to
17
+ // `createRequire`. This is the Phase-8 browser-mode unblocker the dependency
18
+ // audit called for (a wiring fix; the model was always browser-capable).
19
+ //
20
+ // The loader stays SYNCHRONOUS (the adapters and their callers are sync): the
21
+ // browser host registers up front; Node resolves lazily via createRequire. Failure
22
+ // is cached as null — a checkout without the optional deps, or a page that never
23
+ // registered a model, simply runs adapter-less (lemma/POS tiers honestly off), it
24
+ // never throws.
25
+
26
+ import { createRequire } from "node:module";
27
+
28
+ let injected; // browser/bundler-supplied `() => ({ winkNLP, model })`, or undefined
29
+ let cached; // undefined = not tried yet; null = unavailable (tried once, honestly off)
30
+
31
+ /** Browser/bundler seam: register a factory returning `{ winkNLP, model }` (each the
32
+ * imported module) so the page's own bundler resolves wink instead of a Node
33
+ * `require`. Call once before any ask/prose lemma use. Resets the cache so a late
34
+ * registration still takes effect. */
35
+ export function registerWinkModel(factory) {
36
+ injected = factory;
37
+ cached = undefined;
38
+ }
39
+
40
+ /** Load `{ winkNLP, model }` once, or null when wink isn't available. Prefers a
41
+ * registered browser factory; otherwise falls back to Node module resolution. */
42
+ export function loadWinkModel() {
43
+ if (cached !== undefined) return cached;
44
+ try {
45
+ const pair = injected ? injected() : nodeRequireWink();
46
+ cached = pair && pair.winkNLP && pair.model ? pair : null;
47
+ } catch {
48
+ cached = null;
49
+ }
50
+ return cached;
51
+ }
52
+
53
+ /** Node fallback: resolve wink through the module system (never a guessed path),
54
+ * exactly as the two adapters did inline before. CJS deps, so `createRequire`. */
55
+ function nodeRequireWink() {
56
+ const require = createRequire(import.meta.url);
57
+ return {
58
+ winkNLP: require("wink-nlp"),
59
+ model: require("wink-eng-lite-web-model"),
60
+ };
61
+ }
62
+
63
+ /** Convenience: the constructed `nlp` instance (`winkNLP(model)`) or null. Both
64
+ * adapters want exactly this. Not cached here — the adapters cache their own
65
+ * higher-level object; constructing `nlp` is cheap next to loading the model. */
66
+ export function winkInstance() {
67
+ const loaded = loadWinkModel();
68
+ if (!loaded) return null;
69
+ try {
70
+ return loaded.winkNLP(loaded.model);
71
+ } catch {
72
+ return null;
73
+ }
74
+ }