@polycode-projects/the-mechanical-code-talker 1.4.0 → 1.5.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.
@@ -0,0 +1,154 @@
1
+ // completions/rank.mjs — Stage 4 ("mechanical summarization") of PLAN_COMPLETIONS.md's
2
+ // six-stage mechanical-text-generation pipeline. Stage-2-of-staging scope per the plan's own
3
+ // §4 table: extractive SENTENCE ranking within a group.mjs group — no cross-group inference
4
+ // (Stage 3, §1.3 — separately scoped), pruning (Stage 5), or voice pass (Stage 6) happens
5
+ // here.
6
+ //
7
+ // "Extractive sentence selection over the grouped-and-inferred material, not abstractive
8
+ // rewriting... query-focused multi-document summarization (feature-fusion sentence
9
+ // selection, graph-ranking approaches in the LexRank/TextRank family, clustering-cum-ranking
10
+ // as in CoRank)" — PLAN_COMPLETIONS.md §1.4/§4. The graph-ranking machinery this stage needs
11
+ // already exists and ships: memory/blocks.mjs's rankBlocks() (PageRank, d=0.85, 20
12
+ // iterations, over the shared-token block-similarity graph) and degreeOf() (hub dampening),
13
+ // both generic over any `{ id: tokens[] }` map — NOT block-specific despite the module name.
14
+ // This file reuses rankBlocks()/degreeOf() VERBATIM at sentence granularity (no PageRank
15
+ // reimplementation), combined with the same idf = log(1 + N/(1+df)) formula group.mjs already
16
+ // replicates for group-scoped (not whole-corpus-scoped) weighting, and the same
17
+ // idfSum * (1+rank) / sqrt(1+degree) combination retrieveBlocks() uses to fuse relevance,
18
+ // centrality, and hub-dampening into one score.
19
+ //
20
+ // New in this file: splitSentences() (no sentence-splitter existed anywhere in this repo —
21
+ // grepped for "sentence" across src/ and found none; a simple regex splitter is intentional
22
+ // here per the dispatch's own instruction not to pull in an NLP dependency for this) and the
23
+ // sentence-level id/token/scoring wiring that adapts rankBlocks/degreeOf from block to
24
+ // sentence granularity.
25
+ //
26
+ // Determinism: no randomness anywhere. Same group in (same members, same text, same order)
27
+ // -> same ranked sentence list out (stable score-descending sort with a deterministic
28
+ // sourceBlockId/sentence-text tiebreak) — see test/completions-stage2.test.mjs's double-run
29
+ // diff, the exact discipline test/completions-stage0.test.mjs established for search+group.
30
+
31
+ import { degreeOf, rankBlocks, tokenizeBlock, OVERLAP_MIN } from "../memory/blocks.mjs";
32
+ import { STOPWORDS } from "../prose.mjs";
33
+
34
+ // Same content-token filter group.mjs applies to its own adjacency/labeling (not exported
35
+ // from group.mjs, so replicated here rather than reached across files — group.mjs's own
36
+ // header explains why raw tokenizeBlock output is unsuitable for unweighted overlap: it
37
+ // re-admits stopwords/filler that would collapse unrelated sentences into false edges).
38
+ const isContentToken = (t) => /^[a-z0-9]+$/.test(t) && !STOPWORDS.has(t);
39
+
40
+ /** tokenizeBlock(text), narrowed to real content tokens — see isContentToken above. */
41
+ function contentTokens(text) {
42
+ return tokenizeBlock(text).filter(isContentToken);
43
+ }
44
+
45
+ // Sentence boundary: a run of [.!?] followed by whitespace and an uppercase letter or digit —
46
+ // deliberately simple (no abbreviation dictionary, no NLP dependency, per the dispatch's own
47
+ // instruction). Applied per-line (a block's own "Q: ...\nA: ..." shape already gives clean
48
+ // boundaries at the newline; this regex further splits any line that itself holds more than
49
+ // one sentence).
50
+ const SENTENCE_SPLIT_RE = /(?<=[.!?])\s+(?=[A-Z0-9])/;
51
+
52
+ /**
53
+ * Split raw block text into trimmed, non-empty sentences (order-preserving, no dedup —
54
+ * dedup, if ever needed, is the caller's call). Splits on blank lines first (a block's own
55
+ * natural line structure), then on sentence-ending punctuation within each line.
56
+ *
57
+ * @param {string} text
58
+ * @returns {string[]}
59
+ */
60
+ export function splitSentences(text) {
61
+ const out = [];
62
+ for (const rawLine of String(text || "").split("\n")) {
63
+ const line = rawLine.trim();
64
+ if (!line) continue;
65
+ for (const part of line.split(SENTENCE_SPLIT_RE)) {
66
+ const s = part.trim();
67
+ if (s) out.push(s);
68
+ }
69
+ }
70
+ return out;
71
+ }
72
+
73
+ /**
74
+ * Stage 4 — extractive sentence ranking within one group.mjs group. Splits every member's
75
+ * text into sentences, builds a sentence-level shared-token-overlap similarity graph (the
76
+ * exact adjacency buildNeighbours/rankBlocks/degreeOf already establish, just re-keyed to
77
+ * sentence ids instead of block ids), runs the same PageRank (rankBlocks) and hub-dampening
78
+ * (degreeOf) over it, and combines with group-scoped IDF the same way retrieveBlocks() fuses
79
+ * relevance × centrality × hub-dampening — self-weighted (LexRank-style: a sentence's own
80
+ * rare/informative tokens) unless `opts.query` is supplied, in which case only tokens
81
+ * overlapping the query are IDF-summed (query-focused summarization, PLAN_COMPLETIONS.md's
82
+ * own literature framing) — a sentence with zero query overlap honestly scores 0 rather than
83
+ * silently falling back to self-weighting, the same "never a guessed match" discipline
84
+ * retrieveBlocks() applies when idfSum <= 0.
85
+ *
86
+ * @param {{members: Array<{id:string, text:string}>}} group a group.mjs groupHits() entry
87
+ * (or any object shaped `{ members: [{id, text}, ...] }`)
88
+ * @param {object} [opts]
89
+ * @param {number} [opts.overlapMin=OVERLAP_MIN] shared content-token threshold for a
90
+ * sentence-similarity edge — defaults to the same value group.mjs/blocks.mjs use, so
91
+ * grouping, block-ranking, and sentence-ranking all agree on what "related" means unless
92
+ * the caller deliberately overrides it.
93
+ * @param {string|null} [opts.query=null] optional query text to focus ranking on (see above)
94
+ * @returns {Array<{sentence:string, score:number, sourceBlockId:string}>} best-first;
95
+ * deterministic tiebreak (sourceBlockId, then sentence text) on equal score.
96
+ */
97
+ export function rankSentences(group, { overlapMin = OVERLAP_MIN, query = null } = {}) {
98
+ const members = Array.isArray(group?.members) ? group.members : [];
99
+ if (!members.length) return [];
100
+
101
+ // One entry per sentence: a stable id ("<blockId>#<index>") so PageRank/degree/IDF can be
102
+ // keyed exactly like rankBlocks/degreeOf already key blocks — deterministic since it's
103
+ // derived purely from member order (group.mjs's members are already id-sorted) and each
104
+ // block's own sentence order.
105
+ const sentences = [];
106
+ for (const m of members) {
107
+ const parts = splitSentences(m?.text || "");
108
+ parts.forEach((sentence, i) => {
109
+ sentences.push({ id: `${m.id}#${i}`, sentence, sourceBlockId: m.id });
110
+ });
111
+ }
112
+ if (!sentences.length) return [];
113
+
114
+ const tokensById = {};
115
+ for (const s of sentences) tokensById[s.id] = contentTokens(s.sentence);
116
+
117
+ // Reused verbatim — the exact PageRank/hub-dampening machinery rankBlocks()/degreeOf()
118
+ // already run for block-level ranking, generic over any id->tokens map.
119
+ const ranks = rankBlocks(tokensById, { overlapMin });
120
+ const degrees = degreeOf(tokensById, { overlapMin });
121
+
122
+ // IDF scoped to THIS group's sentence set (df/N here, not the whole corpus) — the same
123
+ // scoping discipline group.mjs applies to its own label tokens.
124
+ const ids = Object.keys(tokensById);
125
+ const N = ids.length;
126
+ const df = new Map();
127
+ for (const id of ids) {
128
+ for (const t of new Set(tokensById[id])) df.set(t, (df.get(t) || 0) + 1);
129
+ }
130
+ const idf = (t) => Math.log(1 + N / (1 + (df.get(t) || 0)));
131
+
132
+ const queryTokens = query ? new Set(contentTokens(query)) : null;
133
+
134
+ const scored = sentences.map((s) => {
135
+ const tokens = tokensById[s.id];
136
+ const idfTokens = queryTokens ? tokens.filter((t) => queryTokens.has(t)) : tokens;
137
+ let idfSum = 0;
138
+ for (const t of idfTokens) idfSum += idf(t);
139
+ const rank = ranks[s.id] ?? 0;
140
+ const degree = degrees[s.id] ?? 0;
141
+ // idfSum * (1 + rank) / sqrt(1 + degree) — retrieveBlocks()'s own combination formula
142
+ // (memory/blocks.mjs), reused at sentence granularity: IDF-weighted informativeness
143
+ // decides content, PageRank centrality breaks ties toward well-connected (consensus)
144
+ // sentences, and the degree divisor dampens a sentence that merely shares vocabulary
145
+ // with disproportionately many others from winning on inflated rank alone.
146
+ const score = (idfSum * (1 + rank)) / Math.sqrt(1 + degree);
147
+ return { sentence: s.sentence, score, sourceBlockId: s.sourceBlockId };
148
+ });
149
+
150
+ scored.sort((a, b) => b.score - a.score
151
+ || a.sourceBlockId.localeCompare(b.sourceBlockId)
152
+ || a.sentence.localeCompare(b.sentence));
153
+ return scored;
154
+ }
@@ -0,0 +1,85 @@
1
+ // completions/search.mjs — Stage 1 ("broad search") of PLAN_COMPLETIONS.md's six-stage
2
+ // mechanical-text-generation pipeline. This is Stage-0 scope ONLY per the plan's own §4
3
+ // staging table (Stage 1 + Stage 2, no inference/summarization/pruning/voice-pass yet — see
4
+ // group.mjs for Stage 2).
5
+ //
6
+ // Near-total reuse, as the plan's own prior research established: this is a thin composition
7
+ // of retrieveBlocks (src/memory/blocks.mjs's best-first block ranker) and the graph
8
+ // search()/ask() services (src/providers/graph-service.mjs, contracted by
9
+ // src/repository-interface.mjs's SERVICE_GROUPS.search) — a WIDER query shape ("broad
10
+ // search": more hits per source, both sources asked) rather than any new retrieval
11
+ // machinery. Nothing here re-implements ranking, tokenizing, or graph traversal.
12
+ //
13
+ // Granularity note (explicit, per the strategy-advisor's Stage-0 correction): retrieveBlocks
14
+ // only ever returns whole BLOCK text — one node per ~800-token document/transcript chunk, the
15
+ // unit memory/blocks.mjs's index actually stores. PLAN_COMPLETIONS.md's own prose loosely
16
+ // says "spans"; this module does NOT invent sub-block span segmentation. Every hit this
17
+ // module returns is whole-block (or a whole graph-search/ask result) granularity. Finer-grain
18
+ // spans, if the pipeline ever needs them, are a real future increment, out of scope here.
19
+ //
20
+ // Determinism: retrieveBlocks is already deterministic (stable sort with an id tiebreak);
21
+ // the graph service's search()/ask() are pure functions over a fixed graph. Given the same
22
+ // dir/query/graphService, broadSearch() always returns the same array in the same order.
23
+
24
+ import { retrieveBlocks } from "../memory/blocks.mjs";
25
+
26
+ const DEFAULT_BLOCK_K = 8; // "broad" > chat's narrow single-answer k (typically 3)
27
+ const DEFAULT_GRAPH_LIMIT = 8;
28
+
29
+ /**
30
+ * Stage 1 — broad search. Runs retrieveBlocks (memory/blocks.mjs) for text-block hits and,
31
+ * when a Repository-Interface graph service is supplied, its search() and ask() services for
32
+ * graph hits — merged into one flat, source-tagged hit list.
33
+ *
34
+ * @param {string} dir repo root (retrieveBlocks reads <dir>/.tmct/memory/blocks/)
35
+ * @param {string} query the broad prompt driving retrieval
36
+ * @param {object} [opts]
37
+ * @param {number} [opts.blockK=8] retrieveBlocks' k (how many blocks to pull)
38
+ * @param {object|null} [opts.graphService=null] an optional Repository-Interface service
39
+ * (e.g. createGraphService(graph) from src/providers/graph-service.mjs, or any provider
40
+ * satisfying SERVICE_GROUPS.search's `search`/`ask`). When supplied, its search() and
41
+ * ask() services are queried too. Omitted -> block-only search (still a valid, honest
42
+ * broad search; graph access is opt-in, not required — this module never constructs a
43
+ * graph service itself, it only calls one it's handed).
44
+ * @param {number} [opts.graphLimit=8] graph search()'s result limit
45
+ * @returns {Promise<Array<{source:"block"|"graph-search"|"graph-ask", id:string, text:string, score:number}>>}
46
+ * best-first within each source; blocks first, then graph-search, then graph-ask (stable,
47
+ * deterministic order — never shuffled/merged by score across sources, since block scores
48
+ * and graph relevance are not on a comparable scale; Stage 2's grouping doesn't need them
49
+ * to be).
50
+ */
51
+ export async function broadSearch(dir, query, {
52
+ blockK = DEFAULT_BLOCK_K, graphService = null, graphLimit = DEFAULT_GRAPH_LIMIT,
53
+ } = {}) {
54
+ const q = String(query || "").trim();
55
+ if (!q) return [];
56
+
57
+ const hits = [];
58
+
59
+ const blockHits = await retrieveBlocks(dir, q, blockK);
60
+ for (const b of blockHits) {
61
+ hits.push({ source: "block", id: b.id, text: b.text || "", score: typeof b.score === "number" ? b.score : 0 });
62
+ }
63
+
64
+ if (graphService) {
65
+ if (typeof graphService.search === "function") {
66
+ const res = graphService.search(q, { limit: graphLimit });
67
+ if (res?.ok) {
68
+ for (const r of res.value.results || []) {
69
+ if (!r) continue;
70
+ hits.push({ source: "graph-search", id: r.id, text: r.label || r.id, score: 0 });
71
+ }
72
+ }
73
+ }
74
+ if (typeof graphService.ask === "function") {
75
+ const res = graphService.ask(q);
76
+ if (res?.ok && res.value && res.value.content) {
77
+ // one honest hit per prompt — the ask() answer itself, id-tagged with the query so
78
+ // it never collides with a block/search id and stays traceable to what produced it.
79
+ hits.push({ source: "graph-ask", id: `ask:${q}`, text: String(res.value.content), score: 0 });
80
+ }
81
+ }
82
+ }
83
+
84
+ return hits;
85
+ }
@@ -147,8 +147,27 @@ export function toFacts(assertions, map, provenancePrefix = "corpus:conceptnet")
147
147
  * survivors are written in ONE batched appendFacts call, not a per-fact loop.
148
148
  * Returns { appended, skipped, total }. `provenancePrefix` is threaded through to
149
149
  * toFacts (default "corpus:conceptnet" → byte-identical seed) so a seon/tier-2
150
- * corpus can tag its facts "corpus:seon" / "corpus:tier2:<id>". */
151
- export async function seedMemory(dir, { limit, slicePath = SLICE_FILE, mapPath = MAP_FILE, prefer, provenancePrefix } = {}) {
150
+ * corpus can tag its facts "corpus:seon" / "corpus:tier2:<id>".
151
+ *
152
+ * `captureUnknownContext` (default false — every existing call stays
153
+ * byte-identical): when true, also runs corpus/unknown-ingest.mjs's
154
+ * `ingestUnknownFromAssertions` over this same assertions/map pair — the
155
+ * PLAN_AGENTS.md §4 "context-preserving ingestion for unknown words"
156
+ * mechanism — so a term that ONLY ever appears in a row `toFacts` silently
157
+ * drops (an `ace = "none"` relation like RelatedTo/HasContext) still lands
158
+ * in memory, tagged with the passage it was found in, instead of vanishing.
159
+ * `unknownContextLimit` bounds how many distinct unknown terms one call
160
+ * captures (default 500 — see that module's own doc comment for why an
161
+ * unbounded sweep over a wide slice would not be "bounded, not padding").
162
+ * The result's `unknown` key is present only when the flag is set. Loaded
163
+ * dynamically (not a static import) to avoid a load-time import cycle with
164
+ * unknown-ingest.mjs, which itself statically imports `termText` from here —
165
+ * the same "avoid the cycle" discipline extensions.mjs's
166
+ * seedActiveCorpusEntries already uses for this very module. */
167
+ export async function seedMemory(dir, {
168
+ limit, slicePath = SLICE_FILE, mapPath = MAP_FILE, prefer, provenancePrefix,
169
+ captureUnknownContext = false, unknownContextLimit,
170
+ } = {}) {
152
171
  const [assertions, map] = await Promise.all([loadSlice(slicePath), loadMap(mapPath)]);
153
172
  let facts = toFacts(assertions, map, provenancePrefix);
154
173
  if (Array.isArray(prefer) && prefer.length) {
@@ -186,5 +205,19 @@ export async function seedMemory(dir, { limit, slicePath = SLICE_FILE, mapPath =
186
205
  // for the 6 k-fact slice). appendFacts also skips any malformed row rather than
187
206
  // throwing, so its skipped count folds into the dedup skips here.
188
207
  const res = await appendFacts(dir, toWrite);
189
- return { appended: res.appended, skipped: skipped + res.skipped, total: facts.length };
208
+
209
+ let unknown;
210
+ if (captureUnknownContext) {
211
+ const { ingestUnknownFromAssertions } = await import("./unknown-ingest.mjs");
212
+ unknown = await ingestUnknownFromAssertions(dir, {
213
+ assertions, map, mappedFacts: facts, memory,
214
+ provenancePrefix: provenancePrefix ? `${provenancePrefix}-unknown` : undefined,
215
+ limit: unknownContextLimit,
216
+ });
217
+ }
218
+
219
+ return {
220
+ appended: res.appended, skipped: skipped + res.skipped, total: facts.length,
221
+ ...(unknown ? { unknown } : {}),
222
+ };
190
223
  }
@@ -0,0 +1,209 @@
1
+ // corpus/unknown-ingest.mjs — context-preserving ingestion for unknown terms
2
+ // (PLAN_AGENTS.md §4 Phase 1, the "still not built at all" bullet).
3
+ //
4
+ // The problem this closes: `toFacts()` (conceptnet.mjs) only emits a Fact for
5
+ // a relation the ACE-OWL map marks axiom-worthy (`ace != "none"`) — a row
6
+ // whose relation is RelatedTo/Synonym/FormOf/SimilarTo/HasContext/etc is
7
+ // SILENTLY skipped (`if (row.ace === "none") continue;`), on purpose, because
8
+ // the relation itself doesn't fit a clean OWL axiom. That is the right call
9
+ // for the AXIOM graph, but it means a term that ONLY ever shows up in one of
10
+ // those dropped rows — never as the endpoint of a relation tmct actually
11
+ // reifies — has NO anchor in memory at all. A wider seed set (a broader
12
+ // ConceptNet slice, a tier-2 bundle, Phase 4's scraped web content) makes
13
+ // this common: real terms, genuinely mentioned, quietly vanishing.
14
+ //
15
+ // This module does NOT change what counts as an axiom. It adds a SEPARATE,
16
+ // honestly-labelled kind of individual for exactly the terms that would
17
+ // otherwise vanish: a term is "unknown" here if it has never been the
18
+ // subject/object of any reified Fact — not in memory already, and not in the
19
+ // mapped facts this same seeding batch is about to write. For each dropped
20
+ // (ace="none") row that touches an unknown term, the term becomes a Fact
21
+ // tagged with the PASSAGE it was found in (ConceptNet's own `surfaceText`
22
+ // when present, else the map's own `surface` template filled with the row's
23
+ // two endpoints — both are committed, closed-vocabulary text; nothing here
24
+ // ever generates free text), via a dedicated `mgx:contextPassage` predicate.
25
+ // The row's OTHER endpoint (always) plus any already-known term recognizable
26
+ // by exact word/bigram match in the passage (bounded to a handful) are linked
27
+ // to the unknown term via one plain, closed-vocabulary co-occurrence
28
+ // predicate, `mgx:coOccursWith` — deliberately NOT distributional/embedding
29
+ // meaning induction (PLAN_AGENTS.md's own scoping): this buys traceable
30
+ // context, never automatic sense disambiguation.
31
+ //
32
+ // Reuses src/memory/core.mjs's existing appendFacts/loadMemory/normFactTerm
33
+ // machinery unmodified — a captured term is a completely ordinary Fact
34
+ // individual (same trust/provenance/Source pipeline every other fact gets),
35
+ // just carrying two new-but-closed predicates instead of an ACE-OWL one.
36
+ //
37
+ // Wiring: `seedMemory` (conceptnet.mjs) accepts an opt-in
38
+ // `captureUnknownContext: true` (default false — every existing seed call
39
+ // stays byte-identical) that calls `ingestUnknownFromAssertions` after the
40
+ // mapped facts are computed, dynamically imported (the same "avoid a static
41
+ // import cycle" discipline extensions.mjs's seedActiveCorpusEntries already
42
+ // uses for conceptnet.mjs itself).
43
+ //
44
+ // NOT covered here (out of scope for this module, see the caller's report):
45
+ // the LIVE chat teach/miss path (src/chat.mjs) has its own, separate
46
+ // unknown-word moment — a visitor's utterance mentioning a term the grammar
47
+ // can't classify — which has no "assertion batch" or ConceptNet-shaped
48
+ // surfaceText to draw a passage from at all. That needs its own hook (the
49
+ // raw utterance text IS the passage there); this module only ever consumes
50
+ // {start, rel, end, surfaceText?} shaped rows, so it cannot be reused as-is
51
+ // for that path without a chat.mjs-side adapter. See the report for the
52
+ // exact shape that hook would need — deliberately not built here.
53
+
54
+ import { appendFacts, loadMemory, normFactTerm, FACT_CLASS } from "../memory/core.mjs";
55
+ import { termText } from "./conceptnet.mjs";
56
+
57
+ /** unknown term -> the passage it was captured from (object = the passage
58
+ * text itself, capped by normFactTerm's own TEXT_CAP like any fact term). */
59
+ export const CONTEXT_PASSAGE_PREDICATE = "mgx:contextPassage";
60
+ /** plain, undirected-in-spirit co-occurrence edge: term -> another term seen
61
+ * in the SAME passage. Deliberately the ONE relation this module ever
62
+ * emits for "these two showed up together" — no relation-type inference. */
63
+ export const CO_OCCURS_PREDICATE = "mgx:coOccursWith";
64
+
65
+ // Function words filtered out of passage word/bigram scanning — never
66
+ // candidates for a co-occurrence link (a "the"<->term edge would be noise,
67
+ // not context). Closed, small, hand-curated — not a general stopword list.
68
+ const STOPWORDS = new Set([
69
+ "a", "an", "the", "is", "are", "was", "were", "be", "being", "been",
70
+ "to", "of", "for", "in", "on", "at", "with", "by", "as", "and", "or",
71
+ "that", "this", "it", "its", "you", "your", "related", "kind", "used",
72
+ ]);
73
+
74
+ const MAX_EXTRA_LINKS_PER_PASSAGE = 3; // bounded — not an open-ended sweep
75
+
76
+ /** The set of terms tmct already "recognizes": every subject/object across
77
+ * every reified Fact currently in memory, union every term the mapped facts
78
+ * THIS batch is about to write introduce. A term with a real Fact anywhere
79
+ * already has structured knowledge — only a term that never gets one is a
80
+ * candidate for context-only capture. Pure; does not mutate `memory`. */
81
+ export function knownTermsFrom(memory, mappedFacts) {
82
+ const known = new Set();
83
+ for (const ind of memory?.individuals || []) {
84
+ if (ind?.class !== FACT_CLASS) continue;
85
+ const get = (k) => (ind.attributes || []).find((a) => a?.key === k)?.value;
86
+ const s = get("subject");
87
+ const o = get("object");
88
+ if (s) known.add(normFactTerm(s));
89
+ if (o) known.add(normFactTerm(o));
90
+ }
91
+ for (const f of mappedFacts || []) {
92
+ if (f?.subject) known.add(normFactTerm(f.subject));
93
+ if (f?.object) known.add(normFactTerm(f.object));
94
+ }
95
+ return known;
96
+ }
97
+
98
+ /** The human-readable context passage for one DROPPED assertion — ConceptNet's
99
+ * own `surfaceText` (bracket-stripped) when present, else the mapping row's
100
+ * own `surface` template filled with the two endpoint terms. Both sources
101
+ * are committed, closed-vocabulary text — this never generates free text.
102
+ * Returns null when neither source is available (never fatal). */
103
+ export function passageFor(assertion, row) {
104
+ const raw = assertion?.surfaceText;
105
+ if (typeof raw === "string" && raw.trim()) {
106
+ return raw.replace(/\[\[([^\]]+)\]\]/g, "$1").replace(/\s+/g, " ").trim();
107
+ }
108
+ const s = termText(assertion?.start);
109
+ const o = termText(assertion?.end);
110
+ if (row?.surface && s && o) {
111
+ return row.surface.replace("{start}", s).replace("{end}", o);
112
+ }
113
+ return null;
114
+ }
115
+
116
+ /** Single words + adjacent bigrams in `passage` that are already-known terms
117
+ * (present in `known`), excluding anything in `exclude` (the row's own two
118
+ * endpoints — already linked directly). Bounded to
119
+ * MAX_EXTRA_LINKS_PER_PASSAGE hits; independent of `known`'s size (a Set
120
+ * lookup per token/bigram, never a substring sweep over every known term). */
121
+ function knownMentionsIn(passage, known, exclude) {
122
+ const tokens = String(passage || "").toLowerCase().replace(/[^a-z0-9\s]/g, " ").split(/\s+/).filter(Boolean);
123
+ const hits = [];
124
+ const seen = new Set();
125
+ for (let i = 0; i < tokens.length && hits.length < MAX_EXTRA_LINKS_PER_PASSAGE; i += 1) {
126
+ const candidates = [tokens[i]];
127
+ if (i + 1 < tokens.length) candidates.push(`${tokens[i]} ${tokens[i + 1]}`);
128
+ for (const c of candidates) {
129
+ if (STOPWORDS.has(c) || seen.has(c) || exclude.has(c)) continue;
130
+ if (known.has(c)) {
131
+ hits.push(c);
132
+ seen.add(c);
133
+ if (hits.length >= MAX_EXTRA_LINKS_PER_PASSAGE) break;
134
+ }
135
+ }
136
+ }
137
+ return hits;
138
+ }
139
+
140
+ /**
141
+ * Capture unknown terms out of the assertions a corpus-seeding batch is
142
+ * about to (or just did) write. Scans only rows whose relation maps to
143
+ * `ace = "none"` (the genuinely-dropped rows) — a mapped (ace != "none") row
144
+ * always gets a real reified Fact via toFacts()/appendFacts() already, so it
145
+ * is never a "silently dropped" case this module needs to rescue.
146
+ *
147
+ * { assertions, map, mappedFacts, memory?, provenancePrefix?, limit? }:
148
+ * - assertions/map: the SAME loadSlice()/loadMap() results seedMemory has.
149
+ * - mappedFacts: the toFacts() output for this same batch (feeds
150
+ * knownTermsFrom so a term this batch itself just defined isn't
151
+ * re-captured as "unknown").
152
+ * - memory: a pre-loaded payload (seedMemory already has one) — loaded
153
+ * fresh via loadMemory(dir) when omitted.
154
+ * - provenancePrefix: tags the captured facts, default "corpus:unknown".
155
+ * - limit: caps how many DISTINCT unknown terms get captured in one call
156
+ * (default 500) — a wide slice's RelatedTo rows alone number in the tens
157
+ * of thousands; this keeps one run bounded, not a flood.
158
+ *
159
+ * Returns { captured, linked, appended, skipped } — `captured` = distinct
160
+ * unknown terms newly given a contextPassage fact, `linked` = co-occurrence
161
+ * edges written (the direct pair plus any bounded extra known-term hits).
162
+ */
163
+ export async function ingestUnknownFromAssertions(dir, {
164
+ assertions, map, mappedFacts = [], memory, provenancePrefix = "corpus:unknown", limit = 500,
165
+ } = {}) {
166
+ if (!Array.isArray(assertions) || !map) return { captured: 0, linked: 0, appended: 0, skipped: 0 };
167
+ const mem = memory || await loadMemory(dir);
168
+ const known = knownTermsFrom(mem, mappedFacts);
169
+ const capturedTerms = new Set();
170
+ const toWrite = [];
171
+
172
+ for (const a of assertions) {
173
+ if (capturedTerms.size >= limit) break;
174
+ const row = map.get(a.rel);
175
+ if (!row || row.ace !== "none") continue; // only the genuinely-dropped rows
176
+ const subject = termText(a.start);
177
+ const object = termText(a.end);
178
+ if (!subject || !object) continue; // non-en endpoint — nothing to anchor a passage to
179
+ const sKey = normFactTerm(subject);
180
+ const oKey = normFactTerm(object);
181
+ const subjectUnknown = !known.has(sKey);
182
+ const objectUnknown = !known.has(oKey);
183
+ if (!subjectUnknown && !objectUnknown) continue; // both sides already recognized
184
+
185
+ const passage = passageFor(a, row);
186
+ if (!passage) continue;
187
+
188
+ const pairs = [[subject, object, subjectUnknown], [object, subject, objectUnknown]];
189
+ for (const [term, other, isUnknown] of pairs) {
190
+ if (!isUnknown || capturedTerms.size >= limit) continue;
191
+ const termKey = normFactTerm(term);
192
+ if (capturedTerms.has(termKey)) continue; // one context capture per term per run
193
+ const tag = `${provenancePrefix} ${a.rel}`;
194
+ toWrite.push({ subject: term, predicate: CONTEXT_PASSAGE_PREDICATE, object: passage, provenance: tag });
195
+ toWrite.push({ subject: term, predicate: CO_OCCURS_PREDICATE, object: other, provenance: tag });
196
+ const extra = knownMentionsIn(passage, known, new Set([sKey, oKey]));
197
+ for (const m of extra) {
198
+ toWrite.push({ subject: term, predicate: CO_OCCURS_PREDICATE, object: m, provenance: tag });
199
+ }
200
+ capturedTerms.add(termKey);
201
+ known.add(termKey); // now recognized — a captured term is never re-captured
202
+ }
203
+ }
204
+
205
+ if (!toWrite.length) return { captured: 0, linked: 0, appended: 0, skipped: 0 };
206
+ const res = await appendFacts(dir, toWrite);
207
+ const linked = toWrite.filter((f) => f.predicate === CO_OCCURS_PREDICATE).length;
208
+ return { captured: capturedTerms.size, linked, appended: res.appended, skipped: res.skipped };
209
+ }
@@ -6,10 +6,14 @@
6
6
  // resolveExtensions(repoRoot) → { entries: Map<name, ResolvedEntry>, biasByBundle }
7
7
  //
8
8
  // BUILTIN_EXTENSIONS ships the exact two bundles chat.mjs's bootstrap has
9
- // always seeded — `seon` and `conceptnet`, both active — plus three shipped-
10
- // but-INACTIVE tier-2 bundles (`tier2-aws` / `tier2-python` / `tier2-java`).
11
- // Activating one is a config-only edit (`tmct init --corpus aws`, or a
12
- // `[extensions.tier2-aws] active = true` in tmct.toml) — zero code change.
9
+ // always seeded — `seon` and `conceptnet`, both active — plus four shipped-
10
+ // but-INACTIVE tier-2 bundles (`tier2-aws` / `tier2-python` / `tier2-java` /
11
+ // `tier2-general`). Activating one is a config-only edit (`tmct init --corpus
12
+ // aws`, or a `[extensions.tier2-aws] active = true` in tmct.toml) — zero code
13
+ // change. `tier2-general` (PLAN_AGENTS.md Phase 1) is deliberately NOT a
14
+ // language/domain bundle like the other three — everyday-knowledge concepts
15
+ // with zero code-domain framing, the "wider general-knowledge seed set"
16
+ // bullet made real instead of just mechanically activatable.
13
17
  //
14
18
  // A `tmct.toml` may carry a top-level `[extensions]` table-of-tables
15
19
  // (`[extensions.tier2-aws]`, …): a RECOGNIZED name (one of the builtins above)
@@ -87,6 +91,12 @@ function builtinExtensions() {
87
91
  corpusPath: join(TIER2_DIR, "java.jsonl"),
88
92
  provenancePrefix: "corpus:tier2-java",
89
93
  },
94
+ "tier2-general": {
95
+ kind: "corpus",
96
+ active: false,
97
+ corpusPath: join(TIER2_DIR, "general.jsonl"),
98
+ provenancePrefix: "corpus:tier2-general",
99
+ },
90
100
  };
91
101
  }
92
102
 
package/src/finish.mjs CHANGED
@@ -312,13 +312,46 @@ function ruleAgreement(segments, rule) {
312
312
  return out;
313
313
  }
314
314
 
315
- // Rule 3 — sentence capitalisation. Only when the answer OPENS on a prose span
316
- // whose first non-space character is a lowercase letter. An answer that opens on
317
- // a protected span (a path/entity) is left exactly as grounded.
315
+ // Rule 3 — sentence capitalisation. Capitalises (a) the very first character
316
+ // when the answer OPENS on a prose span (the ORIGINAL single-answer scope,
317
+ // unchanged), (b) every INTERNAL sentence boundary inside a single prose span
318
+ // — a run of terminal punctuation + whitespace followed by a lowercase letter
319
+ // — and (c) a sentence boundary that CROSSES a span boundary: a prose span
320
+ // ends in terminal punctuation (+ optional trailing whitespace) and the next
321
+ // real-content span is itself prose starting lowercase (any purely-whitespace
322
+ // prose spans in between are skipped over). (b) and (c) are the
323
+ // PLAN_COMPLETIONS.md Stage 6 generalisation — a genuinely multi-sentence
324
+ // completion needs every internal boundary capitalised, not just the whole-
325
+ // answer opener. A boundary that lands on a PROTECTED span (path/entity/…) is
326
+ // left exactly as grounded — a protected span's casing is never
327
+ // rule-transformed, the same guard (a) always applied to an answer that opens
328
+ // on one.
318
329
  function ruleCapitalise(segments) {
319
- if (!segments.length || segments[0].type !== "prose") return segments;
330
+ if (!segments.length) return segments;
320
331
  const out = segments.map((s) => ({ ...s }));
321
- out[0].text = out[0].text.replace(/^(\s*)([a-z])/, (m, sp, ch) => sp + ch.toUpperCase());
332
+
333
+ // (a) answer-initial capitalisation
334
+ if (out[0].type === "prose") {
335
+ out[0].text = out[0].text.replace(/^(\s*)([a-z])/, (m, sp, ch) => sp + ch.toUpperCase());
336
+ }
337
+
338
+ // (b) every internal sentence boundary WITHIN a single prose span
339
+ for (const seg of out) {
340
+ if (seg.type !== "prose") continue;
341
+ seg.text = seg.text.replace(/([.!?])(\s+)([a-z])/g, (m, stop, sp, ch) => stop + sp + ch.toUpperCase());
342
+ }
343
+
344
+ // (c) a sentence boundary that CROSSES a span boundary
345
+ for (let i = 0; i < out.length; i += 1) {
346
+ const seg = out[i];
347
+ if (seg.type !== "prose" || !/[.!?]\s*$/.test(seg.text)) continue;
348
+ let j = i + 1;
349
+ while (j < out.length && out[j].type === "prose" && /^\s*$/.test(out[j].text)) j += 1;
350
+ if (j < out.length && out[j].type === "prose") {
351
+ out[j].text = out[j].text.replace(/^(\s*)([a-z])/, (m, sp, ch) => sp + ch.toUpperCase());
352
+ }
353
+ }
354
+
322
355
  return out;
323
356
  }
324
357
 
@@ -345,19 +378,18 @@ function ruleList(segments, rule) {
345
378
  return out;
346
379
  }
347
380
 
348
- // Rule 5 — terminal punctuation. Collapse a trailing run of 2+ sentence stops in
349
- // the LAST prose span to a single stop ("done.." → "done."). Adds nothing where
350
- // a fragment/list answer legitimately ends without a stop.
381
+ // Rule 5 — terminal punctuation. Collapses ANY run of 2+ sentence stops within
382
+ // a prose span to a single stop ("done.." → "done."; "Sentence one.. Sentence
383
+ // two!!" "Sentence one. Sentence two!") generalised (PLAN_COMPLETIONS.md
384
+ // Stage 6) from the original single-answer scope (only the LAST prose span,
385
+ // only a run anchored at the very end of the answer) to every internal
386
+ // sentence boundary a multi-sentence completion can carry. A legitimate
387
+ // fragment/list answer that ends without a stop is unaffected — there is
388
+ // nothing to collapse.
351
389
  function ruleTerminal(segments, rule) {
352
390
  const stops = (rule.stops && rule.stops.length ? rule.stops : [".", "!", "?"]).map(escapeRe2).join("");
353
- const out = segments.map((s) => ({ ...s }));
354
- let idx = -1;
355
- for (let i = out.length - 1; i >= 0; i -= 1) if (out[i].type === "prose") { idx = i; break; }
356
- if (idx < 0) return out;
357
- const re = new RegExp(`([${stops}])(?:\\s*[${stops}])+(\\s*)$`);
358
- const m = out[idx].text.match(re);
359
- if (m) out[idx].text = out[idx].text.slice(0, m.index) + m[1] + m[2];
360
- return out;
391
+ const re = new RegExp(`([${stops}])(?:\\s*[${stops}])+(\\s*)`, "g");
392
+ return segments.map((s) => (s.type === "prose" ? { ...s, text: s.text.replace(re, "$1$2") } : { ...s }));
361
393
  }
362
394
 
363
395
  const HANDLERS = {
@@ -421,16 +453,27 @@ export function applyGrammar(segments, rules = grammarRules()) {
421
453
  // runTurn, at the `withLast` seam, `result = finish(result, { graph })` so every
422
454
  // producer passes through once. Until then finish() is exercised by its unit +
423
455
  // golden tests; wiring it changes no fact, only fixes our own generated defects.
456
+ //
457
+ // ctx.rules (optional, additive): a caller-supplied rule table overriding the
458
+ // cached grammarRules() for this call only — e.g. completions/complete.mjs's
459
+ // Stage 6 pass force-enables the sentence-capitalisation rule (PARKED in the
460
+ // live chat table per grammar-rules.toml's own cycle-006 note) because a
461
+ // genuinely multi-sentence extractive completion needs every internal sentence
462
+ // boundary capitalised to read as one voice, without touching the chat
463
+ // pipeline's default live/parked flags. Every EXISTING call site omits
464
+ // ctx.rules and is therefore byte-identical to before this option existed.
424
465
 
425
466
  /** Finish a turn result: grammar-correct its prose spans, preserving every fact.
426
467
  * Byte-stable when neutral (returns its argument unchanged); rebuilds only on a
427
- * genuine fix. Throws if finishing would move any protected span (guard #4). */
468
+ * genuine fix. Throws if finishing would move any protected span (guard #4).
469
+ * @param {{rules?: object[]}} [ctx.rules] optional rule-table override (see above) */
428
470
  export function finish(result, ctx = {}) {
429
471
  if (!result || typeof result.answer !== "string") return result;
472
+ const rules = Array.isArray(ctx.rules) ? ctx.rules : grammarRules();
430
473
  const before = Array.isArray(result.segments) && result.segments.length
431
474
  ? result.segments
432
475
  : maskSegments(result.answer, ctx);
433
- const after = applyGrammar(before, grammarRules());
476
+ const after = applyGrammar(before, rules);
434
477
  assertInvariance(before, after);
435
478
  const answer = flatten(after);
436
479
  if (answer === result.answer) return result; // neutral → byte-stable, same reference