@polycode-projects/the-mechanical-code-talker 2.10.3 → 2.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/README.md +68 -12
  2. package/bin/tmct.mjs +5 -2
  3. package/corpus/sprites/src/sprite-facts.jsonl +18 -0
  4. package/corpus/worlds/manifest.json +5 -5
  5. package/corpus/worlds/shards/ashcombe-hall.jsonl.gz +0 -0
  6. package/corpus/worlds/src/ashcombe-hall.jsonl +27 -0
  7. package/data/sprites/book-icon.toml +12 -0
  8. package/data/sprites/cellar-icon.toml +12 -0
  9. package/data/sprites/drawing-room-icon.toml +13 -0
  10. package/data/sprites/garden-icon.toml +12 -0
  11. package/data/sprites/kitchen-icon.toml +13 -0
  12. package/data/sprites/library-icon.toml +12 -0
  13. package/data/sprites/pan-icon.toml +11 -0
  14. package/data/sprites/study-icon.toml +12 -0
  15. package/package.json +7 -2
  16. package/src/adapters/corpus/wikipedia-live.mjs +182 -26
  17. package/src/adapters/corpus/worlds-pack.mjs +8 -2
  18. package/src/adapters/memory/core.mjs +8 -1
  19. package/src/adapters/toml-config.mjs +6 -0
  20. package/src/domain/cli-verbs.mjs +2 -0
  21. package/src/domain/memory/trust.mjs +32 -2
  22. package/src/domain/sense-split.mjs +203 -0
  23. package/src/domain/worlds-pack.mjs +50 -0
  24. package/src/services/adventure-autoplay.mjs +5 -2
  25. package/src/services/adventure-viz.mjs +301 -33
  26. package/src/services/adventure.mjs +162 -14
  27. package/src/services/chat-page-viz.mjs +341 -197
  28. package/src/services/chat-session.mjs +24 -9
  29. package/src/services/chat.mjs +580 -47
  30. package/src/services/code-explorer-viz.mjs +198 -76
  31. package/src/services/extract-facts.mjs +384 -82
  32. package/src/services/fold.mjs +1 -1
  33. package/src/services/ingest-viz.mjs +637 -0
  34. package/src/services/ledger-viz.mjs +209 -0
  35. package/src/services/memory-panel-viz.mjs +159 -0
  36. package/src/services/research.mjs +266 -0
  37. package/src/services/sentences.mjs +19 -0
  38. package/src/services/session-log-format.mjs +64 -0
  39. package/src/services/sessions.mjs +56 -22
  40. package/src/services/spider-fly-turn.mjs +54 -1
  41. package/src/services/spider-fly-viz.mjs +41 -23
  42. package/src/surfaces/web/adventure-browser-entry.mjs +9 -5
  43. package/src/surfaces/web/chat-browser-entry.mjs +32 -11
  44. package/src/surfaces/web/code-explorer-browser-entry.mjs +27 -11
  45. package/src/surfaces/web/ingest-browser-entry.mjs +208 -0
  46. package/src/surfaces/web/ledger-browser-entry.mjs +24 -5
  47. package/src/surfaces/web/memory-ask-browser.bundle.js +134 -125
  48. package/src/surfaces/web/memory-stats.mjs +53 -0
  49. package/src/tools/definitions.mjs +14 -0
  50. package/src/tools/handlers/index.mjs +2 -0
  51. package/src/tools/handlers/tmct-ingest.mjs +43 -0
  52. package/src/tools/server.mjs +5 -2
@@ -1,22 +1,33 @@
1
- // `tmct extract` — turn a plain text file into facts by reusing the SAME
2
- // deterministic recognizer the interactive chat's "teach" lane already has
3
- // (runTurn, src/services/chat.mjs) — no new NLU, no LLM, no guessing.
1
+ // `tmct extract` — turn plain text into facts by reusing the SAME deterministic
2
+ // recognizer the interactive chat's "teach" lane already has (runTurn,
3
+ // src/services/chat.mjs) — no new NLU, no LLM, no guessing.
4
4
  //
5
5
  // tmct extract <text-file> [--repo <path>] [--out <file.jsonl>]
6
+ // [--optimistic] [--canonical]
6
7
  //
7
8
  // The text file is named positionally, or with --file, the way `tmct import`
8
- // names one.
9
+ // names one. The whole pipeline is exposed as one service seam, `ingestText`,
10
+ // so a browser or a tool layer can ground the same text without the CLI.
9
11
  //
10
- // How it works: the file is split into sentences with wink-nlp's own
11
- // sentence-boundary detection (src/adapters/wink-model.mjs — the same leaf loader
12
- // src/adapters/ask-nlp.mjs/src/adapters/prose-nlp.mjs already use; never a naive regex split).
13
- // Each sentence is fed through runTurn() exactly as if an operator had typed
14
- // it into the live chat. A sentence the recognizer turns into a stored fact
15
- // (record.via === "assert", record.miss === false) is kept; every other
16
- // sentence a question, a fragment, a scene-setting clause, anything
17
- // outside the recognized teach-frame surface — is silently SKIPPED. This is
18
- // an honest partial extraction, an "attempt", never full NLU: nothing here
19
- // ever paraphrases or invents a fact the recognizer itself didn't produce.
12
+ // How it works: the text is split into sentences with wink-nlp's own
13
+ // sentence-boundary detection (src/services/sentences.mjs — never a naive regex
14
+ // split). Each sentence is fed through runTurn() exactly as if an operator had
15
+ // typed it into the live chat. A sentence the recognizer turns into a stored
16
+ // fact (record.via === "assert", record.miss === false) is kept; every other
17
+ // sentence is SKIPPED. This is an honest partial extraction, an "attempt", never
18
+ // full NLU: nothing here ever paraphrases or invents a fact the recognizer
19
+ // itself didn't produce.
20
+ //
21
+ // --optimistic ALSO run a bounded, lexicon-gated fuzzy tier over the sentences
22
+ // the strict recognizer skipped (optimisticTriples below): a
23
+ // copula or a known relation verb flanked by two resolvable
24
+ // entities becomes a candidate triple, stored under its OWN
25
+ // low-trust source kind (optimistic-extract:<source>, prior 0.35 —
26
+ // below every curated pack, memory/trust.mjs) with NO operator or
27
+ // teach tag riding alongside, so a fuzzy candidate can never
28
+ // corroborate a curated fact.
29
+ // --canonical Print each ingested fact in canonical triple form, enriched with
30
+ // how each endpoint already links into the store.
20
31
  //
21
32
  // --repo <path> write straight into that repo's own tmct memory (runTurn's
22
33
  // normal memoryDir write path — "grow my own tmct memory
@@ -27,40 +38,44 @@
27
38
  // shape JSONL ({subject, predicate, object, provenance}) —
28
39
  // to stdout, or to --out <file.jsonl> if given.
29
40
  //
30
- // Every recognized fact ALSO gets a second, additive provenance tag —
31
- // extracted:<source-file-basename> — layered on top of whatever the
32
- // recognizer itself already wrote (ace:chat:…/teach:chat:…), via appendFact's
33
- // existing provenance UNION (memory/core.mjs). That keeps an extracted fact
34
- // auditable as "this document evidenced this claim", distinct from live
35
- // operator speech or a curated corpus, at its own trust-prior tier
36
- // (SOURCE_PRIOR.extracted, memory/trust.mjs see that file's comment for
37
- // the reasoning: same closed-set recognizer as `teach`, but an unvetted
38
- // source document, so it sits just above `web`).
41
+ // Every strict-recognized fact ALSO gets a second, additive provenance tag —
42
+ // extracted:<source-file-basename> — layered on top of whatever the recognizer
43
+ // itself already wrote (ace:chat:…/teach:chat:…), via appendFact's provenance
44
+ // UNION (memory/core.mjs). That keeps an extracted fact auditable as "this
45
+ // document evidenced this claim" at its own trust-prior tier
46
+ // (SOURCE_PRIOR.extracted, memory/trust.mjs). An optimistic candidate carries
47
+ // ONLY its own low tag, because it was never operator speech.
39
48
  //
40
49
  // Never claims full coverage: the summary this prints always states how many
41
- // sentences were found, how many were recognized, and how many were honestly
42
- // skipped.
50
+ // sentences were found, how many were recognized, and how many were skipped.
43
51
 
44
52
  import { readFile, writeFile, mkdtemp, rm } from "node:fs/promises";
45
53
  import { tmpdir } from "node:os";
46
54
  import { basename, join, resolve } from "node:path";
47
55
 
48
56
  import { runTurn, uuidv7 } from "./chat.mjs";
49
- import { splitSentencesPreservingPaths } from "./sentences.mjs";
57
+ import { splitSentencesPreservingPaths, stripCitationResidue } from "./sentences.mjs";
50
58
  import { loadMemory, readFactRows, appendFact } from "../adapters/memory/core.mjs";
51
59
  import { loadConfig } from "../adapters/config.mjs";
52
60
  import { touchedFactRows } from "../domain/memory/touched-facts.mjs";
61
+ import { normFactTerm } from "../domain/hash.mjs";
62
+ import { winkInstance } from "../adapters/wink-model.mjs";
63
+ import {
64
+ loadLexicon, lookupNoun, lookupVerb, lookupAdjective, lookupProperName, predicateOf,
65
+ } from "../domain/grammar/lexicon.mjs";
53
66
 
54
- export const USAGE = "usage: tmct extract <text-file>|--file <text-file> [--repo <path>] [--out <file.jsonl>]";
67
+ export const USAGE = "usage: tmct extract <text-file>|--file <text-file> [--repo <path>] [--out <file.jsonl>] [--optimistic] [--canonical]";
55
68
 
56
69
  export function parseArgs(argv) {
57
- const args = { file: null, repo: null, out: null };
70
+ const args = { file: null, repo: null, out: null, optimistic: false, canonical: false };
58
71
  const rest = [];
59
72
  for (let i = 0; i < argv.length; i += 1) {
60
73
  const a = argv[i];
61
74
  if (a === "--repo") args.repo = argv[i += 1];
62
75
  else if (a === "--out") args.out = argv[i += 1];
63
76
  else if (a === "--file") args.file = argv[i += 1];
77
+ else if (a === "--optimistic") args.optimistic = true;
78
+ else if (a === "--canonical") args.canonical = true;
64
79
  else rest.push(a);
65
80
  }
66
81
  args.file = args.file || rest[0] || null;
@@ -81,75 +96,362 @@ async function runSentence(sentence, { config, memoryDir }) {
81
96
  return { recognized: true, rows: touchedFactRows(before, after) };
82
97
  }
83
98
 
99
+ // The copula lemmas that read as class membership; a following noun phrase is
100
+ // the class the subject is-a. "has/have" and other verbs are relations, not isa.
101
+ const OPTIMISTIC_COPULAS = new Set(["is", "are", "was", "were", "be", "been", "being", "am"]);
102
+ // The determiners and generic classifier heads the lexical fallback reads
103
+ // through when a wink model isn't available to tag parts of speech.
104
+ const OPTIMISTIC_SKIP = new Set([
105
+ "a", "an", "the", "this", "that", "these", "those", "its", "his", "her", "their",
106
+ "our", "my", "your", "some", "any", "one", "kind", "sort", "type", "of",
107
+ ]);
108
+ const OPTIMISTIC_ENTITY_HOPS = 4;
109
+
110
+ /** Fold an entity surface to its stored key: a lexicon noun's lemma, else the
111
+ * word's own normFactTerm (the optimistic tier mints unlisted content nouns
112
+ * the way the strict teach lane already mints "redis"). */
113
+ function foldEntity(word, lexicon) {
114
+ const noun = lookupNoun(lexicon, String(word).toLowerCase());
115
+ return normFactTerm(noun ? noun.lemma : word);
116
+ }
117
+
118
+ /** The precise tier: wink POS tags pick out the NOUN/PROPN either side of a
119
+ * copula (→ rdfs:subClassOf) or a lexicon-known relation verb (→ its
120
+ * predicate). Adjectives, determiners and prepositions are never mistaken for
121
+ * the entity, so "the quick brown fox jumps over something" yields nothing.
122
+ * The entity scan stops at punctuation so it never crosses a clause. */
123
+ function optimisticTriplesPos(sentence, lexicon, nlp) {
124
+ let values;
125
+ let pos;
126
+ try {
127
+ const doc = nlp.readDoc(String(sentence || ""));
128
+ values = doc.tokens().out(nlp.its.value);
129
+ pos = doc.tokens().out(nlp.its.pos);
130
+ } catch { return []; }
131
+ const nearestEntity = (idx, step) => {
132
+ for (let i = idx + step; i >= 0 && i < values.length; i += step) {
133
+ if (pos[i] === "PUNCT") break;
134
+ if (pos[i] === "NOUN" || pos[i] === "PROPN") return foldEntity(values[i], lexicon);
135
+ }
136
+ return null;
137
+ };
138
+ const tripleAt = (i, predicate) => {
139
+ const subject = nearestEntity(i, -1);
140
+ const object = nearestEntity(i, +1);
141
+ return subject && object && subject !== object ? { subject, predicate, object } : null;
142
+ };
143
+ for (let i = 1; i < values.length - 1; i += 1) {
144
+ if (pos[i] === "AUX" && OPTIMISTIC_COPULAS.has(values[i].toLowerCase())) {
145
+ const t = tripleAt(i, "rdfs:subClassOf");
146
+ if (t) return [t];
147
+ }
148
+ }
149
+ for (let i = 1; i < values.length - 1; i += 1) {
150
+ if (pos[i] !== "VERB") continue;
151
+ const verb = lookupVerb(lexicon, values[i].toLowerCase());
152
+ if (!verb) continue;
153
+ const t = tripleAt(i, predicateOf(verb));
154
+ if (t) return [t];
155
+ }
156
+ return [];
157
+ }
158
+
159
+ /** The lexical fallback for a checkout with no wink model: a copula flanked by
160
+ * two lexicon-known nouns (or a mid-sentence proper noun). Narrower and more
161
+ * cautious than the POS tier — without part-of-speech tags the only safe entity
162
+ * test is lexicon membership, so unlisted content nouns are skipped here. */
163
+ function optimisticTriplesLexical(sentence, lexicon) {
164
+ const raw = String(sentence || "").match(/[A-Za-z][A-Za-z'-]*/g) || [];
165
+ if (raw.length < 3) return [];
166
+ const lower = raw.map((w) => w.toLowerCase());
167
+ const nearestEntity = (idx, step) => {
168
+ for (let i = idx + step, hops = 0; i >= 0 && i < lower.length && hops < OPTIMISTIC_ENTITY_HOPS; i += step, hops += 1) {
169
+ const w = lower[i];
170
+ if (OPTIMISTIC_SKIP.has(w) || lookupAdjective(lexicon, w)) continue;
171
+ const noun = lookupNoun(lexicon, w);
172
+ if (noun) return normFactTerm(noun.lemma);
173
+ const proper = lookupProperName(lexicon, w);
174
+ if (proper) return normFactTerm(proper);
175
+ if (i > 0 && /^[A-Z]/.test(raw[i])) return normFactTerm(raw[i]);
176
+ return null;
177
+ }
178
+ return null;
179
+ };
180
+ for (let i = 1; i < lower.length - 1; i += 1) {
181
+ if (!OPTIMISTIC_COPULAS.has(lower[i])) continue;
182
+ const subject = nearestEntity(i, -1);
183
+ const object = nearestEntity(i, +1);
184
+ if (subject && object && subject !== object) return [{ subject, predicate: "rdfs:subClassOf", object }];
185
+ }
186
+ return [];
187
+ }
188
+
189
+ /**
190
+ * A bounded triple candidate from a sentence the strict recognizer skipped: a
191
+ * copula (→ rdfs:subClassOf) or a lexicon-known relation verb (→ its predicate)
192
+ * flanked by two entities. At most one triple per sentence; [] when nothing
193
+ * resolves both sides — no guessing past the shape. Uses wink POS tags when a
194
+ * model is available (the precise tier), else a narrower lexicon-only fallback.
195
+ *
196
+ * opts.lexicon a loaded lexicon (the core vocabulary when absent).
197
+ * opts.nlp a wink instance (winkInstance() when absent); null forces the
198
+ * lexical fallback.
199
+ */
200
+ export function optimisticTriples(sentence, { lexicon = loadLexicon(), nlp } = {}) {
201
+ const engine = nlp === undefined ? winkInstance() : nlp;
202
+ return engine ? optimisticTriplesPos(sentence, lexicon, engine) : optimisticTriplesLexical(sentence, lexicon);
203
+ }
204
+
205
+ // A closed set of clause markers a compound sentence hinges on. A candidate
206
+ // fragment is only kept when it stands alone as a clause; the marker set is
207
+ // deliberately small so a run-on never shatters into noise.
208
+ const CLAUSE_MARKER_RE = /,?\s+(?:because|since|although|though|whereas|while|so|and|but)\s+/i;
209
+ // The copula/auxiliary/light-verb words that mark a fragment as a would-be
210
+ // clause when no wink POS tagger is on hand — the same closed-list discipline
211
+ // the strict teach frames use in place of a probabilistic tag.
212
+ const CLAUSE_VERBISH = new Set([
213
+ "is", "are", "was", "were", "be", "been", "being", "am",
214
+ "has", "have", "had", "can", "could", "will", "would", "should",
215
+ "do", "does", "did", "want", "wants", "need", "needs", "make", "makes", "made",
216
+ ]);
217
+
218
+ /** Does the fragment carry a verb-ish token — a real VERB/AUX when wink tags
219
+ * it, else one of the closed copula/aux words above? A clause with no verb is
220
+ * a noun pile, never a sentence to ground. */
221
+ function fragmentHasVerb(fragment, nlp) {
222
+ const engine = nlp === undefined ? winkInstance() : nlp;
223
+ if (engine) {
224
+ try {
225
+ const pos = engine.readDoc(String(fragment)).tokens().out(engine.its.pos);
226
+ if (pos.some((p) => p === "VERB" || p === "AUX")) return true;
227
+ } catch { /* fall through to the closed-list check */ }
228
+ }
229
+ return String(fragment).toLowerCase().split(/\s+/).some((w) => CLAUSE_VERBISH.has(w));
230
+ }
231
+
232
+ /**
233
+ * The grounding candidates a sentence offers, WHOLE SENTENCE FIRST: the strict
234
+ * recognizer should always get the full sentence before any fragment, so a
235
+ * clause split only ever adds fallbacks, never pre-empts a clean whole-sentence
236
+ * read. When the sentence hinges on a closed clause marker
237
+ * (because/since/although/though/whereas/while/so/and/but), each side is added
238
+ * as a fallback — but only a fragment of at least 3 tokens that carries a
239
+ * verb-ish token, so a stray connective can't split a sentence into noise.
240
+ * Returns [sentence, ...fragments] with no duplicates.
241
+ */
242
+ export function clauseCandidates(sentence, { nlp } = {}) {
243
+ const whole = String(sentence ?? "").trim();
244
+ const out = [whole];
245
+ if (!whole) return out;
246
+ const parts = whole.split(CLAUSE_MARKER_RE).map((s) => s.trim()).filter(Boolean);
247
+ if (parts.length <= 1) return out;
248
+ for (const part of parts) {
249
+ if (part === whole || out.includes(part)) continue;
250
+ if (part.split(/\s+/).length < 3) continue;
251
+ if (!fragmentHasVerb(part, nlp)) continue;
252
+ out.push(part);
253
+ }
254
+ return out;
255
+ }
256
+
257
+ // The pronoun subjects a bounded carry substitutes with the paragraph's last
258
+ // grounded subject. Ingest only — a chat turn resolves "it"/"they" against the
259
+ // live focus, never a stale paragraph carry.
260
+ const PRONOUN_LEAD_RE = /^(?:they|it|these|those|this)\b\s*/i;
261
+
262
+ /** A readable predicate for canonical output: the local part of an rdfs:/ace:
263
+ * CURIE, otherwise the predicate verbatim. */
264
+ const readablePredicate = (predicate) => String(predicate).replace(/^[a-z]+:/i, "");
265
+
266
+ /** One canonical line per ingested fact, enriched with how each endpoint links
267
+ * back into the store: `subject predicate object` plus, for each endpoint that
268
+ * the store also mentions elsewhere, how many OTHER facts touch it. */
269
+ function canonicalLines(facts, storeRows) {
270
+ const degree = new Map();
271
+ for (const row of storeRows) {
272
+ degree.set(row.subject, (degree.get(row.subject) || 0) + 1);
273
+ if (row.object !== row.subject) degree.set(row.object, (degree.get(row.object) || 0) + 1);
274
+ }
275
+ return facts.map((f) => {
276
+ const links = [];
277
+ for (const end of [f.subject, f.object]) {
278
+ const others = (degree.get(end) || 0) - 1; // exclude this fact's own edge
279
+ if (others > 0) links.push(`${end} links to ${others} other fact${others === 1 ? "" : "s"}`);
280
+ }
281
+ const line = `${f.subject} ${readablePredicate(f.predicate)} ${f.object}`;
282
+ return links.length ? `${line} [${links.join("; ")}]` : line;
283
+ });
284
+ }
285
+
286
+ /**
287
+ * ingestText — the single ingest seam. Grounds `text` into facts and returns a
288
+ * structured result; the CLI, the browser and the tool layer all drive this.
289
+ *
290
+ * text the raw string to ground.
291
+ * options:
292
+ * memoryDir write grounded facts here (a real .tmct memory dir). Omit for
293
+ * an ephemeral scratch dir that is created and deleted inside
294
+ * this call, so nothing on disk is mutated — the returned facts
295
+ * are the only output.
296
+ * sourceTag the label the audit provenance carries (extracted:<tag> /
297
+ * optimistic-extract:<tag>). Default "text".
298
+ * optimistic also run the fuzzy tier over strict-skipped sentences.
299
+ * canonical include a `canonical` array: one enriched triple line per
300
+ * ingested fact.
301
+ * config a loaded config; derived from the write dir when absent.
302
+ * lexicon a loaded lexicon; the core vocabulary when absent.
303
+ *
304
+ * Returns { sentences, recognized, extracted, optimistic, skipped, canonical? }.
305
+ * recognized — strict-recognized sentence count.
306
+ * extracted — strict fact rows ({subject, predicate, object, provenance,
307
+ * quantifier, sentence}).
308
+ * optimistic — fuzzy candidate rows ({subject, predicate, object, provenance,
309
+ * sentence}); always [] unless options.optimistic.
310
+ * skipped — sentences neither tier grounded.
311
+ */
312
+ export async function ingestText(text, {
313
+ memoryDir = null, sourceTag = "text", optimistic = false,
314
+ canonical = false, config = null, lexicon = null,
315
+ } = {}) {
316
+ // Paragraphs first (blank-line separated), so the pronoun carry never bridges
317
+ // a topic break: a fresh paragraph clears the last-subject it would resolve
318
+ // "they"/"it" against. Each paragraph then splits into sentences the shared
319
+ // path-preserving way.
320
+ const paragraphs = String(text ?? "").split(/\n[ \t]*\n/);
321
+ const ephemeral = !memoryDir;
322
+ const dir = memoryDir || await mkdtemp(join(tmpdir(), "tmct-ingest-"));
323
+ // `dir` may be a backend handle (a sqlite store), not a path — only a real
324
+ // directory string can seed loadConfig's cwd; a handle-holding caller passes
325
+ // its own config.
326
+ const cfg = config || loadConfig(process.env, typeof dir === "string" ? dir : process.cwd());
327
+ const lex = lexicon || loadLexicon();
328
+ const nlp = optimistic ? winkInstance() : null;
329
+
330
+ const extracted = [];
331
+ const optimisticFacts = [];
332
+ let sentenceCount = 0;
333
+ let recognizedSentences = 0;
334
+ let optimisticSentences = 0;
335
+
336
+ // One recognized read of some text form: null when the strict recognizer
337
+ // grounds nothing, else the Fact rows it touched.
338
+ const strictRows = async (form) => {
339
+ const { recognized, rows } = await runSentence(form, { config: cfg, memoryDir: dir });
340
+ return recognized && rows.length ? rows : null;
341
+ };
342
+
343
+ try {
344
+ for (const paragraph of paragraphs) {
345
+ // The last unique grounded subject in THIS paragraph, carried onto a
346
+ // later pronoun-led sentence the strict recognizer couldn't ground on
347
+ // its own. Cleared at the paragraph boundary.
348
+ let carrySubject = null;
349
+ for (const sentence of splitSentencesPreservingPaths(paragraph)) {
350
+ sentenceCount += 1;
351
+ const cleaned = stripCitationResidue(sentence);
352
+ // Whole sentence first, then each closed-marker clause as a fallback.
353
+ let rows = null;
354
+ for (const candidate of clauseCandidates(cleaned, { nlp })) {
355
+ rows = await strictRows(candidate);
356
+ if (rows) break;
357
+ }
358
+ // Bounded pronoun carry: a "they/it/these/those/this …" sentence the
359
+ // recognizer skipped is retried once with the paragraph's last grounded
360
+ // subject in the pronoun's place. Never a chat turn — ingest only.
361
+ if (!rows && carrySubject && PRONOUN_LEAD_RE.test(cleaned)) {
362
+ rows = await strictRows(cleaned.replace(PRONOUN_LEAD_RE, `${carrySubject} `));
363
+ }
364
+ if (rows) {
365
+ recognizedSentences += 1;
366
+ const subjects = new Set(rows.map((r) => r.subject));
367
+ if (subjects.size === 1) carrySubject = [...subjects][0];
368
+ const tag = `extracted:${sourceTag}`;
369
+ for (const row of rows) {
370
+ await appendFact(dir, {
371
+ subject: row.subject, predicate: row.predicate, object: row.object,
372
+ provenance: tag, quantifier: row.quantifier || "",
373
+ });
374
+ extracted.push({
375
+ subject: row.subject, predicate: row.predicate, object: row.object,
376
+ provenance: tag, quantifier: row.quantifier || "", sentence,
377
+ });
378
+ }
379
+ continue;
380
+ }
381
+ if (!optimistic) continue;
382
+ const candidates = optimisticTriples(cleaned, { lexicon: lex, nlp });
383
+ if (!candidates.length) continue;
384
+ optimisticSentences += 1;
385
+ const tag = `optimistic-extract:${sourceTag}`;
386
+ for (const t of candidates) {
387
+ await appendFact(dir, {
388
+ subject: t.subject, predicate: t.predicate, object: t.object, provenance: tag,
389
+ });
390
+ optimisticFacts.push({ ...t, provenance: tag, sentence });
391
+ }
392
+ }
393
+ }
394
+
395
+ const result = {
396
+ sentences: sentenceCount,
397
+ recognized: recognizedSentences,
398
+ extracted,
399
+ optimistic: optimisticFacts,
400
+ skipped: sentenceCount - recognizedSentences - optimisticSentences,
401
+ };
402
+ if (canonical) {
403
+ result.canonical = canonicalLines([...extracted, ...optimisticFacts], readFactRows(await loadMemory(dir)));
404
+ }
405
+ return result;
406
+ } finally {
407
+ if (ephemeral) await rm(dir, { recursive: true, force: true });
408
+ }
409
+ }
410
+
84
411
  /**
85
412
  * `argv` defaults to the real CLI args (process.argv.slice(2)) but takes an
86
413
  * explicit array too, so a test can drive this exactly like the CLI does
87
414
  * without touching global process.argv. Returns { sentences, recognized,
88
- * extracted } (the same counts the printed summary reports) so a test can
89
- * assert on structured results instead of scraping console output.
415
+ * extracted, optimistic, skipped } (the same counts the printed summary
416
+ * reports) so a test can assert on structured results instead of scraping
417
+ * console output.
90
418
  */
91
419
  export async function main(argv = process.argv.slice(2)) {
92
- const { file, repo, out } = parseArgs(argv);
420
+ const { file, repo, out, optimistic, canonical } = parseArgs(argv);
93
421
  if (!file) {
94
422
  console.error(USAGE);
95
423
  process.exitCode = 1;
96
- return { sentences: 0, recognized: 0, extracted: [] };
424
+ return { sentences: 0, recognized: 0, extracted: [], optimistic: [], skipped: 0 };
97
425
  }
98
426
 
99
427
  const filePath = resolve(process.cwd(), file);
100
428
  const text = await readFile(filePath, "utf8");
101
429
  const sourceTag = basename(filePath);
102
- const sentences = splitSentencesPreservingPaths(text);
103
-
104
- const ephemeral = !repo;
105
- const memoryDir = repo ? resolve(process.cwd(), repo) : await mkdtemp(join(tmpdir(), "tmct-extract-"));
106
- const config = loadConfig(process.env, memoryDir);
430
+ const memoryDir = repo ? resolve(process.cwd(), repo) : null;
107
431
 
108
- try {
109
- const extracted = [];
110
- let recognizedSentences = 0;
111
-
112
- for (const sentence of sentences) {
113
- const { recognized, rows } = await runSentence(sentence, { config, memoryDir });
114
- if (!recognized || !rows.length) continue; // unrecognized shape, or a Rule (not a Fact) — skip, honest
115
- recognizedSentences += 1;
116
- const tag = `extracted:${sourceTag}`;
117
- for (const row of rows) {
118
- // Additive: layers the audit tag onto the SAME (subject, predicate,
119
- // object) the recognizer just stored — appendFact unions provenance
120
- // by id, so this never duplicates or overwrites the recognizer's own
121
- // ace:chat:/teach:chat: entry, and re-running this over the same
122
- // file/fact is idempotent (the union simply dedupes the tag).
123
- await appendFact(memoryDir, {
124
- subject: row.subject, predicate: row.predicate, object: row.object,
125
- provenance: tag, quantifier: row.quantifier || "",
126
- });
127
- extracted.push({
128
- subject: row.subject, predicate: row.predicate, object: row.object,
129
- provenance: tag, quantifier: row.quantifier || "", sentence,
130
- });
131
- }
132
- }
432
+ const result = await ingestText(text, { memoryDir, sourceTag, optimistic, canonical });
433
+ const emitted = optimistic ? [...result.extracted, ...result.optimistic] : result.extracted;
133
434
 
134
- if (out) {
135
- const body = extracted.map((f) => JSON.stringify(f)).join("\n") + (extracted.length ? "\n" : "");
136
- await writeFile(resolve(process.cwd(), out), body, "utf8");
137
- } else if (ephemeral) {
138
- for (const f of extracted) console.log(JSON.stringify(f));
139
- }
140
-
141
- const skipped = sentences.length - recognizedSentences;
142
- console.error(
143
- `${sentences.length} sentence${sentences.length === 1 ? "" : "s"} found, `
144
- + `${recognizedSentences} recognized as fact${recognizedSentences === 1 ? "" : "s"} `
145
- + `(${extracted.length} fact row${extracted.length === 1 ? "" : "s"}), `
146
- + `${skipped} skipped — not a recognized declarative shape (an honest, expected gap; this is `
147
- + `an attempt, not full NLU).`,
148
- );
149
- if (repo) console.error(`facts written into ${memoryDir}'s tmct memory, tagged ${sourceTag}`);
150
- if (out) console.error(`facts written to ${out}`);
151
- return { sentences: sentences.length, recognized: recognizedSentences, extracted };
152
- } finally {
153
- if (ephemeral) await rm(memoryDir, { recursive: true, force: true });
435
+ if (out) {
436
+ const body = emitted.map((f) => JSON.stringify(f)).join("\n") + (emitted.length ? "\n" : "");
437
+ await writeFile(resolve(process.cwd(), out), body, "utf8");
438
+ } else if (canonical) {
439
+ for (const line of result.canonical) console.log(line);
440
+ } else if (!repo) {
441
+ for (const f of emitted) console.log(JSON.stringify(f));
154
442
  }
443
+
444
+ const { sentences, recognized } = result;
445
+ const optimisticCount = result.optimistic.length;
446
+ console.error(
447
+ `${sentences} sentence${sentences === 1 ? "" : "s"} found, `
448
+ + `${recognized} recognized as fact${recognized === 1 ? "" : "s"} `
449
+ + `(${result.extracted.length} fact row${result.extracted.length === 1 ? "" : "s"})`
450
+ + (optimistic ? `, ${optimisticCount} optimistic candidate${optimisticCount === 1 ? "" : "s"}` : "")
451
+ + `, ${result.skipped} skipped — not a recognized declarative shape (an honest, expected gap; this is `
452
+ + `an attempt, not full NLU).`,
453
+ );
454
+ if (repo) console.error(`facts written into ${memoryDir}'s tmct memory, tagged ${sourceTag}`);
455
+ if (out) console.error(`facts written to ${out}`);
456
+ return { sentences, recognized, extracted: result.extracted, optimistic: result.optimistic, skipped: result.skipped };
155
457
  }
@@ -113,7 +113,7 @@ export async function foldSessionLogs(repoDir, { sessionId = null } = {}) {
113
113
  processed.push(record.id);
114
114
  let answers = new Map();
115
115
  try {
116
- answers = parseSessionLog(await readFile(join(repoDir, LOG_DIR_REL, `session-${record.id}.log`), "utf8"));
116
+ answers = parseSessionLog(await readFile(join(repoDir, LOG_DIR_REL, `session-${record.id}.md`), "utf8"));
117
117
  } catch { /* transcript gone — fold question-only, honestly */ }
118
118
  const text = cleanSessionText(record, answers);
119
119
  if (text) {