@polycode-projects/the-mechanical-code-talker 2.10.2 → 2.10.5

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.
@@ -31,6 +31,17 @@ export function edgePhrase(kind) {
31
31
  return EDGE_PHRASE.get(String(kind || "")) || String(kind || "").replace(/([a-z0-9])([A-Z])/g, "$1 $2").toLowerCase();
32
32
  }
33
33
 
34
+ // Both packagers (build-electron-app.mjs, build-demo-site.mjs) place
35
+ // vendor/wink.js one level below the page they render, so the same loader
36
+ // script inlines into renderCodeExplorerHtml's `winkLoaderInline` slot for
37
+ // either channel.
38
+ export const VENDOR_WINK_LOADER_JS = `window.__WINK_LOADER__ = async function () {
39
+ var m = await import("./vendor/wink.js");
40
+ return { winkNLP: m.winkNLP, model: m.model };
41
+ };`;
42
+
43
+ export const DESKTOP_APP_URL = "https://gitlab.com/polycode-projects/the-mechanical-code-talker#the-code-explorer-desktop";
44
+
34
45
  const LEDGER_ROW_LIMIT_DEFAULT = 4000;
35
46
 
36
47
  /** Pure derivation over an entities payload (individuals + objectProperties).
@@ -247,9 +258,11 @@ const CLIENT_JS = String.raw`
247
258
  * computeCodeExplorerData's output. `bundleInline` inlines the dock engine
248
259
  * (for a single-file page / a data: URL); otherwise `bundleAvailable` links
249
260
  * `./code-explorer.bundle.js`. `winkLoaderInline` optionally inlines a wink
250
- * model loader as `window.__WINK_LOADER__`.
261
+ * model loader as `window.__WINK_LOADER__`. `showDesktopLink` adds a line
262
+ * pointing at the desktop app's README section — the desktop shell itself
263
+ * renders this page too and passes `false`, since it has nothing to point at.
251
264
  */
252
- export function renderCodeExplorerHtml(data, { bundleInline = "", bundleAvailable = false, winkLoaderInline = "", sourceName = "demo code graph" } = {}) {
265
+ export function renderCodeExplorerHtml(data, { bundleInline = "", bundleAvailable = false, winkLoaderInline = "", sourceName = "demo code graph", showDesktopLink = false } = {}) {
253
266
  const payloadJson = embedJson(data.payload);
254
267
  const dataJson = embedJson({ ledger: data.ledger, hints: data.hints, focus: data.focus, meta: data.meta });
255
268
  const title = escapeHtml(data.meta?.title || "code explorer");
@@ -304,6 +317,7 @@ ul.rows { list-style: none; margin: 0; padding: 0; }
304
317
  <header>
305
318
  <h1>tmct code explorer</h1>
306
319
  <span class="sub">source: <span id="source-name">${escapeHtml(sourceName)}</span></span>
320
+ ${showDesktopLink ? `<span class="sub">Also available as a <a href="${DESKTOP_APP_URL}">desktop app</a>.</span>` : ""}
307
321
  <div class="pickers">
308
322
  <button id="open-graph">Open graph…</button>
309
323
  <button id="open-repo">Open repo…</button>
@@ -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,19 +38,16 @@
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";
@@ -50,17 +58,24 @@ import { splitSentencesPreservingPaths } 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,272 @@ 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 readable predicate for canonical output: the local part of an rdfs:/ace:
206
+ * CURIE, otherwise the predicate verbatim. */
207
+ const readablePredicate = (predicate) => String(predicate).replace(/^[a-z]+:/i, "");
208
+
209
+ /** One canonical line per ingested fact, enriched with how each endpoint links
210
+ * back into the store: `subject predicate object` plus, for each endpoint that
211
+ * the store also mentions elsewhere, how many OTHER facts touch it. */
212
+ function canonicalLines(facts, storeRows) {
213
+ const degree = new Map();
214
+ for (const row of storeRows) {
215
+ degree.set(row.subject, (degree.get(row.subject) || 0) + 1);
216
+ if (row.object !== row.subject) degree.set(row.object, (degree.get(row.object) || 0) + 1);
217
+ }
218
+ return facts.map((f) => {
219
+ const links = [];
220
+ for (const end of [f.subject, f.object]) {
221
+ const others = (degree.get(end) || 0) - 1; // exclude this fact's own edge
222
+ if (others > 0) links.push(`${end} links to ${others} other fact${others === 1 ? "" : "s"}`);
223
+ }
224
+ const line = `${f.subject} ${readablePredicate(f.predicate)} ${f.object}`;
225
+ return links.length ? `${line} [${links.join("; ")}]` : line;
226
+ });
227
+ }
228
+
229
+ /**
230
+ * ingestText — the single ingest seam. Grounds `text` into facts and returns a
231
+ * structured result; the CLI, the browser and the tool layer all drive this.
232
+ *
233
+ * text the raw string to ground.
234
+ * options:
235
+ * memoryDir write grounded facts here (a real .tmct memory dir). Omit for
236
+ * an ephemeral scratch dir that is created and deleted inside
237
+ * this call, so nothing on disk is mutated — the returned facts
238
+ * are the only output.
239
+ * sourceTag the label the audit provenance carries (extracted:<tag> /
240
+ * optimistic-extract:<tag>). Default "text".
241
+ * optimistic also run the fuzzy tier over strict-skipped sentences.
242
+ * canonical include a `canonical` array: one enriched triple line per
243
+ * ingested fact.
244
+ * config a loaded config; derived from the write dir when absent.
245
+ * lexicon a loaded lexicon; the core vocabulary when absent.
246
+ *
247
+ * Returns { sentences, recognized, extracted, optimistic, skipped, canonical? }.
248
+ * recognized — strict-recognized sentence count.
249
+ * extracted — strict fact rows ({subject, predicate, object, provenance,
250
+ * quantifier, sentence}).
251
+ * optimistic — fuzzy candidate rows ({subject, predicate, object, provenance,
252
+ * sentence}); always [] unless options.optimistic.
253
+ * skipped — sentences neither tier grounded.
254
+ */
255
+ export async function ingestText(text, {
256
+ memoryDir = null, sourceTag = "text", optimistic = false,
257
+ canonical = false, config = null, lexicon = null,
258
+ } = {}) {
259
+ const sentences = splitSentencesPreservingPaths(String(text ?? ""));
260
+ const ephemeral = !memoryDir;
261
+ const dir = memoryDir || await mkdtemp(join(tmpdir(), "tmct-ingest-"));
262
+ // `dir` may be a backend handle (a sqlite store), not a path — only a real
263
+ // directory string can seed loadConfig's cwd; a handle-holding caller passes
264
+ // its own config.
265
+ const cfg = config || loadConfig(process.env, typeof dir === "string" ? dir : process.cwd());
266
+ const lex = lexicon || loadLexicon();
267
+ const nlp = optimistic ? winkInstance() : null;
268
+
269
+ const extracted = [];
270
+ const optimisticFacts = [];
271
+ let recognizedSentences = 0;
272
+ let optimisticSentences = 0;
273
+
274
+ try {
275
+ for (const sentence of sentences) {
276
+ const { recognized, rows } = await runSentence(sentence, { config: cfg, memoryDir: dir });
277
+ if (recognized && rows.length) {
278
+ recognizedSentences += 1;
279
+ const tag = `extracted:${sourceTag}`;
280
+ for (const row of rows) {
281
+ await appendFact(dir, {
282
+ subject: row.subject, predicate: row.predicate, object: row.object,
283
+ provenance: tag, quantifier: row.quantifier || "",
284
+ });
285
+ extracted.push({
286
+ subject: row.subject, predicate: row.predicate, object: row.object,
287
+ provenance: tag, quantifier: row.quantifier || "", sentence,
288
+ });
289
+ }
290
+ continue;
291
+ }
292
+ if (!optimistic) continue;
293
+ const candidates = optimisticTriples(sentence, { lexicon: lex, nlp });
294
+ if (!candidates.length) continue;
295
+ optimisticSentences += 1;
296
+ const tag = `optimistic-extract:${sourceTag}`;
297
+ for (const t of candidates) {
298
+ await appendFact(dir, {
299
+ subject: t.subject, predicate: t.predicate, object: t.object, provenance: tag,
300
+ });
301
+ optimisticFacts.push({ ...t, provenance: tag, sentence });
302
+ }
303
+ }
304
+
305
+ const result = {
306
+ sentences: sentences.length,
307
+ recognized: recognizedSentences,
308
+ extracted,
309
+ optimistic: optimisticFacts,
310
+ skipped: sentences.length - recognizedSentences - optimisticSentences,
311
+ };
312
+ if (canonical) {
313
+ result.canonical = canonicalLines([...extracted, ...optimisticFacts], readFactRows(await loadMemory(dir)));
314
+ }
315
+ return result;
316
+ } finally {
317
+ if (ephemeral) await rm(dir, { recursive: true, force: true });
318
+ }
319
+ }
320
+
84
321
  /**
85
322
  * `argv` defaults to the real CLI args (process.argv.slice(2)) but takes an
86
323
  * explicit array too, so a test can drive this exactly like the CLI does
87
324
  * 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.
325
+ * extracted, optimistic, skipped } (the same counts the printed summary
326
+ * reports) so a test can assert on structured results instead of scraping
327
+ * console output.
90
328
  */
91
329
  export async function main(argv = process.argv.slice(2)) {
92
- const { file, repo, out } = parseArgs(argv);
330
+ const { file, repo, out, optimistic, canonical } = parseArgs(argv);
93
331
  if (!file) {
94
332
  console.error(USAGE);
95
333
  process.exitCode = 1;
96
- return { sentences: 0, recognized: 0, extracted: [] };
334
+ return { sentences: 0, recognized: 0, extracted: [], optimistic: [], skipped: 0 };
97
335
  }
98
336
 
99
337
  const filePath = resolve(process.cwd(), file);
100
338
  const text = await readFile(filePath, "utf8");
101
339
  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);
340
+ const memoryDir = repo ? resolve(process.cwd(), repo) : null;
107
341
 
108
- try {
109
- const extracted = [];
110
- let recognizedSentences = 0;
342
+ const result = await ingestText(text, { memoryDir, sourceTag, optimistic, canonical });
343
+ const emitted = optimistic ? [...result.extracted, ...result.optimistic] : result.extracted;
111
344
 
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
- }
133
-
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 });
345
+ if (out) {
346
+ const body = emitted.map((f) => JSON.stringify(f)).join("\n") + (emitted.length ? "\n" : "");
347
+ await writeFile(resolve(process.cwd(), out), body, "utf8");
348
+ } else if (canonical) {
349
+ for (const line of result.canonical) console.log(line);
350
+ } else if (!repo) {
351
+ for (const f of emitted) console.log(JSON.stringify(f));
154
352
  }
353
+
354
+ const { sentences, recognized } = result;
355
+ const optimisticCount = result.optimistic.length;
356
+ console.error(
357
+ `${sentences} sentence${sentences === 1 ? "" : "s"} found, `
358
+ + `${recognized} recognized as fact${recognized === 1 ? "" : "s"} `
359
+ + `(${result.extracted.length} fact row${result.extracted.length === 1 ? "" : "s"})`
360
+ + (optimistic ? `, ${optimisticCount} optimistic candidate${optimisticCount === 1 ? "" : "s"}` : "")
361
+ + `, ${result.skipped} skipped — not a recognized declarative shape (an honest, expected gap; this is `
362
+ + `an attempt, not full NLU).`,
363
+ );
364
+ if (repo) console.error(`facts written into ${memoryDir}'s tmct memory, tagged ${sourceTag}`);
365
+ if (out) console.error(`facts written to ${out}`);
366
+ return { sentences, recognized, extracted: result.extracted, optimistic: result.optimistic, skipped: result.skipped };
155
367
  }
@@ -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) {