@polycode-projects/the-mechanical-code-talker 2.10.3 → 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.
@@ -1,7 +1,7 @@
1
1
  // sessions.mjs — chat sessions as first-class temporal graph data, like commits.
2
2
  //
3
3
  // A `tmct chat` session leaves two artifacts under the target repo:
4
- // .tmct/session-<uuidv7>.log — the human-readable transcript (chat.mjs)
4
+ // .tmct/session-<uuidv7>.md — the human-readable transcript (chat-session.mjs, session-log-format.mjs)
5
5
  // .tmct/sessions/session-<uuidv7>.jsonl — the STRUCTURED sidecar this module owns:
6
6
  // {"type":"session", id, started, repo, tmctVersion} (header line)
7
7
  // {"type":"turn", ts, query, via, resolvedIds, answeredIds, miss} (one per turn, flushed)
@@ -207,7 +207,7 @@ async function recordSessionMemory(graphFile, record, repoDirOverride = null) {
207
207
 
208
208
  let answers = new Map();
209
209
  try {
210
- answers = parseSessionLog(await readFile(join(repoDir, ".tmct", `session-${record.id}.log`), "utf8"));
210
+ answers = parseSessionLog(await readFile(join(repoDir, ".tmct", `session-${record.id}.md`), "utf8"));
211
211
  } catch { /* no transcript (direct API callers) — record the requests alone */ }
212
212
 
213
213
  const utterances = [];
@@ -294,38 +294,72 @@ export function parseSessionJsonl(text) {
294
294
  return { id: String(header.id), started: String(header.started || ""), ended, turns };
295
295
  }
296
296
 
297
- // A transcript turn opens with an ISO-8601 ms timestamp line followed by the
298
- // echoed "> <query>" line (chat.mjs's logLines shape).
299
- const LOG_TS_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
297
+ // A transcript turn heading (session-log-format.mjs's sessionLogTurnMarkdown):
298
+ // "### HH:MM:SS.mmm · turn N" time of day only, no calendar date (the date
299
+ // lives once, in the header byline). The header byline itself carries that
300
+ // date: "*YYYY-MM-DD · started HH:MM:SS.mmm[ · repo ...]*".
301
+ const MD_TURN_HEADING_RE = /^### (\d{2}:\d{2}:\d{2}\.\d{3}) · turn \d+$/;
302
+ const MD_BYLINE_DATE_RE = /^\*(\d{4}-\d{2}-\d{2}) ·/;
300
303
 
301
304
  export { turnKey };
302
305
 
306
+ /** The calendar date one UTC day after `dateStr` ("YYYY-MM-DD") — used to
307
+ * carry the transcript's running date forward across a midnight rollover
308
+ * (see parseSessionLog below). */
309
+ function nextUtcDate(dateStr) {
310
+ const d = new Date(`${dateStr}T00:00:00Z`);
311
+ d.setUTCDate(d.getUTCDate() + 1);
312
+ return d.toISOString().slice(0, 10);
313
+ }
314
+
303
315
  /**
304
- * Parse a human-readable session transcript (.tmct/session-<id>.log) into a
316
+ * Parse a human-readable session transcript (.tmct/session-<id>.md) into a
305
317
  * Map of turnKey(ts, query) → answer text. The transcript is the ONLY session
306
318
  * artifact that carries the answer PROSE (the structured sidecar records ids,
307
- * not text),
319
+ * not text).
320
+ *
321
+ * Each turn heading carries only a TIME of day (see MD_TURN_HEADING_RE above)
322
+ * — the calendar date is read once from the header byline and carried
323
+ * forward turn to turn, advancing a day whenever a heading's time reads
324
+ * EARLIER than the turn before it (a midnight rollover on a long session).
325
+ * The reconstructed `date + "T" + time + "Z"` is then byte-identical to the
326
+ * full ISO timestamp session-log-format.mjs's writer sliced the time out of,
327
+ * so it matches the sidecar's own `record.ts` under turnKey exactly.
308
328
  */
309
329
  export function parseSessionLog(text) {
310
330
  const lines = String(text ?? "").split("\n");
311
331
  const answers = new Map();
312
- let open = null; // { ts, query, answerLines }
313
- const close = () => {
314
- if (!open) return;
315
- while (open.answerLines.length && !open.answerLines.at(-1).trim()) open.answerLines.pop();
316
- answers.set(turnKey(open.ts, open.query), open.answerLines.join("\n"));
317
- open = null;
318
- };
319
- for (let i = 0; i < lines.length; i += 1) {
320
- if (LOG_TS_RE.test(lines[i]) && lines[i + 1]?.startsWith("> ")) {
321
- close();
322
- open = { ts: lines[i], query: lines[i + 1].slice(2), answerLines: [] };
323
- i += 1;
324
- } else if (open) {
325
- open.answerLines.push(lines[i]);
332
+ let date = null;
333
+ let lastTime = null;
334
+ let i = 0;
335
+ while (i < lines.length) {
336
+ if (date === null) {
337
+ const dateMatch = lines[i].match(MD_BYLINE_DATE_RE);
338
+ if (dateMatch) date = dateMatch[1];
339
+ }
340
+ const heading = date && lines[i].match(MD_TURN_HEADING_RE);
341
+ if (heading) {
342
+ const time = heading[1];
343
+ if (lastTime !== null && time < lastTime) date = nextUtcDate(date);
344
+ lastTime = time;
345
+ let j = i + 1;
346
+ if (lines[j] === "") j += 1;
347
+ const queryLine = lines[j];
348
+ if (queryLine?.startsWith("> ")) {
349
+ j += 1;
350
+ if (lines[j] === "") j += 1;
351
+ if (lines[j] === "```text") {
352
+ j += 1;
353
+ const answerLines = [];
354
+ while (j < lines.length && lines[j] !== "```") { answerLines.push(lines[j]); j += 1; }
355
+ answers.set(turnKey(`${date}T${time}Z`, queryLine.slice(2)), answerLines.join("\n"));
356
+ i = j + 1;
357
+ continue;
358
+ }
359
+ }
326
360
  }
361
+ i += 1;
327
362
  }
328
- close();
329
363
  return answers;
330
364
  }
331
365
 
@@ -18,7 +18,7 @@
18
18
  import {
19
19
  DIRECTION_DELTA, WORLD_NAME, cellId, parseCellId, inBounds, chebyshevDistance, oneStepDirectionBetween,
20
20
  } from "../domain/spider-fly-world.mjs";
21
- import { foldSpiderFlyState, runSpiderFlyTick, startSpiderFlyGame } from "./spider-fly.mjs";
21
+ import { foldSpiderFlyState, runSpiderFlyTick, startSpiderFlyGame, beliefSnapshotFor } from "./spider-fly.mjs";
22
22
  import { worldProvenanceTag } from "../domain/worlds-pack.mjs";
23
23
  import { getWorldsPackProvider } from "../adapters/corpus/worlds-pack.mjs";
24
24
  import { appendFacts, appendRule, loadMemory, readFactRows } from "../adapters/memory/core.mjs";
@@ -66,6 +66,12 @@ const SPIDER_FLY_TOLD_RE = new RegExp(
66
66
  "i",
67
67
  );
68
68
 
69
+ // The observable-facts read: "what does the fly see?" / "what can the
70
+ // spider see?" — a closed vocabulary shape, styled after the other
71
+ // game-lane regexes above, with the same optional numbered suffix
72
+ // (SPIDER_FLY_ADDRESS_LEAD_RE's own "-<n>") for a board past one of a kind.
73
+ const SPIDER_FLY_SEE_RE = /^what (?:does|can) the (spider|fly)(?:-(\d+))?\s+see[.!?\s]*$/i;
74
+
69
75
  const WORLD_OPENING_FALLBACK =
70
76
  "a spider waits in its web; a fly drifts in from the edge of the board. Neither is yours to move — watch, or address one by name in chat.";
71
77
 
@@ -342,6 +348,48 @@ async function runTickAndRender({ planHolder, memoryDir, cache, toldFacts = [],
342
348
  };
343
349
  }
344
350
 
351
+ // ---- the observable-facts read: "what does the fly see?" -----------------
352
+
353
+ /** One `[id, cellId | null]` belief entry as a sentence: `"spider-1 is at
354
+ * cell-3-4."` when observed/told, `"fly-2 has not been observed."`
355
+ * otherwise — the same wording spider-fly-viz.mjs's own click-expand panel
356
+ * (observedFactsHtml) renders, so the chat phrasing and the browser panel
357
+ * never disagree about what an agent can see. */
358
+ function observedFactSentence(id, believedCell) {
359
+ return believedCell ? `${id} is at ${believedCell}.` : `${id} has not been observed.`;
360
+ }
361
+
362
+ /** "what does the fly see?" / "what does the spider see?" rendered as plain
363
+ * text: the same beliefSnapshotFor read spider-fly.mjs's own tick loop and
364
+ * the browser panel already use, over the CURRENT board state — read-only,
365
+ * no tick runs, nothing is written. Candidates are every OTHER live agent
366
+ * of either kind; toldFacts is empty (a told position only ever arrives
367
+ * fresh alongside a tick — see runToldFactTurn — so there is none standing
368
+ * between ticks to read back here). */
369
+ async function spiderFlyBeliefAnswer(match, { memoryDir, gameConfig = DEFAULT_GAME_CONFIG }) {
370
+ const kind = match[1].toLowerCase();
371
+ const num = match[2];
372
+ const rows = readFactRows(await loadMemory(memoryDir));
373
+ const state = foldSpiderFlyState(rows);
374
+ const observerId = resolveAgentId(kind, num, state);
375
+ if (!observerId) return noSuchAgentAnswer(kind, "addressee");
376
+ const observerCell = parseCellId(state.placements.get(observerId).cell);
377
+ const candidateIds = [...liveIdsOfKind("spider", state), ...liveIdsOfKind("fly", state)];
378
+ const visionRadius = kind === "spider"
379
+ ? gameConfig?.spiderFly?.spiderVisionRadius
380
+ : gameConfig?.spiderFly?.flyVisionRadius;
381
+ const belief = beliefSnapshotFor(observerId, observerCell, candidateIds, state, { visionRadius });
382
+ const entries = Object.entries(belief);
383
+ const text = entries.length
384
+ ? `${observerId} sees: ${entries.map(([id, cell]) => observedFactSentence(id, cell)).join(" ")}`
385
+ : `${observerId} is alone on the board — nothing else to see.`;
386
+ return {
387
+ text,
388
+ lane: "game-inform",
389
+ note: `SPIDER-FLY — belief snapshot rendered for ${observerId} via beliefSnapshotFor (read-only, no tick run)`,
390
+ };
391
+ }
392
+
345
393
  /** The addressed teach-frame turn: resolve the addressee and the belief
346
394
  * subject, resolve the told cell, and run ONE tick with that told-fact fed
347
395
  * in. Told-facts are NOT persisted on the session slot across turns — each
@@ -526,6 +574,11 @@ export async function spiderFlyTurn(line, { planHolder, memoryDir, env, cache =
526
574
  return runToldFactTurn(told, { planHolder, memoryDir, cache, gameConfig });
527
575
  }
528
576
 
577
+ const seeMatch = String(line).trim().match(SPIDER_FLY_SEE_RE);
578
+ if (seeMatch) {
579
+ return spiderFlyBeliefAnswer(seeMatch, { memoryDir, gameConfig });
580
+ }
581
+
529
582
  if (SPIDER_FLY_TICK_RE.test(line)) {
530
583
  return runTickAndRender({ planHolder, memoryDir, cache, toldFacts: [], gameConfig });
531
584
  }
@@ -346,10 +346,10 @@ ${THEME_TOKENS_CSS}
346
346
  negative margin equal to this padding, so it reads as one welded unit,
347
347
  not a label floating inside a box. */
348
348
  .hud, .chat, .tuning { background: var(--chrome-face); border: 1px solid var(--chrome-edge-lo); box-shadow: var(--chrome-shadow-raised); border-radius: 2px; padding: .6rem .75rem; }
349
- /* the tuning strip matches the board's own width (not the wider stage-left
350
- column it sits in) so the two line up as one visual stack, the same way
351
- the board-frame below it is already centered at a fixed width. */
352
- .tuning { width: ${BOARD_PX}px; max-width: 100%; margin: 0 auto; box-sizing: border-box; }
349
+ /* Both the controls strip above the board and the tuning strip below it
350
+ match the board's own width (not the wider stage-left column they sit
351
+ in), so all three line up as one visual stack. */
352
+ .tuning, .controls-panel { width: ${BOARD_PX}px; max-width: 100%; margin: 0 auto; box-sizing: border-box; }
353
353
  .hud h2, .chat h2, .tuning h2 {
354
354
  font-family: ${MONO_STACK}; font-size: .68rem; letter-spacing: .1em; text-transform: uppercase; font-weight: 600;
355
355
  margin: -.6rem -.75rem .5rem; padding: .42rem .75rem;
@@ -447,7 +447,7 @@ ${THEME_TOKENS_CSS}
447
447
  .tuning-col.spider input[type="range"]::-moz-range-thumb { border-color: var(--taught); }
448
448
  .tuning-col.fly input[type="range"]::-moz-range-thumb { border-color: var(--fly); }
449
449
  .tuning-col input:disabled { opacity: .45; }
450
- .controls-row { display: flex; align-items: center; gap: .6rem; margin-top: 1rem; flex-wrap: wrap; }
450
+ .controls-row { display: flex; align-items: center; gap: .6rem; flex-wrap: wrap; }
451
451
  /* Chunky keycaps: raised by default, pressed-in on :active — the one place
452
452
  this redesign uses interaction (not animation) to sell "physical
453
453
  control panel", and it costs nothing under reduced-motion since neither
@@ -461,7 +461,7 @@ ${THEME_TOKENS_CSS}
461
461
  page is remembered by. */
462
462
  .controls-row .turn { margin-left: auto; font-family: ${MONO_STACK}; font-size: .82rem; letter-spacing: .05em; text-transform: uppercase; font-variant-numeric: tabular-nums; color: var(--chrome-well-ink); background: var(--chrome-well); border: 1px solid var(--chrome-brass); box-shadow: var(--chrome-shadow-inset); border-radius: 2px; padding: .3rem .7rem; }
463
463
  .status { font-family: ${MONO_STACK}; font-size: .74rem; color: var(--muted); margin-top: .5rem; padding-left: .5rem; border-left: 2px solid var(--chrome-brass); }
464
- body.preview .side, body.preview .controls-row, body.preview .status, body.preview .tuning { display: none; }
464
+ body.preview .side, body.preview .controls-panel, body.preview .tuning { display: none; }
465
465
  body.preview main { padding: 0; max-width: none; }
466
466
  body.preview .stage, body.preview .stage-left { display: block; }
467
467
  body.preview .board-frame { margin: 0; }
@@ -474,6 +474,21 @@ ${THEME_TOKENS_CSS}
474
474
  <h1>Multiple competing planning agents</h1>
475
475
  <div class="stage">
476
476
  <div class="stage-left">
477
+ <div class="controls-panel" id="controlsPanel" aria-label="game controls">
478
+ <div class="controls-row">
479
+ <button id="resetBtn" type="button" disabled>reset</button>
480
+ <button id="playBtn" type="button" disabled>&#9654; play</button>
481
+ <button id="stepBtn" type="button" disabled>step</button>
482
+ <span class="turn mono" id="turnLabel">turn: 0</span>
483
+ </div>
484
+ <div class="status" id="status">loading the engine&hellip;</div>
485
+ </div>
486
+ <div class="board-frame" id="boardFrame">
487
+ <canvas id="board" width="${BOARD_PX}" height="${BOARD_PX}" aria-label="the 10x10 board"></canvas>
488
+ <canvas id="pov" width="${BOARD_PX}" height="${BOARD_PX}" aria-hidden="true"></canvas>
489
+ <div class="sprite-layer" id="spriteLayer"></div>
490
+ <div class="thread-tip" id="threadTip"></div>
491
+ </div>
477
492
  <div class="tuning" id="tuning">
478
493
  <h2>live tuning &mdash; mass loss, spawn rate, vision, per class</h2>
479
494
  <div class="tuning-grid">
@@ -497,12 +512,6 @@ ${THEME_TOKENS_CSS}
497
512
  </div>
498
513
  </div>
499
514
  </div>
500
- <div class="board-frame" id="boardFrame">
501
- <canvas id="board" width="${BOARD_PX}" height="${BOARD_PX}" aria-label="the 10x10 board"></canvas>
502
- <canvas id="pov" width="${BOARD_PX}" height="${BOARD_PX}" aria-hidden="true"></canvas>
503
- <div class="sprite-layer" id="spriteLayer"></div>
504
- <div class="thread-tip" id="threadTip"></div>
505
- </div>
506
515
  </div>
507
516
  <aside class="side" aria-label="Chat and agents">
508
517
  <div class="chat">
@@ -528,13 +537,6 @@ ${THEME_TOKENS_CSS}
528
537
  </div>
529
538
  </aside>
530
539
  </div>
531
- <div class="controls-row">
532
- <button id="resetBtn" type="button" disabled>reset</button>
533
- <button id="playBtn" type="button" disabled>&#9654; play</button>
534
- <button id="stepBtn" type="button" disabled>step</button>
535
- <span class="turn mono" id="turnLabel">turn: 0</span>
536
- </div>
537
- <div class="status" id="status">loading the engine&hellip;</div>
538
540
  </main>
539
541
  <script>
540
542
  const SPIDERFLY = ${gridData};
@@ -19,6 +19,7 @@
19
19
  import { runTurn, vocabExampleHint } from "../../services/chat.mjs";
20
20
  import { createInMemoryStore, normFactTerm, loadMemory, readFactRows } from "../../adapters/memory/core.mjs";
21
21
  import { serializeFactsJsonl } from "../../adapters/memory/export-jsonl.mjs";
22
+ import { splitSentencesPreservingPaths } from "../../services/sentences.mjs";
22
23
  import { provenanceTagToSource } from "../../domain/memory/trust.mjs";
23
24
  import { parseEntities } from "../../domain/codegraph.mjs";
24
25
  import { loadLexicon } from "../../domain/grammar/lexicon.mjs";
@@ -68,15 +69,18 @@ export function createChatSession({ seedPayload = null, vocabSeeded = false, liv
68
69
  let focus = null;
69
70
  let last = null;
70
71
  let planState = null;
71
- let liveReferenceOn = Boolean(liveReference);
72
+ // Tri-state, like the TUI: false (off), true (rescue on a miss), or
73
+ // "supplement" (also append a cited read-out under every grounded answer).
74
+ const normLive = (v) => (v === "supplement" ? "supplement" : Boolean(v));
75
+ let liveReferenceOn = normLive(liveReference);
72
76
 
73
77
  return {
74
78
  memoryDir,
75
79
  sessionId,
76
80
  get liveReference() { return liveReferenceOn; },
77
- /** The page's toggle seam: flip the live Wikipedia supplement for every
78
- * later turn (the `/wiki on|off` command flips the same state). */
79
- setLiveReference(v) { liveReferenceOn = Boolean(v); },
81
+ /** The page's toggle seam: set the live Wikipedia mode for every later turn
82
+ * (the `/wiki on|off|supplement` command sets the same state). */
83
+ setLiveReference(v) { liveReferenceOn = normLive(v); },
80
84
 
81
85
  /** One dispatched turn. A throwing runTurn must never kill the session —
82
86
  * the page has no other chance to show this turn's answer. */
@@ -95,7 +99,7 @@ export function createChatSession({ seedPayload = null, vocabSeeded = false, liv
95
99
  focus = result.focus;
96
100
  last = result.last;
97
101
  if ("planState" in result) planState = result.planState;
98
- if (typeof result.liveReference === "boolean") liveReferenceOn = result.liveReference;
102
+ if (typeof result.liveReference === "boolean" || result.liveReference === "supplement") liveReferenceOn = result.liveReference;
99
103
  return { answer: result.answer, end: Boolean(result.end), record: result.record ?? null, plan: result.plan ?? null };
100
104
  },
101
105
  };
@@ -158,4 +162,4 @@ export async function exportFactsJsonl(memoryDir) {
158
162
  return serializeFactsJsonl(await loadMemory(memoryDir));
159
163
  }
160
164
 
161
- globalThis.tmctChat = { createChatSession, registerWinkModel, registerReferencePackProvider, registerLiveReferenceProvider, normFactTerm, vocabExampleHint, memoryStats, openPersistedStore, exportFactsJsonl };
165
+ globalThis.tmctChat = { createChatSession, registerWinkModel, registerReferencePackProvider, registerLiveReferenceProvider, normFactTerm, vocabExampleHint, memoryStats, openPersistedStore, exportFactsJsonl, splitSentences: splitSentencesPreservingPaths };
@@ -0,0 +1,126 @@
1
+ // ingest-browser-entry.mjs — the esbuild entry for the ingest page's engine
2
+ // (public/ingest-browser.bundle.js, built by scripts/build-ingest-bundle.mjs),
3
+ // mirroring ledger-browser-entry.mjs's own createLedgerSession shape.
4
+ //
5
+ // The ingest page turns plain pasted/dropped text into stored facts by
6
+ // running each sentence through the SAME deterministic recognizer the chat
7
+ // teach lane already has (runTurn, src/services/chat.mjs) — no new NLU, no
8
+ // LLM, no guessing, exactly as the `tmct extract` CLI does it
9
+ // (src/services/extract-facts.mjs). A sentence the recognizer turns into a
10
+ // stored assertion is kept; every other sentence is honestly skipped.
11
+ //
12
+ // The recognizer is reached through ONE seam — `groundTextToFacts` — so a
13
+ // wider ingest tier (an optimistic fuzzy-match pass, a canonical/graph-linked
14
+ // output) slots in behind this single function without the page changing.
15
+ // Today the seam is the strict recognizer alone.
16
+ //
17
+ // Gitignored, Pages-demo-site-only output (scripts/build-demo-site.mjs builds
18
+ // it fresh on every deploy, never committed) — the same posture
19
+ // ledger-browser-entry.mjs documents for its own bundle. It carries the full
20
+ // runTurn engine, the same weight class as the chat/ledger bundles, and is
21
+ // never published.
22
+ import { runTurn, vocabExampleHint } from "../../services/chat.mjs";
23
+ import { createInMemoryStore, normFactTerm, loadMemory, readFactRows } from "../../adapters/memory/core.mjs";
24
+ import { serializeFactsJsonl } from "../../adapters/memory/export-jsonl.mjs";
25
+ import { splitSentencesPreservingPaths } from "../../services/sentences.mjs";
26
+ import { touchedFactRows } from "../../domain/memory/touched-facts.mjs";
27
+ import { parseEntities } from "../../domain/codegraph.mjs";
28
+ import { loadLexicon } from "../../domain/grammar/lexicon.mjs";
29
+ import { registerWinkModel } from "../../adapters/wink-model.mjs";
30
+
31
+ /**
32
+ * The single recognizer seam. Splits `text` into sentences (wink's own
33
+ * boundary detection, never a regex), runs each through runTurn against
34
+ * `memoryDir`, and keeps a sentence only when runTurn's own record calls it a
35
+ * stored assertion (`record.via === "assert"`, `record.miss` false) that
36
+ * actually touched a Fact row — the identical keep/skip rule
37
+ * src/services/extract-facts.mjs's own runSentence holds.
38
+ *
39
+ * `onFact(fact)` (optional) is awaited after each grounded row so the page can
40
+ * render the canonical facts LIVE as they land, one at a time.
41
+ *
42
+ * Returns { sentences, recognized, skipped, facts } — `facts` an array of
43
+ * { subject, predicate, object, provenance, quantifier, sentence } in the same
44
+ * canonical shape `tmct extract` and the JSONL exporter emit.
45
+ *
46
+ * A wider ingest tier plugs in HERE, behind this one function: the page calls
47
+ * it and nothing else.
48
+ */
49
+ export async function groundTextToFacts(text, { memoryDir, sessionId, graph, lexicon, vocabHint, onFact = null } = {}) {
50
+ const sentences = splitSentencesPreservingPaths(text);
51
+ const facts = [];
52
+ let focus = null;
53
+ let last = null;
54
+ let planState = null;
55
+ let recognized = 0;
56
+
57
+ for (const sentence of sentences) {
58
+ const before = readFactRows(await loadMemory(memoryDir));
59
+ let record;
60
+ try {
61
+ const result = await runTurn(sentence, {
62
+ config: null, source: null, graph, focus, last, memoryDir, sessionId,
63
+ env: {}, lexicon, vocabHint, planState,
64
+ });
65
+ focus = result.focus;
66
+ last = result.last;
67
+ if ("planState" in result) planState = result.planState;
68
+ record = result.record;
69
+ } catch {
70
+ continue; // a throwing sentence is a skip, never a page-killer
71
+ }
72
+ if (record?.via !== "assert" || record?.miss) continue;
73
+ const rows = touchedFactRows(before, readFactRows(await loadMemory(memoryDir)));
74
+ if (!rows.length) continue; // a Rule teach touches no Fact row — honest skip
75
+ recognized += 1;
76
+ for (const row of rows) {
77
+ const fact = {
78
+ subject: row.subject, predicate: row.predicate, object: row.object,
79
+ provenance: row.provenance || "", quantifier: row.quantifier || "", sentence,
80
+ };
81
+ facts.push(fact);
82
+ if (onFact) await onFact(fact);
83
+ }
84
+ }
85
+
86
+ return { sentences: sentences.length, recognized, skipped: sentences.length - recognized, facts };
87
+ }
88
+
89
+ /**
90
+ * A browser ingest session over the real turn engine — a fresh in-memory
91
+ * Backend-B store, so facts grounded through the page extend one real graph
92
+ * the page can then export. `seedPayload` (optional) pre-loads a graph the
93
+ * recognizer can recall and link against.
94
+ *
95
+ * Returns { memoryDir, sessionId, ingest }. `ingest(text, { onFact })` is the
96
+ * one call the page makes; it drives groundTextToFacts against this session's
97
+ * store and returns its { sentences, recognized, skipped, facts } summary.
98
+ */
99
+ export function createIngestSession({ seedPayload = null, vocabSeeded = false } = {}) {
100
+ const memoryDir = createInMemoryStore();
101
+ if (seedPayload) memoryDir.payload = { ...memoryDir.payload, ...seedPayload };
102
+
103
+ const graph = parseEntities({ individuals: [], objectProperties: [] });
104
+ const lexicon = loadLexicon();
105
+ const vocabHint = vocabExampleHint(vocabSeeded);
106
+ const sessionId = globalThis.crypto?.randomUUID?.() ?? String(Date.now());
107
+
108
+ return {
109
+ memoryDir,
110
+ sessionId,
111
+ ingest(text, { onFact = null } = {}) {
112
+ return groundTextToFacts(text, { memoryDir, sessionId, graph, lexicon, vocabHint, onFact });
113
+ },
114
+ };
115
+ }
116
+
117
+ /**
118
+ * The session's whole triple store as JSONL — the same
119
+ * { subject, predicate, object, provenance } shape `tmct extract` and
120
+ * `tmct memory --export` emit, offered to the page as the canonical download.
121
+ */
122
+ export async function exportFactsJsonl(memoryDir) {
123
+ return serializeFactsJsonl(await loadMemory(memoryDir));
124
+ }
125
+
126
+ globalThis.tmctIngest = { createIngestSession, groundTextToFacts, exportFactsJsonl, registerWinkModel, normFactTerm };
@@ -17,7 +17,9 @@
17
17
  // never published — only the hosted demo site's public/ledger.html links to
18
18
  // it, as an optional sibling script the page degrades honestly without.
19
19
  import { runTurn, vocabExampleHint } from "../../services/chat.mjs";
20
- import { createInMemoryStore, normFactTerm } from "../../adapters/memory/core.mjs";
20
+ import { createInMemoryStore, normFactTerm, loadMemory } from "../../adapters/memory/core.mjs";
21
+ import { serializeFactsJsonl } from "../../adapters/memory/export-jsonl.mjs";
22
+ import { splitSentencesPreservingPaths } from "../../services/sentences.mjs";
21
23
  import { parseEntities } from "../../domain/codegraph.mjs";
22
24
  import { loadLexicon } from "../../domain/grammar/lexicon.mjs";
23
25
  import { registerWinkModel } from "../../adapters/wink-model.mjs";
@@ -78,8 +80,19 @@ export function createLedgerSession({ seedPayload = null, vocabSeeded = false }
78
80
  };
79
81
  }
80
82
 
83
+ /** The session's whole triple store as JSONL — the same
84
+ * { subject, predicate, object, provenance } shape `tmct extract` and
85
+ * `tmct memory --export` emit. The ledger dock's "export facts" control reads
86
+ * this and offers it as a download. */
87
+ export async function exportFactsJsonl(memoryDir) {
88
+ return serializeFactsJsonl(await loadMemory(memoryDir));
89
+ }
90
+
81
91
  // Re-exported so ledger-viz.mjs's own inline script never has to duplicate
82
92
  // the derivation logic that builds rows/terms/edges/contradictions/
83
93
  // worthALook/stats from a payload — the same posture chat-browser-entry.mjs
84
94
  // takes re-exporting registerWinkModel for its own page's CDN wink load.
85
- globalThis.tmctLedger = { createLedgerSession, computeLedgerDataFromPayload, normFactTerm, registerWinkModel };
95
+ // splitSentences + exportFactsJsonl carry the dock's paste-and-drop ingest and
96
+ // its JSONL export across the bundle boundary, the same one-serializer posture
97
+ // chat-browser-entry.mjs holds for its own page.
98
+ globalThis.tmctLedger = { createLedgerSession, computeLedgerDataFromPayload, normFactTerm, registerWinkModel, splitSentences: splitSentencesPreservingPaths, exportFactsJsonl };