@polycode-projects/the-mechanical-code-talker 1.3.2 → 1.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.
@@ -19,7 +19,15 @@ import {
19
19
  edgesOfKind,
20
20
  relationKind,
21
21
  impactClosure,
22
+ scoreSymbolsRanked,
23
+ searchModulesRanked,
24
+ SEARCH_LIMIT,
25
+ contextPlan,
26
+ sizeBundle,
27
+ bundleMask,
28
+ renderGraphOnlyBundle,
22
29
  } from "../codegraph.mjs";
30
+ import { readSpanSafe, sliceSpan } from "../source-slice.mjs";
23
31
  import { ask } from "../ask.mjs";
24
32
  import {
25
33
  hit,
@@ -56,14 +64,86 @@ function groupMetaForKind(graph, kind) {
56
64
  return { predicate: kind, prop: null };
57
65
  }
58
66
 
67
+ const CONTEXT_BODY_MAX_LINES = 200; // mirrors server.mjs's SNIPPET_MAX_LINES for the source-capable body sections
68
+ const CONTEXT_INLINE_CALLEE_LOC = 120; // mirrors server.mjs's INLINE_CALLEE_LOC budget
69
+
70
+ /** The fs-dependent half of context()'s bundle — anchor / exemplar / inlined-callee body
71
+ * TEXT — layered on top of renderGraphOnlyBundle's pure sections when the provider is
72
+ * source-capable. Mirrors server.mjs's buildContextBundle body sections (same shape, same
73
+ * safe readSpanSafe/sliceSpan primitives from Item 1) but lives here because it needs fs,
74
+ * which codegraph.mjs deliberately never touches. Read failures degrade silently (an
75
+ * omitted section), matching buildContextBundle's own graceful-degradation behavior —
76
+ * this is a best-effort enrichment on top of an already-real graph-only hit, not a new
77
+ * failure mode. Returns "" when nothing could be rendered. */
78
+ async function renderSourceBodies(plan, mask, { readFile, repoRoot }) {
79
+ if (!plan.moduleLabel) return "";
80
+ let lines = null;
81
+ try {
82
+ ({ lines } = await readSpanSafe({ readFile, repoRoot, path: plan.moduleLabel }));
83
+ } catch {
84
+ lines = null;
85
+ }
86
+ if (!lines) return "";
87
+ const out = [];
88
+ if (mask.anchor && plan.anchor?.site) {
89
+ const { start, end } = plan.anchor.site;
90
+ out.push(`\n## anchor: ${plan.anchor.label} (${plan.anchor.class}) @ ${plan.moduleLabel}:${start}-${end}`);
91
+ out.push(sliceSpan(lines, start, end, CONTEXT_BODY_MAX_LINES).text);
92
+ if (plan.callHint) out.push(plan.callHint);
93
+ }
94
+ if (mask.exemplar && plan.exemplar?.site) {
95
+ const { start, end } = plan.exemplar.site;
96
+ const dec = plan.exemplar.decorators ? ` @${plan.exemplar.decorators}` : "";
97
+ out.push(`\n## closest example (full body) — copy this style: ${plan.exemplar.label} (${plan.exemplar.class})${dec} @ ${plan.moduleLabel}:${start}-${end}`);
98
+ out.push(sliceSpan(lines, start, end, CONTEXT_BODY_MAX_LINES).text);
99
+ if (plan.callHint) out.push(plan.callHint);
100
+ }
101
+ if (mask.inlinedCallees && plan.calleeBodies.length) {
102
+ let budget = CONTEXT_INLINE_CALLEE_LOC;
103
+ for (const cb of plan.calleeBodies) {
104
+ if (budget <= 0) break;
105
+ const start = cb.site.start;
106
+ const fromThisFile = cb.site.path === plan.moduleLabel;
107
+ let bodyLines = fromThisFile ? lines : null;
108
+ if (!bodyLines) {
109
+ try { ({ lines: bodyLines } = await readSpanSafe({ readFile, repoRoot, path: cb.site.path })); }
110
+ catch { bodyLines = null; }
111
+ }
112
+ if (!bodyLines) continue;
113
+ const sliced = sliceSpan(bodyLines, start, cb.site.end, budget);
114
+ out.push(`\n## inlined callee body (depth-1 in-repo call): ${cb.label} @ ${cb.site.path}:${start}-${cb.site.end}`);
115
+ out.push(sliced.text);
116
+ budget -= (sliced.end - start + 1);
117
+ }
118
+ }
119
+ return out.join("\n");
120
+ }
121
+
59
122
  /**
60
123
  * @param {object} graph a parseEntities() result
61
124
  * @param {object} [opts]
62
- * @param {boolean} [opts.sourceAccess=false] whether source services can read bodies
125
+ * @param {boolean} [opts.sourceAccess=false] whether source services (snippet, context) read
126
+ * real fs bodies. When true, `repoRoot` + `readFile` are REQUIRED (a programmer error to
127
+ * omit either — this module stays fs-free otherwise, "pure graph queries, no fs" by default;
128
+ * fs is an explicit INJECTED capability, never an ambient import).
129
+ * @param {string} [opts.repoRoot] absolute repo root; required when sourceAccess is true.
130
+ * @param {Function} [opts.readFile] async (path, encoding) => string, e.g. node:fs/promises'
131
+ * readFile; required when sourceAccess is true.
132
+ * @param {object|null} [opts.tel] an optional telemetry sink ({ record(fields) }, e.g. from
133
+ * telemetry.mjs's createTelemetry). When present, every service is wrapped ONCE here to time
134
+ * it and record `{ tool: "ri.<name>", perf: { ms_total }, response: { ok, count } }` — counts
135
+ * only, never raw text/body. Null (the default) skips the wrapping loop entirely — zero
136
+ * overhead, and fixtureProvider()/bootstrapProvider() (which pass no tel) are unaffected.
63
137
  * @returns the typed service object
64
138
  */
65
- export function createGraphService(graph, { sourceAccess = false } = {}) {
139
+ export function createGraphService(graph, { sourceAccess = false, repoRoot = null, readFile = null, tel = null } = {}) {
66
140
  const byId = graph.byId;
141
+ if (sourceAccess && (!repoRoot || typeof readFile !== "function")) {
142
+ throw new TypeError(
143
+ "createGraphService({ sourceAccess: true }) requires both repoRoot and readFile — " +
144
+ "fs access is an injected capability, not an ambient import.",
145
+ );
146
+ }
67
147
 
68
148
  const resolveId = (id) => byId.get(id) || null;
69
149
 
@@ -169,28 +249,37 @@ export function createGraphService(graph, { sourceAccess = false } = {}) {
169
249
  });
170
250
  },
171
251
 
172
- edges(id, kind) {
252
+ edges(id, kind, { limit, offset = 0 } = {}) {
173
253
  if (!EDGE_KINDS.includes(kind)) {
174
254
  throw new TypeError(`edges(): unknown kind "${kind}" (not in EDGE_KINDS)`);
175
255
  }
176
256
  const ind = resolveId(id);
177
257
  if (!ind) return miss(MISS_REASONS.UNRESOLVED_TERM, { term: id });
178
258
  const meta = groupMetaForKind(graph, kind);
179
- const edges = edgesOfKind(graph, kind)
259
+ // edge order is stable/memoized (edgesOfKind's own docblock in codegraph.mjs) — a plain
260
+ // slice after filter/map is a safe, backward-compatible pagination: an omitted `limit`
261
+ // leaves the full list untouched (limit=undefined → slice(offset) → everything from offset).
262
+ let edges = edgesOfKind(graph, kind)
180
263
  .filter((e) => e.subject === id)
181
264
  .map((e) => toEdge(e, meta));
265
+ edges = limit == null ? edges.slice(offset) : edges.slice(offset, offset + limit);
182
266
  return hit({ kind, edges });
183
267
  },
184
268
 
185
- impact(moduleId) {
269
+ impact(moduleId, { maxDepth } = {}) {
186
270
  const ind = resolveId(moduleId);
187
271
  if (!ind) return miss(MISS_REASONS.UNRESOLVED_TERM, { term: moduleId });
188
- const levels = impactClosure(graph, ind);
272
+ const levels = maxDepth == null ? impactClosure(graph, ind) : impactClosure(graph, ind, { maxDepth });
189
273
  const total = levels.reduce((n, l) => n + l.length, 0);
190
274
  return hit({ total, levels });
191
275
  },
192
276
 
193
- snippet(id) {
277
+ // NOTE: async (returns Promise<Result>) — real source reads (node:fs/promises) are
278
+ // inherently async, and unlike the pure graph services above, a caller of a
279
+ // source-reaching service should always `await` it regardless of whether THIS
280
+ // particular provider happens to be source-capable (awaiting a non-Promise value is a
281
+ // safe no-op, so this is backward-compatible for a caller that already awaits).
282
+ async snippet(id) {
194
283
  const ind = resolveId(id);
195
284
  if (!ind) return miss(MISS_REASONS.UNRESOLVED_TERM, { term: id });
196
285
  const site = siteOf(ind);
@@ -201,18 +290,37 @@ export function createGraphService(graph, { sourceAccess = false } = {}) {
201
290
  });
202
291
  }
203
292
  if (!site) return miss(MISS_REASONS.NO_SOURCE, { term: id, detail: "no source span in the graph (likely a module)" });
204
- // A source-capable subclass overrides snippet to read the body; the graph-only
205
- // base returns the span with a null body.
206
- return hit({ path: site.path, span: { start: site.start, end: site.end }, body: null });
293
+ try {
294
+ const sliced = await readSpanSafe({
295
+ readFile, repoRoot, path: site.path, start: site.start, end: site.end, maxLines: CONTEXT_BODY_MAX_LINES,
296
+ });
297
+ return hit({ path: site.path, span: { start: site.start, end: site.end }, body: sliced.text });
298
+ } catch (e) {
299
+ // A path-traversal ToolError or any other read failure both land here — honestly,
300
+ // never a throw (the interface's error contract: a clean miss is a value).
301
+ return miss(MISS_REASONS.NO_SOURCE, { term: id, detail: `could not read ${site.path}: ${e?.message || e}` });
302
+ }
207
303
  },
208
304
 
209
- context(symbol) {
305
+ // INTERFACE_VERSION 1.1.0 (2d): context() is now a graph-only HIT for any resolvable
306
+ // symbol — contextPlan/sizeBundle/renderGraphOnlyBundle are pure graph queries, so a
307
+ // graph-only provider (no working tree) can genuinely answer with siblings/registration/
308
+ // globals/tests/exports/insertion-region, everything EXCEPT anchor/exemplar/inlined-callee
309
+ // body TEXT. Only an unresolvable symbol still misses (UNRESOLVED_TERM). A source-capable
310
+ // provider layers the body sections on top via renderSourceBodies (below).
311
+ async context(symbol, { depth = "auto" } = {}) {
210
312
  const { match } = resolveSymbol(graph, String(symbol ?? ""));
211
313
  if (!match) return miss(MISS_REASONS.UNRESOLVED_TERM, { term: String(symbol ?? "") });
212
- return miss(MISS_REASONS.NO_SOURCE, {
213
- term: String(symbol ?? ""),
214
- detail: "the edit bundle reaches into the working tree; this provider exposes no source",
215
- });
314
+ const plan = contextPlan(graph, match);
315
+ const d = String(depth || "auto").trim().toLowerCase();
316
+ let tier, mask;
317
+ if (d === "min") { tier = "TINY"; mask = bundleMask("TINY"); }
318
+ else if (d === "full") { tier = "FULL"; mask = bundleMask("FULL"); }
319
+ else ({ tier, mask } = sizeBundle(plan, graph, {}));
320
+ const graphText = renderGraphOnlyBundle(plan, mask);
321
+ if (!svc.sourceAccess) return hit({ text: graphText, tier });
322
+ const bodyText = await renderSourceBodies(plan, mask, { readFile, repoRoot });
323
+ return hit({ text: bodyText ? `${graphText}\n${bodyText}` : graphText, tier });
216
324
  },
217
325
 
218
326
  architecture({ package: pkg = "" } = {}) {
@@ -281,21 +389,37 @@ export function createGraphService(graph, { sourceAccess = false } = {}) {
281
389
  return hit({ commits });
282
390
  },
283
391
 
284
- search(query, { kind = "", name = "", decorator = "" } = {}) {
285
- const q = String(query || "").trim().toLowerCase();
392
+ // Ranked lexical search, mirroring codegraph.mjs's renderSearch/searchSymbols semantics
393
+ // instead of the old flat substring filter: module-mode (no kind, or kind="module") ranks
394
+ // via searchModulesRanked/scoreModules (path + defined-symbol + import-proximity scoring),
395
+ // symbol-mode (kind names a symbol kind) ranks via scoreSymbolsRanked. name/decorator
396
+ // filters apply in SYMBOL mode only — module mode never supported them in codegraph.mjs
397
+ // either (renderSearch's module branch ignores both beyond the "was anything specified"
398
+ // check), so this does not invent a new filter semantic. Results are capped at
399
+ // `limit` (default SEARCH_LIMIT), sliced after the full ranked array is computed.
400
+ search(query, { kind = "", name = "", decorator = "", limit = SEARCH_LIMIT, offset = 0 } = {}) {
401
+ const rawQuery = String(query || "");
286
402
  const k = String(kind || "").trim().toLowerCase();
287
- const nm = String(name || "").trim().toLowerCase();
403
+ const nm = String(name || "").trim();
288
404
  const dec = String(decorator || "").trim().toLowerCase();
289
- const results = graph.individuals
290
- .filter((i) => {
291
- const label = String(i.label || "").toLowerCase();
292
- if (k && (i.class || "").toLowerCase() !== k) return false;
293
- if (nm && !label.includes(nm)) return false;
294
- if (dec && !String(attrOf(i, "decorators") || "").toLowerCase().includes(dec)) return false;
295
- if (q && !label.includes(q)) return false;
296
- return true;
297
- })
298
- .map(toIndividual);
405
+ let nameRe = null;
406
+ if (nm) {
407
+ try { nameRe = new RegExp(nm, "i"); } catch { nameRe = null; }
408
+ }
409
+ let rankedInds; // Individual[], highest-ranked first
410
+ if (k && k !== "module") {
411
+ const tokens = rawQuery.toLowerCase().split(/[^a-z0-9_]+/).filter(Boolean);
412
+ rankedInds = scoreSymbolsRanked(graph, tokens, { kind: k, decFilter: dec, nameRe }).map((s) => s.ind);
413
+ } else {
414
+ // label→individual, scoped to this call (no persistent cache) — maps searchModulesRanked's
415
+ // `path` labels (a copy of the label, not the live individual) back to real Individuals.
416
+ const byLabel = new Map();
417
+ for (const i of graph.individuals) if ((i.class || "") === "Module") byLabel.set(i.label, i);
418
+ rankedInds = searchModulesRanked(graph, rawQuery)
419
+ .map(({ path }) => byLabel.get(path))
420
+ .filter(Boolean);
421
+ }
422
+ const results = rankedInds.slice(offset, offset + limit).map(toIndividual);
299
423
  return hit({ results });
300
424
  },
301
425
 
@@ -305,8 +429,61 @@ export function createGraphService(graph, { sourceAccess = false } = {}) {
305
429
  },
306
430
  };
307
431
 
432
+ // Optional telemetry (Item 3.2): when `tel` is supplied, wrap every RI service ONCE here at
433
+ // construction — never per-method by hand — to time it and record COUNTS only (never raw
434
+ // text/body; see responseCounts below and telemetry.mjs's redact() as a second net). When
435
+ // `tel` is null (the default), this loop does not run at all: zero overhead, and
436
+ // fixtureProvider()/bootstrapProvider() (which pass no `tel`) are completely unaffected.
437
+ if (tel) {
438
+ for (const name of SERVICES) {
439
+ const orig = svc[name];
440
+ if (typeof orig !== "function") continue;
441
+ svc[name] = (...args) => {
442
+ const t0 = performance.now();
443
+ const result = orig.apply(svc, args);
444
+ // snippet/context are ASYNC (Promise<Result>) — record after settling, still return a
445
+ // promise; every other service is synchronous — record immediately, return the value
446
+ // as-is. Detected at the ACTUAL call (not a static per-service list) so this is correct
447
+ // even if a future service's sync/async-ness varies by branch.
448
+ if (result && typeof result.then === "function") {
449
+ return result.then((r) => {
450
+ recordTelemetry(tel, name, performance.now() - t0, r);
451
+ return r;
452
+ });
453
+ }
454
+ recordTelemetry(tel, name, performance.now() - t0, result);
455
+ return result;
456
+ };
457
+ }
458
+ }
459
+
308
460
  return svc;
309
461
  }
310
462
 
463
+ /** tel.record({ tool: `ri.${name}`, perf: { ms_total }, response }) for one wrapped service
464
+ * call — swallows any error (telemetry must never break the call it's observing). */
465
+ function recordTelemetry(tel, name, ms, result) {
466
+ try {
467
+ tel.record({ tool: `ri.${name}`, perf: { ms_total: ms }, response: responseCounts(result) });
468
+ } catch { /* telemetry must never break the caller's turn */ }
469
+ }
470
+
471
+ /** Counts only, never raw text/body: { ok, count } — count is the sum of every array-valued
472
+ * field's length under result.value (edges.length, results.length, candidates.length, …), a
473
+ * single generic aggregate rather than guessing each service's own field names. A miss
474
+ * records its reason (a closed-set token, not free text) instead of a count. */
475
+ function responseCounts(result) {
476
+ if (!result || typeof result !== "object" || result.ok !== true) {
477
+ return { ok: false, reason: result?.miss?.reason || null };
478
+ }
479
+ let count = 0;
480
+ const value = result.value;
481
+ if (Array.isArray(value)) count = value.length;
482
+ else if (value && typeof value === "object") {
483
+ for (const v of Object.values(value)) if (Array.isArray(v)) count += v.length;
484
+ }
485
+ return { ok: true, count };
486
+ }
487
+
311
488
  /** The source-reaching services a graph-only provider satisfies with NO_SOURCE. */
312
489
  export { SOURCE_SERVICES };
@@ -18,7 +18,7 @@
18
18
  /** SemVer of the interface. Additive-by-default: new services / optional args are
19
19
  * minor bumps; the suite for version N stays green under N+1. A breaking change
20
20
  * is a new MAJOR with its own suite (see the versioning policy in the plan). */
21
- export const INTERFACE_VERSION = "1.0.0";
21
+ export const INTERFACE_VERSION = "1.1.0";
22
22
 
23
23
  /** The OWL vocabulary the types are grounded in. */
24
24
  export const ONTOLOGY_IRI = "urn:tmct:core";
@@ -211,7 +211,10 @@ export const REPOSITORY_INTERFACE = Object.freeze({
211
211
  capabilitiesModel:
212
212
  "A provider advertises `capabilities: string[]` (service names it implements). " +
213
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.",
214
+ "snippet may be advertised yet answer miss(NO_SOURCE) when no working tree exists (it has " +
215
+ "nothing useful without fs); context is graph-only-capable since INTERFACE_VERSION 1.1.0 — " +
216
+ "it returns a real hit (graph-only bundle) for any resolvable symbol even with no working " +
217
+ "tree, only escalating NO_SOURCE-style behavior to richer body text when source-capable.",
215
218
  services: {
216
219
  resolve: {
217
220
  group: "resolution", args: { term: "string" },
@@ -264,15 +267,26 @@ export const REPOSITORY_INTERFACE = Object.freeze({
264
267
  },
265
268
  snippet: {
266
269
  group: "source", args: { id: "string" },
267
- result: "{ path: string, span: { start, end }, body: string|null }",
270
+ result: "{ path: string, span: { start, end }, body: string|null } — Promise<Result>",
268
271
  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.",
272
+ purpose: "The exact source span of a symbol. A provider with no working tree returns miss(NO_SOURCE) honestly — snippet has nothing useful without fs.",
273
+ note: "ASYNC (returns Promise<Result>) — a real body read is inherently fs I/O; callers should always await it. A graph-only provider still resolves synchronously-in-spirit (the promise settles on the same tick) but the return type is uniformly a Promise regardless of sourceAccess.",
270
274
  },
271
275
  context: {
272
276
  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.",
277
+ result: "{ text: string, tier: string } (a sized edit bundle) — Promise<Result>",
278
+ // INTERFACE_VERSION 1.1.0 (2026-07): NARROWED miss contract — context() used to
279
+ // unconditionally miss(NO_SOURCE) (its whole edit bundle was implemented as fs-only).
280
+ // contextPlan/sizeBundle/renderGraphOnlyBundle are pure graph queries, so a graph-only
281
+ // provider (sourceAccess:false) now returns a REAL HIT for any resolvable symbol —
282
+ // siblings, registration, class members, __all__/re-exports, insertion region, covering
283
+ // tests, co-change — everything except anchor/exemplar/inlined-callee BODY TEXT, which
284
+ // still needs a source-capable provider. NO_SOURCE is consequently no longer a miss
285
+ // reason context() can return (dropped from the list below) — the only remaining miss is
286
+ // an unresolvable symbol.
287
+ misses: ["UNRESOLVED_TERM"], concurrency: CONCURRENT_SAFE,
288
+ purpose: "The composed edit bundle (exemplar, siblings, registration, insertion region). A graph-only provider returns the graph-only sections as a real hit; a source-capable provider (sourceAccess:true, repoRoot, readFile) additionally includes the anchor/exemplar/inlined-callee body text.",
289
+ note: "ASYNC (returns Promise<Result>) — see snippet's note; source-capable rendering is fs I/O.",
276
290
  },
277
291
  architecture: {
278
292
  group: "aggregate", args: { package: "string?" },
package/src/server.mjs CHANGED
@@ -15,8 +15,9 @@
15
15
  // otherwise hand-compose into one deterministic round-trip. See ask.mjs.
16
16
 
17
17
  import { readFile } from "node:fs/promises";
18
- import { dirname, join } from "node:path";
18
+ import { dirname } from "node:path";
19
19
  import { ToolError } from "./config.mjs";
20
+ import { sliceSpan, readSpanSafe } from "./source-slice.mjs";
20
21
  import * as defaultSource from "./source.mjs";
21
22
  import {
22
23
  parseEntities,
@@ -202,7 +203,7 @@ function resolveOrThrow(svc, symbol, what) {
202
203
  * variable, history-derived tails (covering tests, co-change) come LAST, so a stable prefix
203
204
  * maximises prompt-cache reuse.
204
205
  */
205
- export async function buildContextBundle(args, { config, source = defaultSource, trim = false } = {}) {
206
+ export async function buildContextBundle(args, { config, source = defaultSource, trim = false, tel = null } = {}) {
206
207
  const symbol = String(args?.symbol || "").trim();
207
208
  if (!symbol) throw new ToolError("symbol is required");
208
209
  const depth = String(args?.depth || "auto").trim().toLowerCase();
@@ -216,7 +217,17 @@ export async function buildContextBundle(args, { config, source = defaultSource,
216
217
  // by the tmct-max arm to test whether more injection re-bloats.
217
218
  const max = Boolean(args?.max);
218
219
  const graph = await loadGraph(config, source);
219
- const svc = createGraphService(graph);
220
+ // repo root = the dir containing .tmct/ (graphFile = <repo>/.tmct/graph.json) — computed
221
+ // before createGraphService so the RI service can be constructed source-capable (2e): this
222
+ // module still does its OWN safe reads below (readSpanSafe/sliceSpan, Item 1) rather than
223
+ // delegating to svc.context() — see the module docblock's note on why. Passing sourceAccess
224
+ // through anyway keeps svc.snippet()/svc.context() usable by any future/external caller of
225
+ // this same service object without a second, divergent construction path. `tel` (optional,
226
+ // Item 3.3) is an already-constructed telemetry sink threaded down from the caller (e.g.
227
+ // chat.mjs's session-level createTelemetry) — never minted here, so a caller that never
228
+ // passes one costs nothing extra (createGraphService's own wrapping loop no-ops on tel:null).
229
+ const repoRoot = dirname(dirname(config.graphFile));
230
+ const svc = createGraphService(graph, { sourceAccess: true, repoRoot, readFile, tel });
220
231
  const { match } = resolveOrThrow(svc, symbol, "symbol");
221
232
  const plan = contextPlan(graph, match);
222
233
  // #6/B1/B6: pick the section mask by depth — min forces TINY, full/max forces everything, auto
@@ -228,17 +239,13 @@ export async function buildContextBundle(args, { config, source = defaultSource,
228
239
  else if (max || depth === "full") { tier = "FULL"; mask = bundleMask("FULL"); topup = true; }
229
240
  else ({ tier, mask, topup } = sizeBundle(plan, graph, { untuned }));
230
241
  if (trim && !max) mask = trimBundleMask(mask); // B2: secondary digest module → signatures + region only (max keeps the full bundle)
231
- const repoRoot = dirname(dirname(config.graphFile));
232
242
  let lines = null;
233
243
  if (plan.moduleLabel) {
234
- try { lines = (await readFile(join(repoRoot, plan.moduleLabel), "utf8")).split("\n"); }
244
+ try { ({ lines } = await readSpanSafe({ readFile, repoRoot, path: plan.moduleLabel })); }
235
245
  catch { lines = null; }
236
246
  }
237
247
  const lineAt = (n) => (lines && lines[n - 1] != null ? lines[n - 1].trim() : "");
238
- const sliceBody = (start, end) => {
239
- const e = Math.min(lines.length, Math.min(end, start + SNIPPET_MAX_LINES - 1));
240
- return lines.slice(start - 1, e).map((l, i) => `${start + i}\t${l}`).join("\n");
241
- };
248
+ const sliceBody = (start, end) => sliceSpan(lines, start, end, SNIPPET_MAX_LINES).text;
242
249
  const out = [
243
250
  `Edit context for ${plan.moduleLabel} [${tier}${trim ? " secondary" : ""}] — assembled from the typed graph + that file. ` +
244
251
  "You do NOT need to Read it; write the new code directly after reviewing this.",
@@ -266,16 +273,15 @@ export async function buildContextBundle(args, { config, source = defaultSource,
266
273
  for (const cb of plan.calleeBodies) {
267
274
  if (budget <= 0) break;
268
275
  const start = cb.site.start;
269
- const end = Math.min(cb.site.end, start + budget - 1);
270
276
  const fromThisFile = cb.site.path === plan.moduleLabel;
271
277
  const bodyLines = fromThisFile && lines
272
278
  ? lines
273
- : await readFile(join(repoRoot, cb.site.path), "utf8").then((t) => t.split("\n")).catch(() => null);
279
+ : await readSpanSafe({ readFile, repoRoot, path: cb.site.path }).then((r) => r.lines).catch(() => null);
274
280
  if (!bodyLines) continue;
275
- const e = Math.min(bodyLines.length, end);
281
+ const sliced = sliceSpan(bodyLines, start, cb.site.end, budget);
276
282
  out.push(`\n## inlined callee body (depth-1 in-repo call): ${cb.label} @ ${cb.site.path}:${start}-${cb.site.end}`);
277
- out.push(bodyLines.slice(start - 1, e).map((l, i) => `${start + i}\t${l}`).join("\n"));
278
- budget -= (e - start + 1);
283
+ out.push(sliced.text);
284
+ budget -= (sliced.end - start + 1);
279
285
  }
280
286
  }
281
287
  if (mask.classMembers && plan.classMembers && plan.classMembers.members.length) {
@@ -337,11 +343,11 @@ const DISPATCH_TOOLS = new Set([
337
343
  "tmct_file_history", "tmct_method_history", "tmct_class_history",
338
344
  ]);
339
345
 
340
- export async function dispatchTool(name, args, { config, source = defaultSource } = {}) {
346
+ export async function dispatchTool(name, args, { config, source = defaultSource, tel = null } = {}) {
341
347
  // tmct_context builds (and loads) its own edit bundle — return early so we don't
342
348
  // double-load the graph for it.
343
349
  if (name === "tmct_context") {
344
- return (await buildContextBundle(args, { config, source })).text;
350
+ return (await buildContextBundle(args, { config, source, tel })).text;
345
351
  }
346
352
  // Reject an unknown tool BEFORE touching the graph — preserves the original
347
353
  // ordering (an unknown name never triggers a load).
@@ -352,7 +358,13 @@ export async function dispatchTool(name, args, { config, source = defaultSource
352
358
  // the result with tmct's own render* layer (which reads svc.graph). This is the
353
359
  // switch's operations extracted into a named, typed seam without changing bytes.
354
360
  const graph = await loadGraph(config, source);
355
- const svc = createGraphService(graph);
361
+ // repo root = the dir containing .tmct/ (graphFile = <repo>/.tmct/graph.json). Passed through
362
+ // to createGraphService (2e) so svc.snippet()/svc.context() are usable directly; this
363
+ // dispatcher still does its OWN safe read for tmct_snippet below (readSpanSafe/sliceSpan,
364
+ // Item 1) rather than delegating, to keep its richer presentation (candidates, call hints,
365
+ // truncation notices) — see the tmct_snippet branch below.
366
+ const repoRoot = dirname(dirname(config.graphFile));
367
+ const svc = createGraphService(graph, { sourceAccess: true, repoRoot, readFile, tel });
356
368
  if (name === "tmct_context_more") {
357
369
  const symbol = String(args?.symbol || "").trim();
358
370
  if (!symbol) throw new ToolError("symbol is required");
@@ -379,18 +391,16 @@ export async function dispatchTool(name, args, { config, source = defaultSource
379
391
  "it is likely a module. Use tmct_describe for its contents, then tmct_snippet one of the functions/classes it defines.",
380
392
  );
381
393
  }
382
- // repo root = the dir containing .tmct/ (graphFile = <repo>/.tmct/graph.json)
383
- const repoRoot = dirname(dirname(config.graphFile));
384
- const abs = join(repoRoot, site.path);
385
- let text;
386
- try { text = await readFile(abs, "utf8"); }
387
- catch (e) { throw new ToolError(`could not read ${site.path} (${e?.code || e?.message || e})`); }
388
- const lines = text.split("\n");
389
- const start = Math.max(1, site.start);
390
- let end = Math.min(lines.length, site.end);
391
- let truncated = false;
392
- if (end - start + 1 > SNIPPET_MAX_LINES) { end = start + SNIPPET_MAX_LINES - 1; truncated = true; }
393
- const body = lines.slice(start - 1, end).map((l, i) => `${start + i}\t${l}`).join("\n");
394
+ let sliced;
395
+ try {
396
+ sliced = await readSpanSafe({
397
+ readFile, repoRoot, path: site.path, start: site.start, end: site.end, maxLines: SNIPPET_MAX_LINES,
398
+ });
399
+ } catch (e) {
400
+ if (e instanceof ToolError) throw e; // path-traversal guard: message already names the offending path
401
+ throw new ToolError(`could not read ${site.path} (${e?.code || e?.message || e})`);
402
+ }
403
+ const { text: body, truncated } = sliced;
394
404
  const span = site.end > site.start ? `${site.start}-${site.end}` : `${site.start}`;
395
405
  const header = `${match.label} — ${match.class || "Entity"} @ ${site.path}:${span}`;
396
406
  const note = truncated ? `\n… (truncated to ${SNIPPET_MAX_LINES} lines; full span ${span})` : "";
@@ -0,0 +1,68 @@
1
+ // Shared, safe source-span slicing for the tool layer (src/server.mjs) and the
2
+ // source-capable Repository Interface provider (src/providers/graph-service.mjs).
3
+ //
4
+ // Two halves, deliberately split:
5
+ // - sliceSpan — PURE. Given an in-memory `lines` array, extracts + line-numbers
6
+ // one span. No fs, no path logic. Lifted byte-identical from the
7
+ // slicing logic that used to live inline in server.mjs (buildContextBundle's
8
+ // `sliceBody` closure and the tmct_snippet dispatch branch).
9
+ // - readSpanSafe — the fs-touching half. Resolves `join(repoRoot, path)` with Node's
10
+ // `resolve()` and REFUSES to read anything that resolves outside
11
+ // `repoRoot` before ever calling the injected `readFile`. This is the
12
+ // fix for a real path-traversal gap: graph.json's `site.path` values are
13
+ // data (parsed from a JSON artifact on disk), not trusted input — a
14
+ // crafted or corrupted graph with a `../../etc/passwd`-shaped path must
15
+ // never reach an unguarded readFile.
16
+
17
+ import { resolve, sep } from "node:path";
18
+ import { ToolError } from "./config.mjs";
19
+
20
+ /**
21
+ * Extract and line-number one span from an in-memory `lines` array (already split on "\n").
22
+ * Pure — no fs. `start`/`end` are 1-based, inclusive, graph-derived (may be out of range).
23
+ * `maxLines` (optional) caps the span length, truncating from `start`; omit for no cap.
24
+ * Returns { start, end, text, truncated } where start/end are the CLAMPED bounds actually
25
+ * sliced (not the raw input) and text is "<lineNo>\t<content>" per line, joined with "\n".
26
+ */
27
+ export function sliceSpan(lines, start, end, maxLines) {
28
+ const s = Math.max(1, start);
29
+ let e = Math.min(lines.length, end);
30
+ let truncated = false;
31
+ if (maxLines != null && e - s + 1 > maxLines) {
32
+ e = s + maxLines - 1;
33
+ truncated = true;
34
+ }
35
+ const text = lines.slice(s - 1, e).map((l, i) => `${s + i}\t${l}`).join("\n");
36
+ return { start: s, end: e, text, truncated };
37
+ }
38
+
39
+ /**
40
+ * Safely read `path` (relative, from the graph) rooted at `repoRoot`, then optionally slice
41
+ * one span out of it. `readFile` is injected (e.g. node:fs/promises' readFile) — this module
42
+ * never imports fs itself, so callers control the fs capability.
43
+ *
44
+ * SECURITY: resolves the joined path and verifies it is `repoRoot` itself or a descendant of
45
+ * it (`resolved === repoRoot || resolved.startsWith(repoRoot + sep)`) BEFORE calling `readFile`.
46
+ * A path that escapes repoRoot (e.g. via `../../etc/passwd`-shaped graph data) throws a
47
+ * ToolError naming the offending path — never reaches fs.
48
+ *
49
+ * When `start`/`end` are omitted, returns the whole file as `{ lines }` (for callers that need
50
+ * to slice multiple spans out of the same file without re-reading it — see buildContextBundle).
51
+ * When both are given, also slices via sliceSpan and spreads its result in.
52
+ */
53
+ export async function readSpanSafe({ readFile, repoRoot, path, start, end, maxLines }) {
54
+ // Normalize repoRoot to absolute here too (defense in depth) — resolve(repoRoot, path)
55
+ // is always absolute, so comparing it against a RELATIVE repoRoot would make this guard
56
+ // reject every read, not just traversal attempts (the actual bug this normalization
57
+ // fixes; callers should already pass an absolute repoRoot via src/config.mjs, but this
58
+ // function is the real security boundary and must not depend on that).
59
+ const root = resolve(repoRoot);
60
+ const resolved = resolve(root, path);
61
+ if (resolved !== root && !resolved.startsWith(root + sep)) {
62
+ throw new ToolError(`refusing to read outside the repository root: ${path}`);
63
+ }
64
+ const text = await readFile(resolved, "utf8");
65
+ const lines = text.split("\n");
66
+ if (start == null || end == null) return { lines };
67
+ return { lines, ...sliceSpan(lines, start, end, maxLines) };
68
+ }
package/src/telemetry.mjs CHANGED
@@ -14,8 +14,11 @@ import { appendFile } from "node:fs/promises";
14
14
  import { dirname, join } from "node:path";
15
15
  import { uuidv7 } from "./uuid.mjs";
16
16
 
17
- /** Field names whose VALUES are (or embed) raw source and must never be logged. */
18
- const DROP_KEYS = new Set(["text", "content", "snippet"]);
17
+ /** Field names whose VALUES are (or embed) raw source and must never be logged. `body` is
18
+ * the Repository Interface's own field name for real source text (snippet()/context()'s
19
+ * source-capable body sections, PLAN item 2/3) — without it, raw source read via the
20
+ * now-source-capable createGraphService could leak straight into a telemetry log. */
21
+ const DROP_KEYS = new Set(["text", "content", "snippet", "body"]);
19
22
  /** String fields longer than this are truncated — except query.raw (the user's own
20
23
  * question, which is the correlation key and is not file content). */
21
24
  const MAX_STR = 500;
@@ -101,6 +101,13 @@ export async function normalizeConfig(raw, { configDir } = {}) {
101
101
  if (seed.limit !== undefined) seedCfg.limit = seed.limit;
102
102
  if (Object.keys(seedCfg).length) cfg.seed = seedCfg;
103
103
 
104
+ // Extension-pack seam (src/extensions.mjs): sparse PASS-THROUGH only — the
105
+ // raw `[extensions]`/`[bias]` tables ride through unmodified so a caller can
106
+ // see they're present; validation happens one layer up, in
107
+ // resolveExtensions() (never here — this module stays a plain raw reader).
108
+ if (src.extensions !== undefined) cfg.extensions = src.extensions;
109
+ if (src.bias !== undefined) cfg.bias = src.bias;
110
+
104
111
  const idx = src.index || {};
105
112
  const index = {};
106
113
  if (idx.languages !== undefined) index.languages = idx.languages;
@@ -137,6 +144,16 @@ export async function normalizeConfig(raw, { configDir } = {}) {
137
144
  const tel = src.telemetry || {};
138
145
  if (tel.enabled !== undefined) cfg.telemetry = { enabled: tel.enabled };
139
146
 
147
+ // [memory] retention_versions — read by memory/core.mjs's snapshotMemory as
148
+ // the manifest-bootstrap default (only when no manifest.json exists yet; a
149
+ // persisted manifest's own retentionVersions wins after that). Sparse like
150
+ // every other table here: absent when unset, so "unset" stays distinguishable
151
+ // from "set to the default" — the shipped default (5, memory/core.mjs's
152
+ // DEFAULT_RETENTION) is applied where the value is actually CONSUMED
153
+ // (snapshotMemory), not injected here.
154
+ const mem = src.memory || {};
155
+ if (mem.retention_versions !== undefined) cfg.memory = { retentionVersions: mem.retention_versions };
156
+
140
157
  const unwired = [];
141
158
  for (const [a, b] of UNWIRED_KEYS) {
142
159
  if (src[a] && src[a][b] !== undefined) unwired.push(`${a}.${b}`);