@polycode-projects/the-mechanical-code-talker 3.0.0 → 3.0.2

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 CHANGED
@@ -802,6 +802,17 @@ self-contained HTML file you can open in a browser:
802
802
  rows; --term resolves via the same normalization chat uses.
803
803
  ```
804
804
 
805
+ `tmct digest <term>` turns what the graph knows about one term into a short, readable
806
+ paragraph — the vocabulary-side sibling of `tmct cli digest`'s code map. The narrative
807
+ leads, its sources follow, and the full fact count points at the ledger for the rest:
808
+
809
+ ```output:help:digest
810
+ tmct digest <term> a readable digest of what the graph knows about one term:
811
+ [--repo <abs>] a bounded narrative first (selected, sense-filtered,
812
+ [--graph <path>] deduped), then its sources and the stored-fact count.
813
+ [--config <path>] The vocabulary-side sibling of `cli digest`'s code map.
814
+ ```
815
+
805
816
  `tmct serve` runs an Anthropic Messages API-compatible HTTP endpoint over the graph,
806
817
  so a tool-loop client can call tmct like a model, at $0:
807
818
 
package/bin/tmct.mjs CHANGED
@@ -920,6 +920,43 @@ async function main() {
920
920
  // on disk: one copy-paste Bash invocation per tool, rewritten on every init.
921
921
  process.stdout.write(`cold-tool catalog: ${await writeToolsCatalog(repoRoot)}\n`);
922
922
 
923
+ // `--with-persona code`: on top of the corpus-vocabulary bias `initRepo` just wrote,
924
+ // also run the repository INDEXER (`tmct index`'s own machinery) against this repo's
925
+ // real source, so one command produces a `.tmct/graph.json` backed by the repo itself —
926
+ // not just a bias preset. `chat --repo` already reads whatever graph is on disk; this
927
+ // is the onboarding path that puts one there. Failure-tolerant like the corpus seed
928
+ // above: a repo that can't be indexed (no supported source, or a parse error) degrades
929
+ // to an initialized-but-graphless repo, never a crashed init.
930
+ if (personaName === "code") {
931
+ try {
932
+ const { indexRepository } = await import("../src/index/index-repo.mjs");
933
+ const stats = await indexRepository(repoRoot);
934
+ for (const { pass, message } of stats.gitErrors || []) {
935
+ process.stderr.write(`tmct init: WARNING git history pass '${pass}' — ${message} (graph built without those edges)\n`);
936
+ }
937
+ const perLang = Object.entries(stats.perLang)
938
+ .map(([lang, s]) => `${lang}: ${s.modules} modules, ${s.symbols} symbols`).join("; ");
939
+ const kib = (stats.bytes / 1024).toFixed(1);
940
+ process.stdout.write(
941
+ `code persona: indexed the repo — wrote ${stats.graphFile} (${stats.modules} modules, ${stats.symbols} symbols; ${kib} KiB)\n`
942
+ + (perLang ? `${perLang}\n` : "no supported source found under the repo\n"),
943
+ );
944
+ // The capability the graph just unlocked, said out loud — a graphless
945
+ // repo (no supported source) gets no such promise.
946
+ if (stats.modules > 0) {
947
+ process.stdout.write(
948
+ `indexed ${stats.modules} modules (${stats.symbols} symbols) — code questions now work in \`tmct chat\`: `
949
+ + `try "which modules import <path>" or "what does <module> do".\n`,
950
+ );
951
+ }
952
+ if (stats.failures?.length) {
953
+ process.stderr.write(`tmct init: ${stats.failures.length} file(s) failed to parse (skipped): ${stats.failures.slice(0, 5).join(", ")}${stats.failures.length > 5 ? ", …" : ""}\n`);
954
+ }
955
+ } catch (e) {
956
+ process.stderr.write(`tmct init: code persona indexing skipped (${e?.message || e})\n`);
957
+ }
958
+ }
959
+
923
960
  // `--corpus`/`--ontology`/`--lexicon` now mean "activate this bundle and
924
961
  // PERSIST that into tmct.toml" — so a second `tmct init` (or the next chat
925
962
  // bootstrap) remembers the choice, unlike the old ad hoc path, which had
@@ -1011,6 +1048,14 @@ async function main() {
1011
1048
  `tmct index — wrote ${stats.graphFile} (${stats.modules} modules, ${stats.symbols} symbols; ${kib} KiB)\n`
1012
1049
  + (perLang ? `${perLang}\n` : "no supported source found under the repo\n"),
1013
1050
  );
1051
+ // Same discoverability line the code-persona init prints: the graph just
1052
+ // unlocked code questions, so say so — but never over an empty graph.
1053
+ if (stats.modules > 0) {
1054
+ process.stdout.write(
1055
+ `indexed ${stats.modules} modules (${stats.symbols} symbols) — code questions now work in \`tmct chat\`: `
1056
+ + `try "which modules import <path>" or "what does <module> do".\n`,
1057
+ );
1058
+ }
1014
1059
  if (stats.failures?.length) {
1015
1060
  process.stderr.write(`tmct index: ${stats.failures.length} file(s) failed to parse (skipped): ${stats.failures.slice(0, 5).join(", ")}${stats.failures.length > 5 ? ", …" : ""}\n`);
1016
1061
  }
@@ -1283,6 +1328,59 @@ async function main() {
1283
1328
  return;
1284
1329
  }
1285
1330
 
1331
+ if (mode === "digest") {
1332
+ // `tmct digest <term>` — the vocabulary-side digest: turn what the graph
1333
+ // knows about one term into a bounded, readable paragraph, deterministically
1334
+ // (src/domain/digest, wired through corpus/digest-bank.mjs), beside the
1335
+ // code-side `tmct cli digest`. Same repo/backend resolution as `viz`.
1336
+ const rest = process.argv.slice(3);
1337
+ const term = rest.find((a) => !a.startsWith("-"));
1338
+ if (!term) {
1339
+ process.stderr.write("tmct digest — name a term: `tmct digest <term>`\n");
1340
+ process.exitCode = 1;
1341
+ return;
1342
+ }
1343
+ const { resolveRuntimeConfig } = await import("../src/services/cli-args.mjs");
1344
+ const { openMemoryBackend, loadMemory, readFactRows, normFactTerm } = await import("../src/adapters/memory/core.mjs");
1345
+ const { digestTermFromRows } = await import("../src/adapters/corpus/digest-bank.mjs");
1346
+ const { repo, toml } = await resolveRuntimeConfig({ argv: rest });
1347
+ const backendChoice = String(process.env.TMCT_MEMORY_BACKEND || toml?.memory?.backend || "").trim().toLowerCase();
1348
+ const { dir: memoryDir, close: closeMemoryStore } = await openMemoryBackend(repo, backendChoice);
1349
+ let rows;
1350
+ try { rows = readFactRows(await loadMemory(memoryDir)); } finally { await closeMemoryStore(); }
1351
+ // Fold a naive plural to its base on BOTH sides so "aardvark" reaches a
1352
+ // stored "aardvarks" and vice versa — the store keeps whichever number the
1353
+ // teaching sentence used.
1354
+ const baseTerm = (s) => {
1355
+ const x = normFactTerm(s);
1356
+ if (x.endsWith("es")) return x.slice(0, -2);
1357
+ if (x.endsWith("s")) return x.slice(0, -1);
1358
+ return x;
1359
+ };
1360
+ const wantedBase = baseTerm(term);
1361
+ const termRows = rows.filter((r) => baseTerm(r.subject) === wantedBase);
1362
+ if (!termRows.length) {
1363
+ process.stdout.write(`I don't have anything stored about "${term}".\n`);
1364
+ return;
1365
+ }
1366
+ // Render under the term's own stored spelling, not the query's — a plural
1367
+ // query ("doctors") reads back as the singular the store holds ("doctor").
1368
+ const subject = termRows[0].subject;
1369
+ const article = digestTermFromRows(subject, termRows, rows, { budget: 12 });
1370
+ if (!article || !article.paragraphs.length) {
1371
+ // No structure bank, or nothing the selector kept — surface the plain
1372
+ // fact count rather than an empty digest.
1373
+ process.stdout.write(`${subject} — ${termRows.length} fact(s) stored. Run \`tmct viz --focus ${subject}\` for the full ledger.\n`);
1374
+ return;
1375
+ }
1376
+ const sources = article.sources.map((s) => s.provenance).filter(Boolean);
1377
+ const out = [`${subject}`, ...article.paragraphs];
1378
+ if (sources.length) out.push("", `Sources: ${[...new Set(sources)].join("; ")}`);
1379
+ out.push(`${article.detail.factCount} fact(s) stored — \`tmct viz --focus ${subject}\` shows them all.`);
1380
+ process.stdout.write(out.join("\n") + "\n");
1381
+ return;
1382
+ }
1383
+
1286
1384
  if (mode === "serve") {
1287
1385
  // `tmct serve` — the Phase-A capability-router interface: an Anthropic
1288
1386
  // Messages API-compatible HTTP endpoint (POST /v1/messages) over the graph.
@@ -0,0 +1,97 @@
1
+ # data/templates/constructions/digest-sentence-structures.toml — the digest
2
+ # layer's sentence-structure bank (PLAN_DIGEST.md stage 2). A closed table of
3
+ # sentence skeletons keyed by relation FAMILY and FORM, hand-authored and
4
+ # reviewed as DATA, in the same committed-TOML idiom the construction banks in
5
+ # this directory already use. A new family or a reworded frame is an edit to
6
+ # this file, never a code change — src/domain/digest/structures.mjs validates
7
+ # and renders these skeletons but never invents one.
8
+ #
9
+ # The existing construction-bank loader (adapters/corpus/construction-banks.mjs)
10
+ # reads only [[relation]] and [[construction]] tables, so it ignores this file's
11
+ # [[structure]] rows; the digest layer reads them through its own path.
12
+ #
13
+ # Each row is one (family, form) pairing:
14
+ # family one of the digest families (isa | location | partOf | capableOf |
15
+ # usedFor) — the closed set stage 1 groups predicates under. Each is
16
+ # verb-coherent: every predicate in it shares this frame's verb.
17
+ # form single — one fact of the family
18
+ # several — two or more facts of the family, merged into one clause
19
+ # chained — one isa fact plus its rendered ancestry chain
20
+ # template the skeleton. Placeholders the renderer fills (words.mjs supplies
21
+ # the article, plural and casing):
22
+ # {TERM} {TERM_CAP} the term, lower / sentence-leading
23
+ # {A_TERM} {A_TERM_CAP} "an aardvark" / "An aardvark"
24
+ # {TERMS} {TERMS_CAP} "aardvarks" / "Aardvarks"
25
+ # {PRONOUN} {PRONOUN_CAP} "it" / "It" (after first mention)
26
+ # {OBJECT} the first object, raw
27
+ # {A_OBJECT} "a mammal" (article chosen per word)
28
+ # {OBJECTS_A} "a mammal, a burrowing animal, and …"
29
+ # {OBJECTS_PLURAL} "mammals, burrowing animals, and …"
30
+ # {OBJECTS_RAW} "dig, and swim" (no article)
31
+ # {CHAIN} "a mammal, and so an animal"
32
+ # Any placeholder with no value for this call renders empty.
33
+
34
+ # ---- isa: the definition frame (copula) -----------------------------------
35
+
36
+ [[structure]]
37
+ family = "isa"
38
+ form = "single"
39
+ template = "{A_TERM_CAP} is {A_OBJECT}."
40
+
41
+ [[structure]]
42
+ family = "isa"
43
+ form = "several"
44
+ template = "{TERMS_CAP} are {OBJECTS_A}."
45
+
46
+ [[structure]]
47
+ family = "isa"
48
+ form = "chained"
49
+ template = "{A_TERM_CAP} is {CHAIN}."
50
+
51
+ # ---- location: where it is found (atLocation / locatedNear) ----------------
52
+
53
+ [[structure]]
54
+ family = "location"
55
+ form = "single"
56
+ template = "{PRONOUN_CAP} is found in {OBJECT}."
57
+
58
+ [[structure]]
59
+ family = "location"
60
+ form = "several"
61
+ template = "{PRONOUN_CAP} is found in {OBJECTS_RAW}."
62
+
63
+ # ---- partOf: what it belongs to -------------------------------------------
64
+
65
+ [[structure]]
66
+ family = "partOf"
67
+ form = "single"
68
+ template = "{PRONOUN_CAP} is part of {A_OBJECT}."
69
+
70
+ [[structure]]
71
+ family = "partOf"
72
+ form = "several"
73
+ template = "{PRONOUN_CAP} is part of {OBJECTS_A}."
74
+
75
+ # ---- capableOf: what it can do --------------------------------------------
76
+
77
+ [[structure]]
78
+ family = "capableOf"
79
+ form = "single"
80
+ template = "{PRONOUN_CAP} can {OBJECT}."
81
+
82
+ [[structure]]
83
+ family = "capableOf"
84
+ form = "several"
85
+ template = "{PRONOUN_CAP} can {OBJECTS_RAW}."
86
+
87
+ # ---- usedFor: what it is used for -----------------------------------------
88
+
89
+ [[structure]]
90
+ family = "usedFor"
91
+ form = "single"
92
+ template = "{PRONOUN_CAP} is used for {OBJECT}."
93
+
94
+ [[structure]]
95
+ family = "usedFor"
96
+ form = "several"
97
+ template = "{PRONOUN_CAP} is used for {OBJECTS_RAW}."
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@polycode-projects/the-mechanical-code-talker",
3
- "version": "3.0.0",
3
+ "version": "3.0.2",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "The Mechanical Code Talker (tmct) — a tolerant, offline, $0 chat surface that guides you toward precision queries about a software repository. ELIZA/PARRY-style but domain-obsessed with code. No model calls; indexes a repo on request (tmct index) or reads any producer's graph.",
@@ -130,8 +130,14 @@
130
130
  "example:polyglot": "node --disable-warning=ExperimentalWarning bin/tmct.mjs chat --repo examples/polyglot --ephemeral",
131
131
  "chatbench:run": "node chatbench/run.mjs",
132
132
  "chatbench:judge": "node chatbench/judge.mjs",
133
+ "chatbench:judge:cached": "node chatbench/judge.mjs --cache chatbench/verdict-cache.json",
133
134
  "agentbench:run": "node agentbench/run.mjs",
134
135
  "infbench": "node infbench/generate-cases.mjs && node infbench/run.mjs",
136
+ "idxbench:run": "node idxbench/run.mjs",
137
+ "researchbench:run": "node researchbench/run.mjs",
138
+ "synthbench:code": "node synthbench/code/run.mjs",
139
+ "ingestbench:run": "node ingestbench/run.mjs",
140
+ "ingestbench:judge": "node ingestbench/judge.mjs",
135
141
  "corpus:matrix": "node scripts/corpus-matrix.mjs",
136
142
  "corpus:matrix:gaps": "node scripts/corpus-matrix.mjs --gaps",
137
143
  "template:coverage": "node scripts/template-coverage.mjs",
@@ -0,0 +1,62 @@
1
+ // corpus/digest-bank.mjs — the filesystem side of the digest layer's stage-5
2
+ // wiring: read the committed sentence-structure bank and build the pre-parsed
3
+ // table the pure layer (src/domain/digest/) expects, then run one term end to
4
+ // end from a set of fact rows. The pure store scan and the pure pipeline live
5
+ // in src/domain/digest; only the TOML read lives here, so every node surface
6
+ // (chat, `tmct digest`, the page generators) shares one seam.
7
+ //
8
+ // A browser bundle stubs this module out (the ask/chat dock never digests
9
+ // in-page — the pages digest client-side from a table embedded at build time),
10
+ // so every consumer treats a null article as "fall back to the flat fact list".
11
+
12
+ import { readFileSync } from "node:fs";
13
+ import { fileURLToPath } from "node:url";
14
+ import { join, dirname } from "node:path";
15
+ import { parse as parseToml } from "smol-toml";
16
+ import { buildStructureTable, digestTerm } from "../../domain/digest/index.mjs";
17
+ import { digestStoreStats, chainsForObjects, isaObjectsOf } from "../../domain/digest/store-stats.mjs";
18
+
19
+ const HERE = dirname(fileURLToPath(import.meta.url));
20
+ const BANK_FILE = join(HERE, "..", "..", "..", "data", "templates", "constructions", "digest-sentence-structures.toml");
21
+
22
+ /** The raw [[structure]] rows parsed from the committed bank, once. A missing or
23
+ * unparseable bank yields an empty list — the digest degrades to the caller's
24
+ * flat fallback rather than throwing, the same posture the construction-bank
25
+ * loader takes. The raw rows (not the built table) are cached so a surface that
26
+ * needs to embed them for a browser render gets them without re-reading. */
27
+ let cachedStructures = null;
28
+ export function readDigestStructures() {
29
+ if (cachedStructures) return cachedStructures;
30
+ try {
31
+ const parsed = parseToml(readFileSync(BANK_FILE, "utf8"));
32
+ cachedStructures = Array.isArray(parsed.structure) ? parsed.structure : [];
33
+ } catch {
34
+ cachedStructures = [];
35
+ }
36
+ return cachedStructures;
37
+ }
38
+
39
+ let cachedTable = null;
40
+ /** The built structure table, once. Empty when the bank is unavailable. */
41
+ export function loadDigestStructureTable() {
42
+ if (cachedTable) return cachedTable;
43
+ cachedTable = buildStructureTable(readDigestStructures());
44
+ return cachedTable;
45
+ }
46
+
47
+ /**
48
+ * Digest one term end to end from fact rows. `termRows` are the rows whose
49
+ * subject is the term; `allRows` is the whole store the statistics scan over
50
+ * (pass the same array for both when the caller has already narrowed to one
51
+ * term). Returns the term-article shape, or null when the structure bank is
52
+ * unavailable (the browser-stub case) so the surface falls back to its flat
53
+ * rendering. `opts.budget` picks the per-surface fact budget.
54
+ */
55
+ export function digestTermFromRows(term, termRows, allRows, opts = {}) {
56
+ const table = loadDigestStructureTable();
57
+ if (!table || table.size === 0) return null;
58
+ const rows = termRows || [];
59
+ const store = digestStoreStats(allRows || rows);
60
+ const chains = chainsForObjects(store.subClassEdges, isaObjectsOf(rows));
61
+ return digestTerm(term, rows, store, table, { ...opts, chains });
62
+ }
@@ -102,6 +102,14 @@ export async function normalizeConfig(raw, { configDir } = {}) {
102
102
  const arr = Array.isArray(src.graph_files) ? src.graph_files : [src.graph_files];
103
103
  cfg.graphFiles = arr.map((p) => resolve(dir, String(p)));
104
104
  }
105
+ // [graph] read_only — a chat session against this repo READS the graph but
106
+ // writes nothing back into its .tmct/: no per-turn session upsert into
107
+ // graph.json, no transcript/sidecar logs, no memory droppings. A committed
108
+ // example fixture sets it so a plain `tmct chat --repo examples/<x>` (no
109
+ // --ephemeral) can never rewrite the hand-stamped graph. Sparse like the
110
+ // rest: absent when unset, so "unset" stays distinguishable from "false".
111
+ const graph = src.graph || {};
112
+ if (graph.read_only !== undefined) cfg.graph = { readOnly: graph.read_only };
105
113
  const corpus = src.corpus || {};
106
114
  if (corpus.tier !== undefined) cfg.corpus = { tier: corpus.tier };
107
115
  const seed = src.seed || {};
@@ -134,6 +142,11 @@ export async function normalizeConfig(raw, { configDir } = {}) {
134
142
  // resolveResearchConfig's job.
135
143
  if (src.research !== undefined) cfg.research = src.research;
136
144
 
145
+ // Discourse-record knob (src/domain/discourse.mjs): sparse PASS-THROUGH,
146
+ // same discipline — the raw `[discourse]` table (max_referents) rides
147
+ // through; default-filling is resolveDiscourseConfig's job.
148
+ if (src.discourse !== undefined) cfg.discourse = src.discourse;
149
+
137
150
  const idx = src.index || {};
138
151
  const index = {};
139
152
  if (idx.languages !== undefined) index.languages = idx.languages;
@@ -1841,18 +1841,26 @@ function evalRecentCommits(graph) {
1841
1841
  const COMMIT_FILTER_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
1842
1842
  /** Resolves the pivot (a literal ISO date, or a named commit whose own date
1843
1843
  * becomes the pivot), then filters every Commit's date against it. An
1844
- * unresolvable pivot declines honestly (pivotResolved:false). */
1844
+ * unresolvable pivot declines honestly (pivotResolved:false).
1845
+ *
1846
+ * A resolved answer also carries `referents` — the typed discourse referents
1847
+ * this answer establishes (the result set, and the pivot commit the question
1848
+ * was ABOUT, which the focus rules deliberately refuse to hold). Emitted
1849
+ * here, beside the fully typed result, before render() flattens it all to a
1850
+ * sentence; the session layer is what registers them into its record. */
1845
1851
  function evalCommitFilter(graph, ast) {
1846
1852
  const { op, pivotRaw } = ast;
1847
1853
  const dateOf = (c) => String((c.attributes || []).find((a) => a.key === "date")?.value || "").slice(0, 10);
1848
1854
  let pivotDate = null;
1849
1855
  let pivotId = null;
1856
+ let pivotLabel = null;
1850
1857
  if (COMMIT_FILTER_DATE_RE.test(pivotRaw)) {
1851
1858
  pivotDate = pivotRaw;
1852
1859
  } else {
1853
1860
  const { match, ambiguous } = resolveObject(graph, pivotRaw, { expectedClass: "Commit" });
1854
1861
  if (match && !ambiguous && match.class === "Commit" && dateOf(match)) {
1855
1862
  pivotId = match.id;
1863
+ pivotLabel = match.label;
1856
1864
  pivotDate = dateOf(match);
1857
1865
  }
1858
1866
  }
@@ -1867,7 +1875,24 @@ function evalCommitFilter(graph, ast) {
1867
1875
  return d === pivotDate; // "on"
1868
1876
  })
1869
1877
  .sort((a, b) => dateOf(b).localeCompare(dateOf(a)));
1870
- return { compositeKind: "commitFilter", op, pivotRaw, pivotDate, pivotResolved: true, matches };
1878
+ const referents = [];
1879
+ if (matches.length) {
1880
+ referents.push({
1881
+ kind: "set", class: "Commit",
1882
+ label: `${matches.length} commit${matches.length === 1 ? "" : "s"} ${op} ${pivotRaw}`,
1883
+ ids: matches.map((c) => c.id),
1884
+ attrs: { count: matches.length, op, ...(pivotId ? { pivot: pivotId } : {}) },
1885
+ lane: "commitFilter",
1886
+ });
1887
+ }
1888
+ if (pivotId) {
1889
+ referents.push({
1890
+ kind: "event", class: "Commit", label: pivotLabel,
1891
+ ids: [pivotId], attrs: { date: pivotDate },
1892
+ lane: "commitFilter",
1893
+ });
1894
+ }
1895
+ return { compositeKind: "commitFilter", op, pivotRaw, pivotDate, pivotResolved: true, matches, referents };
1871
1896
  }
1872
1897
 
1873
1898
  /** Temporal over a nested set: the commits that touched any member of the
@@ -2371,6 +2396,14 @@ function joinedQueryForm(term) {
2371
2396
  .replace(/[\s\-_]+/g, "");
2372
2397
  }
2373
2398
 
2399
+ /** Ceiling of tier 3's weakest scoring band (the term-component-overlap
2400
+ * fraction): a candidate scores exactly this when EVERY component of the
2401
+ * term appears in its label, strictly less on a partial overlap. Every
2402
+ * NAME-evidence band (stem/containment/joined/derivational) scores
2403
+ * hundreds and up, so score < this ceiling means the candidate shares only
2404
+ * some generic path components with the term. */
2405
+ const COMPONENT_OVERLAP_MAX = 10;
2406
+
2374
2407
  /** Resolve a free-text object/subject term against the graph's individuals, in
2375
2408
  * priority order: a sha-shaped term first resolves against Commit
2376
2409
  * individuals by unique id/label prefix, then (1) exact label/id match, (2)
@@ -2549,7 +2582,7 @@ function resolveObjectCore(graph, term, { expectedClass = null } = {}) {
2549
2582
  // stem hit from the tier above, nor a fuller-fraction overlap on another
2550
2583
  // candidate. termComps.length > 0 is guaranteed here (overlap > 0 requires
2551
2584
  // at least one termComps entry to have matched).
2552
- scored.push({ ind: m, score: (overlap / termComps.length) * 10 });
2585
+ scored.push({ ind: m, score: (overlap / termComps.length) * COMPONENT_OVERLAP_MAX });
2553
2586
  }
2554
2587
  }
2555
2588
  }
@@ -2557,9 +2590,20 @@ function resolveObjectCore(graph, term, { expectedClass = null } = {}) {
2557
2590
  if (scored.length) {
2558
2591
  const [best, ...rest] = scored;
2559
2592
  const tied = rest.filter((x) => x.score === best.score);
2593
+ // A PARTIAL component-overlap runner-up (score < COMPONENT_OVERLAP_MAX:
2594
+ // some but not all of the term's own tokens, e.g. graph-merge.mjs
2595
+ // sharing only "graph"/"mjs" with "graph-build.mjs") is not another
2596
+ // reading of the term, and disclosing it in the "(answering for X — N
2597
+ // other matches)" note reads as leakage. A candidate that matched EVERY
2598
+ // term token (score === COMPONENT_OVERLAP_MAX) or matched by name
2599
+ // evidence (score above the band) stays disclosed. A partial-overlap
2600
+ // winner keeps its partial peers — they are all the evidence there is.
2601
+ const disclosable = best.score >= COMPONENT_OVERLAP_MAX
2602
+ ? rest.filter((x) => x.score >= COMPONENT_OVERLAP_MAX)
2603
+ : rest;
2560
2604
  return {
2561
2605
  match: best.ind,
2562
- candidates: rest.slice(0, 4).map((x) => x.ind),
2606
+ candidates: disclosable.slice(0, 4).map((x) => x.ind),
2563
2607
  tier: 3,
2564
2608
  ambiguous: tied.length > 0,
2565
2609
  };
@@ -4384,6 +4428,11 @@ export function ask(graph, query, { contextId = null, nlp = undefined, prev = nu
4384
4428
  // edit-distance, announced in the content as "assuming you meant …");
4385
4429
  // null for every literal-identifier tier.
4386
4430
  matchedVia: result.matchedVia || null,
4431
+ // The typed discourse referents this answer established (see
4432
+ // evalCommitFilter) — additive, present only when a lane emitted them,
4433
+ // so a session layer can register them without re-deriving the answer's
4434
+ // typed content from its rendered sentence.
4435
+ ...(Array.isArray(result.referents) && result.referents.length ? { discourse: result.referents } : {}),
4387
4436
  ...(rendered.ambiguous ? { candidates: rendered.candidates, candidateParses: rendered.candidateParses } : {}),
4388
4437
  },
4389
4438
  };
@@ -137,6 +137,17 @@ export const CLI_VERBS = [
137
137
  { flag: "[--config <path>]", prose: ["--term <word> override it); --output defaults to", "ledger.html in the cwd; --limit caps the embedded fact", "rows; --term resolves via the same normalization chat uses."] },
138
138
  ],
139
139
  },
140
+ {
141
+ mode: "digest",
142
+ errorLabel: "digest",
143
+ usage: "tmct digest <term>",
144
+ prose: ["a readable digest of what the graph knows about one term:"],
145
+ flags: [
146
+ { flag: "[--repo <abs>]", prose: ["a bounded narrative first (selected, sense-filtered,"] },
147
+ { flag: "[--graph <path>]", prose: ["deduped), then its sources and the stored-fact count."] },
148
+ { flag: "[--config <path>]", prose: ["The vocabulary-side sibling of `cli digest`'s code map."] },
149
+ ],
150
+ },
140
151
  {
141
152
  mode: "serve",
142
153
  errorLabel: "serve",