@polycode-projects/the-mechanical-code-talker 3.0.1 → 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
@@ -941,6 +941,14 @@ async function main() {
941
941
  `code persona: indexed the repo — wrote ${stats.graphFile} (${stats.modules} modules, ${stats.symbols} symbols; ${kib} KiB)\n`
942
942
  + (perLang ? `${perLang}\n` : "no supported source found under the repo\n"),
943
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
+ }
944
952
  if (stats.failures?.length) {
945
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`);
946
954
  }
@@ -1040,6 +1048,14 @@ async function main() {
1040
1048
  `tmct index — wrote ${stats.graphFile} (${stats.modules} modules, ${stats.symbols} symbols; ${kib} KiB)\n`
1041
1049
  + (perLang ? `${perLang}\n` : "no supported source found under the repo\n"),
1042
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
+ }
1043
1059
  if (stats.failures?.length) {
1044
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`);
1045
1061
  }
@@ -1312,6 +1328,59 @@ async function main() {
1312
1328
  return;
1313
1329
  }
1314
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
+
1315
1384
  if (mode === "serve") {
1316
1385
  // `tmct serve` — the Phase-A capability-router interface: an Anthropic
1317
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.1",
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
+ }
@@ -142,6 +142,11 @@ export async function normalizeConfig(raw, { configDir } = {}) {
142
142
  // resolveResearchConfig's job.
143
143
  if (src.research !== undefined) cfg.research = src.research;
144
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
+
145
150
  const idx = src.index || {};
146
151
  const index = {};
147
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",
@@ -0,0 +1,106 @@
1
+ // digest/article.mjs — stage 4 of the digest layer: assemble composed
2
+ // paragraphs into a whole article, and keep the full detail reachable behind an
3
+ // explicit escape. Pure and deterministic; returns a DATA shape, never rendered
4
+ // terminal or HTML — the surface wiring (stage 5) decides how to print the
5
+ // narrative and how the "show the facts" / "show the chains" escapes fire.
6
+ //
7
+ // The narrative leads; the detail is ranked, never destroyed. `detail.facts`
8
+ // carries every stored fact for the term (the ones the digest spoke AND the
9
+ // ones the selector cut), so the escape reaches the whole list, and every
10
+ // paragraph traces back to the rows in `provenanceRows`.
11
+
12
+ /** Minimal, render-ready view of a fact row for the detail escape. */
13
+ const factView = (row) => ({
14
+ subject: row.subject,
15
+ predicate: row.predicate,
16
+ object: row.object,
17
+ sourceTypes: row.sourceTypes || [],
18
+ provenance: row.provenance || "",
19
+ });
20
+
21
+ /** Distinct source descriptors behind a set of rows, in first-seen order, so a
22
+ * digest names where it came from the way the specimen's own replies do. */
23
+ function sourcesFrom(rows) {
24
+ const seen = new Set();
25
+ const out = [];
26
+ for (const row of rows || []) {
27
+ const key = row.provenance || (row.sourceTypes || []).join(",");
28
+ if (!key || seen.has(key)) continue;
29
+ seen.add(key);
30
+ out.push({ provenance: row.provenance || "", sourceTypes: row.sourceTypes || [] });
31
+ }
32
+ return out;
33
+ }
34
+
35
+ const ESCAPES = Object.freeze({ facts: "show the facts", chains: "show the chains" });
36
+
37
+ /** Every stored fact the selector saw for the term — spoken and cut alike —
38
+ * as detail views, plus the count and any ancestry chains. */
39
+ function detailFrom(selection, chains) {
40
+ const rows = [
41
+ ...(selection?.selected || []).map((s) => s.row),
42
+ ...(selection?.cut || []).map((c) => c.row),
43
+ ].filter(Boolean);
44
+ const chainList = Object.entries(chains || {})
45
+ .filter(([, chain]) => Array.isArray(chain) && chain.length > 1)
46
+ .map(([object, chain]) => ({ object, chain }));
47
+ return { escapes: ESCAPES, facts: rows.map(factView), factCount: rows.length, chains: chainList };
48
+ }
49
+
50
+ /**
51
+ * A term article: definition → description paragraphs, the sources it drew on,
52
+ * and the full fact list behind the "show the facts" escape.
53
+ * `composed` is composeTermDigest()'s result; `selection` is selectFacts()'s.
54
+ */
55
+ export function termArticle(selection, composed, opts = {}) {
56
+ const term = composed?.term || selection?.term || "";
57
+ return {
58
+ kind: "term-article",
59
+ term,
60
+ headline: term,
61
+ paragraphs: (composed?.paragraphs || []).map((p) => p.text).filter(Boolean),
62
+ body: composed?.paragraphs || [],
63
+ sources: sourcesFrom(composed?.provenanceRows || []),
64
+ detail: detailFrom(selection, opts.chains),
65
+ provenanceRows: composed?.provenanceRows || [],
66
+ };
67
+ }
68
+
69
+ /**
70
+ * A research-run article: what the run set out to learn, what it grounded, and
71
+ * what it skipped and why. `run` supplies { term, grounded: [topic], skipped:
72
+ * [{ topic, reason }], rows }. The composed narrative (if any) leads; the run
73
+ * facts stay reachable behind the same escape.
74
+ */
75
+ export function researchRunArticle(run, composed = null, opts = {}) {
76
+ const term = run?.term || composed?.term || "";
77
+ const rows = run?.rows || composed?.provenanceRows || [];
78
+ return {
79
+ kind: "research-run",
80
+ term,
81
+ headline: term,
82
+ paragraphs: (composed?.paragraphs || []).map((p) => p.text).filter(Boolean),
83
+ grounded: (run?.grounded || []).map(String),
84
+ skipped: (run?.skipped || []).map((s) => ({ topic: String(s?.topic || ""), reason: String(s?.reason || "") })),
85
+ sources: sourcesFrom(rows),
86
+ detail: { escapes: ESCAPES, facts: rows.map(factView), factCount: rows.length, chains: (opts.chains ? detailFrom({ selected: [], cut: [] }, opts.chains).chains : []) },
87
+ provenanceRows: rows,
88
+ };
89
+ }
90
+
91
+ /**
92
+ * A session digest: what this conversation taught the store. `session` supplies
93
+ * { rows } (the fact rows the session asserted). Groups nothing away — the rows
94
+ * ARE the detail, and any composed narrative leads.
95
+ */
96
+ export function sessionDigestArticle(session, composed = null) {
97
+ const rows = session?.rows || [];
98
+ return {
99
+ kind: "session-digest",
100
+ paragraphs: (composed?.paragraphs || []).map((p) => p.text).filter(Boolean),
101
+ learnedCount: rows.length,
102
+ sources: sourcesFrom(rows),
103
+ detail: { escapes: ESCAPES, facts: rows.map(factView), factCount: rows.length, chains: [] },
104
+ provenanceRows: rows,
105
+ };
106
+ }
@@ -0,0 +1,103 @@
1
+ // digest/compose.mjs — stage 3 of the digest layer: order the selected facts
2
+ // into sentences and group them into paragraphs. Pure and deterministic; the
3
+ // prose quality lives here — the lead sentence names the term, every sentence
4
+ // after it refers back with a pronoun, and a paragraph never runs past its
5
+ // sentence cap. Each sentence keeps the fact rows behind it, so provenance
6
+ // survives composition into the article stage.
7
+
8
+ import { renderStructure } from "./structures.mjs";
9
+ import { FAMILY_PRIORITY } from "./select.mjs";
10
+ import { articleFor, capitalizeFirst } from "./words.mjs";
11
+
12
+ const DESCRIPTION_FAMILIES = FAMILY_PRIORITY.filter((f) => f !== "isa" && f !== "other");
13
+
14
+ /** Group the selector's `selected` items by family, preserving each item's
15
+ * ranked order, and return `{ family -> rows[] }` over the fact rows. */
16
+ function rowsByFamily(selected) {
17
+ const by = new Map();
18
+ for (const item of selected || []) {
19
+ if (!by.has(item.family)) by.set(item.family, []);
20
+ by.get(item.family).push(item.row);
21
+ }
22
+ return by;
23
+ }
24
+
25
+ /** The first sentence must introduce the term. When no isa fact led (so the
26
+ * first sentence opens with a bare "It"), rewrite that pronoun into the term's
27
+ * own noun phrase so the pronoun has an antecedent. */
28
+ function ensureFirstNamesTerm(sentences, term) {
29
+ if (!sentences.length) return;
30
+ const first = sentences[0];
31
+ if (/\bnames-term\b/.test(first.role)) return;
32
+ const phrase = capitalizeFirst(`${articleFor(term)} ${term}`);
33
+ first.text = first.text.replace(/^It\b/, phrase);
34
+ first.role = "names-term";
35
+ }
36
+
37
+ /** Split a flat sentence list into paragraphs of at most `cap` sentences,
38
+ * respecting the caller's paragraph boundaries (a sentence's `paragraph` tag). */
39
+ function paragraphsFrom(sentences, cap) {
40
+ const groups = [];
41
+ let current = null;
42
+ let currentTag = null;
43
+ for (const s of sentences) {
44
+ if (!current || s.paragraph !== currentTag || current.length >= cap) {
45
+ current = [];
46
+ currentTag = s.paragraph;
47
+ groups.push(current);
48
+ }
49
+ current.push(s);
50
+ }
51
+ return groups.map((sentences) => ({
52
+ sentences,
53
+ text: sentences.map((s) => s.text).join(" "),
54
+ }));
55
+ }
56
+
57
+ /**
58
+ * Compose the term digest from a selector result and a structure table.
59
+ *
60
+ * opts:
61
+ * - chains: { object -> [object, parent, …] } ancestry chains, so a lone isa
62
+ * fact can render as a chain ("a mammal, and so an animal") when one exists.
63
+ * - maxSentencesPerParagraph: the sentence cap (default 3).
64
+ *
65
+ * Returns { term, sentences, paragraphs, provenanceRows }. Every sentence in
66
+ * `sentences` carries { text, rows, family, role, paragraph }; `paragraphs`
67
+ * joins them for rendering; `provenanceRows` is every fact row the digest used,
68
+ * deduped by id.
69
+ */
70
+ export function composeTermDigest(selection, table, opts = {}) {
71
+ const term = selection?.term || "";
72
+ const cap = Number.isInteger(opts.maxSentencesPerParagraph) ? opts.maxSentencesPerParagraph : 3;
73
+ const chains = opts.chains || {};
74
+ const byFamily = rowsByFamily(selection?.selected || []);
75
+ const sentences = [];
76
+
77
+ const isaRows = byFamily.get("isa") || [];
78
+ if (isaRows.length) {
79
+ const chain = isaRows.length === 1 ? chains[isaRows[0].object] : null;
80
+ const form = chain && chain.length > 1 ? "chained" : (isaRows.length > 1 ? "several" : "single");
81
+ const s = renderStructure(table, "isa", isaRows, { term, form, chain });
82
+ if (s) sentences.push({ ...s, role: "names-term", paragraph: "definition" });
83
+ }
84
+
85
+ for (const family of DESCRIPTION_FAMILIES) {
86
+ const rows = byFamily.get(family);
87
+ if (!rows || !rows.length) continue;
88
+ const s = renderStructure(table, family, rows, { term });
89
+ if (s) sentences.push({ ...s, role: "describes", paragraph: "description" });
90
+ }
91
+
92
+ ensureFirstNamesTerm(sentences, term);
93
+
94
+ const provenanceById = new Map();
95
+ for (const s of sentences) for (const r of s.rows) if (!provenanceById.has(r.id)) provenanceById.set(r.id, r);
96
+
97
+ return {
98
+ term,
99
+ sentences,
100
+ paragraphs: paragraphsFrom(sentences, cap),
101
+ provenanceRows: [...provenanceById.values()],
102
+ };
103
+ }
@@ -0,0 +1,13 @@
1
+ {
2
+ "uninformativeClassMaxShare": 0.35,
3
+ "uninformativeClassMinSubjects": 12,
4
+ "entailedDepthPenalty": 0.2,
5
+ "minoritySensePenalty": 0.5,
6
+ "minScore": 0.05,
7
+ "budget": {
8
+ "chatReply": 5,
9
+ "researchPanel": 10,
10
+ "cliDigest": 12,
11
+ "ledgerTerm": 8
12
+ }
13
+ }