@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.
- package/README.md +141 -7
- package/bin/tmct.mjs +128 -18
- package/package.json +1 -1
- package/src/chat.mjs +119 -72
- package/src/codegraph.mjs +73 -4
- package/src/config.mjs +7 -2
- package/src/conformance.mjs +59 -15
- package/src/corpus/templates.mjs +38 -0
- package/src/extensions.mjs +348 -0
- package/src/init.mjs +92 -7
- package/src/memory/bias.mjs +77 -0
- package/src/memory/blocks.mjs +57 -18
- package/src/memory/core.mjs +237 -20
- package/src/memory/fold.mjs +0 -0
- package/src/memory/trust.mjs +94 -6
- package/src/providers/bootstrap.mjs +5 -3
- package/src/providers/fixture.mjs +7 -3
- package/src/providers/graph-service.mjs +205 -28
- package/src/repository-interface.mjs +21 -7
- package/src/server.mjs +39 -29
- package/src/source-slice.mjs +68 -0
- package/src/telemetry.mjs +5 -2
- package/src/toml-config.mjs +17 -0
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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(
|
|
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
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
-
|
|
285
|
-
|
|
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()
|
|
403
|
+
const nm = String(name || "").trim();
|
|
288
404
|
const dec = String(decorator || "").trim().toLowerCase();
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
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.
|
|
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
|
-
"
|
|
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
|
-
|
|
275
|
-
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
|
279
|
+
: await readSpanSafe({ readFile, repoRoot, path: cb.site.path }).then((r) => r.lines).catch(() => null);
|
|
274
280
|
if (!bodyLines) continue;
|
|
275
|
-
const
|
|
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(
|
|
278
|
-
budget -= (
|
|
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
|
-
|
|
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
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
catch (e) {
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
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
|
-
|
|
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;
|
package/src/toml-config.mjs
CHANGED
|
@@ -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}`);
|