@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 +11 -0
- package/bin/tmct.mjs +69 -0
- package/data/templates/constructions/digest-sentence-structures.toml +97 -0
- package/package.json +7 -1
- package/src/adapters/corpus/digest-bank.mjs +62 -0
- package/src/adapters/toml-config.mjs +5 -0
- package/src/domain/ask.mjs +53 -4
- package/src/domain/cli-verbs.mjs +11 -0
- package/src/domain/digest/article.mjs +106 -0
- package/src/domain/digest/compose.mjs +103 -0
- package/src/domain/digest/config.json +13 -0
- package/src/domain/digest/index.mjs +38 -0
- package/src/domain/digest/select.mjs +233 -0
- package/src/domain/digest/store-stats.mjs +77 -0
- package/src/domain/digest/structures.mjs +100 -0
- package/src/domain/digest/words.mjs +40 -0
- package/src/domain/discourse.mjs +0 -0
- package/src/services/chat-session.mjs +6 -1
- package/src/services/chat.mjs +191 -12
- package/src/services/ledger-viz.mjs +14 -3
- package/src/surfaces/web/memory-ask-browser.bundle.js +98 -93
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.
|
|
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;
|
package/src/domain/ask.mjs
CHANGED
|
@@ -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
|
-
|
|
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) *
|
|
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:
|
|
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
|
};
|
package/src/domain/cli-verbs.mjs
CHANGED
|
@@ -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
|
+
}
|