@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.
@@ -0,0 +1,388 @@
1
+ // ingest-viz.mjs — ingest.html, the "bring your own text" page: a
2
+ // self-contained document shaped exactly like chat-page-viz.mjs's own
3
+ // page-builder — one inlined <style> importing viz-theme.mjs's shared tokens,
4
+ // behaviour as an inlined IIFE — running the ingest engine
5
+ // (ingest-browser.bundle.js's globalThis.tmctIngest) by same-origin relative
6
+ // paths.
7
+ //
8
+ // The page's own chrome is a two-pane translate-tool layout: mode pills
9
+ // (Text | Document) across the top, a roomy free-text area on the left that
10
+ // takes paste and drag-and-drop plus a browse-for-file control, and a
11
+ // soft-panel canonical facts pane on the right that fills LIVE as the
12
+ // recognizer grounds each sentence. One action row under the panes: ingest,
13
+ // download canonical, clear.
14
+ //
15
+ // Behind the panes is the ONE recognizer seam the browser bundle exposes —
16
+ // session.ingest(text) — so a wider ingest tier plugs in without this page
17
+ // changing. Grounded facts write to the page's own in-memory store; the
18
+ // download serializes that store as canonical JSONL.
19
+ //
20
+ // renderIngestHtml() is pure: no I/O, deterministic output for identical
21
+ // input. scripts/build-demo-site.mjs calls it directly and writes the result
22
+ // to public/ingest.html, after ingest-browser.bundle.js already exists.
23
+ import { THEME_TOKENS_CSS, SERIF_STACK, MONO_STACK, escapeHtml } from "./viz-theme.mjs";
24
+
25
+ const DEFAULT_TITLE = "the-mechanical-code-talker — ingest";
26
+
27
+ /** One grounded fact as a canonical triple line — subject, predicate, object,
28
+ * each in its own cell so the pane can align them. Pure and
29
+ * `.toString()`-splice safe (no outer refs): the inline script splices this
30
+ * in and calls it per row. */
31
+ export function factTripleParts(fact) {
32
+ return {
33
+ subject: String((fact && fact.subject) || ""),
34
+ predicate: String((fact && fact.predicate) || ""),
35
+ object: String((fact && fact.object) || ""),
36
+ provenance: String((fact && fact.provenance) || ""),
37
+ };
38
+ }
39
+
40
+ /** The self-contained ingest page. Pure — the same output for the same
41
+ * `title` every time; every piece of state (the session, each grounded fact)
42
+ * is computed live in the browser once the sibling ingest bundle loads. */
43
+ export function renderIngestHtml({ title = DEFAULT_TITLE } = {}) {
44
+ return `<!doctype html>
45
+ <html lang="en">
46
+ <head>
47
+ <meta charset="utf-8">
48
+ <meta name="viewport" content="width=device-width, initial-scale=1">
49
+ <title>${escapeHtml(title)}</title>
50
+ <!--
51
+ The wink lemma/POS tier loads from ./vendor/wink.js — the site's own shared
52
+ first-party bundle (built by scripts/build-wink-vendor.mjs), one cached copy
53
+ for every page, no CDN. The ingest engine needs wink to split sentences and
54
+ to parse each teach frame; a failed load degrades to the honest "nothing
55
+ recognized" answer, never an error.
56
+ -->
57
+ <style>
58
+ ${THEME_TOKENS_CSS}
59
+ html, body { height: 100%; }
60
+ body { margin: 0; background: var(--bg); color: var(--ink); font-family: ${SERIF_STACK}; font-size: 16px; line-height: 1.5; display: flex; flex-direction: column; overflow: hidden; }
61
+ .mono { font-family: ${MONO_STACK}; }
62
+ button { font: inherit; color: inherit; background: none; cursor: pointer; border: none; }
63
+ button:focus-visible, textarea:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }
64
+
65
+ header.topbar { flex: 0 0 auto; display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding: .7rem 1.1rem; border-bottom: 1px solid var(--line); flex-wrap: wrap; }
66
+ .brand { display: flex; flex-direction: column; gap: .1rem; }
67
+ .eyebrow { font-family: ${MONO_STACK}; font-size: .78rem; letter-spacing: .08em; color: var(--muted); }
68
+ .subtitle { font-size: .82rem; color: var(--muted); }
69
+
70
+ /* mode pills — Text | Document — the translate-tool idiom: two segments in
71
+ one rounded track, the active one filled. */
72
+ .pills { display: inline-flex; border: 1px solid var(--line); border-radius: 99px; overflow: hidden; font-family: ${MONO_STACK}; font-size: .72rem; }
73
+ .pills button { padding: .3rem .95rem; color: var(--muted); background: var(--card); border-right: 1px solid var(--line); }
74
+ .pills button:last-child { border-right: none; }
75
+ .pills button[aria-pressed="true"] { background: var(--ink); color: var(--bg); }
76
+
77
+ /* the two panes: a roomy input on the left, a soft-panel facts render on the
78
+ right. A CSS grid that stacks on a phone. */
79
+ main.panes { flex: 1 1 auto; min-height: 0; display: grid; grid-template-columns: 1fr 1fr; gap: 1px; background: var(--line); overflow: hidden; }
80
+ .pane { min-width: 0; min-height: 0; display: flex; flex-direction: column; background: var(--bg); }
81
+ .pane-head { flex: 0 0 auto; display: flex; align-items: center; justify-content: space-between; gap: .6rem; padding: .55rem .9rem; font-family: ${MONO_STACK}; font-size: .7rem; color: var(--muted); border-bottom: 1px solid var(--line); }
82
+
83
+ /* left: the free-text area, drop target and paste sink both. */
84
+ .inPane { position: relative; }
85
+ #source { flex: 1 1 auto; width: 100%; box-sizing: border-box; resize: none; border: none; background: var(--bg); color: var(--ink); font-family: ${MONO_STACK}; font-size: .84rem; line-height: 1.5; padding: .9rem 1rem; }
86
+ #source::placeholder { color: var(--muted); }
87
+ .inPane.drag { outline: 2px dashed var(--corpus); outline-offset: -6px; }
88
+ .dropHint { position: absolute; inset: 0; display: none; align-items: center; justify-content: center; pointer-events: none; font-family: ${MONO_STACK}; font-size: .8rem; color: var(--corpus); background: var(--corpus-soft); }
89
+ .inPane.drag .dropHint { display: flex; }
90
+ .browse { font-family: ${MONO_STACK}; font-size: .68rem; color: var(--muted); border: 1px solid var(--line); border-radius: 4px; padding: .16rem .55rem; background: var(--card); }
91
+ .browse:hover { color: var(--ink); }
92
+
93
+ /* right: the canonical facts, on the soft panel background. */
94
+ .outPane { background: var(--card); }
95
+ #facts { flex: 1 1 auto; overflow-y: auto; padding: .6rem .9rem 1rem; font-family: ${MONO_STACK}; font-size: .8rem; }
96
+ #facts .fact { display: grid; grid-template-columns: 1fr auto 1fr; gap: .5rem; align-items: baseline; padding: .3rem 0; border-bottom: 1px solid var(--line); }
97
+ #facts .fact .subj { color: var(--ink); text-align: right; word-break: break-word; }
98
+ #facts .fact .pred { color: var(--corpus); white-space: nowrap; }
99
+ #facts .fact .obj { color: var(--ink); word-break: break-word; }
100
+ #facts .fact .prov { grid-column: 1 / -1; color: var(--muted); font-size: .66rem; }
101
+ #facts .empty { color: var(--muted); padding: .5rem 0; }
102
+
103
+ .actions { flex: 0 0 auto; display: flex; align-items: center; gap: .6rem; padding: .6rem 1.1rem; border-top: 1px solid var(--line); flex-wrap: wrap; }
104
+ .actions .btn { font-family: ${MONO_STACK}; font-size: .74rem; color: var(--ink); border: 1px solid var(--line); border-radius: 6px; padding: .35rem .9rem; background: var(--card); }
105
+ .actions .btn.primary { background: var(--ink); color: var(--bg); border-color: var(--ink); }
106
+ .actions .btn:disabled { opacity: .45; cursor: default; }
107
+ .actions .status { margin-left: auto; font-family: ${MONO_STACK}; font-size: .7rem; color: var(--muted); }
108
+
109
+ @media (max-width: 720px) {
110
+ main.panes { grid-template-columns: 1fr; grid-template-rows: 1fr 1fr; }
111
+ }
112
+ @media (prefers-reduced-motion: reduce) { * { scroll-behavior: auto !important; } }
113
+ </style>
114
+ </head>
115
+ <body>
116
+ <header class="topbar">
117
+ <div class="brand">
118
+ <span class="eyebrow">the-mechanical-code-talker</span>
119
+ <span class="subtitle">ingest &mdash; paste or drop text; it keeps only the facts it can ground, and skips the rest honestly</span>
120
+ </div>
121
+ <div class="pills" role="group" aria-label="Input mode">
122
+ <button type="button" id="modeText" aria-pressed="true">Text</button>
123
+ <button type="button" id="modeDoc" aria-pressed="false">Document</button>
124
+ </div>
125
+ </header>
126
+ <main class="panes">
127
+ <section class="pane inPane" id="inPane" aria-label="Text to ingest">
128
+ <div class="pane-head">
129
+ <span id="srcLabel">your text</span>
130
+ <button type="button" class="browse" id="browseBtn">browse for a file&hellip;</button>
131
+ <input type="file" id="fileInput" accept=".txt,.md,text/plain,text/markdown" hidden>
132
+ </div>
133
+ <textarea id="source" spellcheck="false" autocapitalize="off"
134
+ placeholder="Paste text here, drop a .txt/.md file, or browse for one.&#10;&#10;Each sentence it recognizes as a fact (&quot;A beagle is a kind of dog.&quot;) is kept; every other sentence is skipped, never guessed at."></textarea>
135
+ <div class="dropHint">drop the file to load it</div>
136
+ </section>
137
+ <section class="pane outPane" aria-label="Grounded canonical facts">
138
+ <div class="pane-head">
139
+ <span>canonical facts</span>
140
+ <span id="factCount" class="mono"></span>
141
+ </div>
142
+ <div id="facts"><p class="empty">Nothing ingested yet. The facts it grounds will appear here as it reads.</p></div>
143
+ </section>
144
+ </main>
145
+ <div class="actions">
146
+ <button type="button" class="btn primary" id="ingestBtn" disabled>ingest</button>
147
+ <button type="button" class="btn" id="downloadBtn" disabled>download canonical</button>
148
+ <button type="button" class="btn" id="clearBtn">clear</button>
149
+ <span class="status" id="status">loading the engine&hellip;</span>
150
+ </div>
151
+ <script src="./ingest-browser.bundle.js"></script>
152
+ <script>
153
+ (function () {
154
+ "use strict";
155
+ const factTripleParts = ${factTripleParts.toString()};
156
+ const el = (id) => document.getElementById(id);
157
+
158
+ if ("serviceWorker" in navigator) navigator.serviceWorker.register("./tmct-sw.js").catch(() => {});
159
+
160
+ const sourceEl = el("source");
161
+ const factsEl = el("facts");
162
+ const factCountEl = el("factCount");
163
+ const statusEl = el("status");
164
+ const ingestBtn = el("ingestBtn");
165
+ const downloadBtn = el("downloadBtn");
166
+ const clearBtn = el("clearBtn");
167
+ const browseBtn = el("browseBtn");
168
+ const fileInput = el("fileInput");
169
+ const inPane = el("inPane");
170
+ const srcLabel = el("srcLabel");
171
+ const modeTextBtn = el("modeText");
172
+ const modeDocBtn = el("modeDoc");
173
+
174
+ let session = null;
175
+ let grounded = 0; // facts on show in the right pane
176
+ let sourceTag = "pasted text"; // what the header names the current input
177
+
178
+ // ---- input mode: Text | Document ---------------------------------------
179
+ // Both feed the same textarea and the same pipeline; the pill only changes
180
+ // what the header names the source and which affordance it leads with.
181
+ function setMode(doc) {
182
+ modeTextBtn.setAttribute("aria-pressed", String(!doc));
183
+ modeDocBtn.setAttribute("aria-pressed", String(doc));
184
+ browseBtn.style.display = doc ? "" : "none";
185
+ if (doc && sourceTag === "pasted text") srcLabel.textContent = "drop or browse for a file";
186
+ else if (!doc) srcLabel.textContent = sourceTag;
187
+ }
188
+ modeTextBtn.addEventListener("click", () => setMode(false));
189
+ modeDocBtn.addEventListener("click", () => setMode(true));
190
+
191
+ browseBtn.addEventListener("click", () => fileInput.click());
192
+ fileInput.addEventListener("change", () => {
193
+ const file = fileInput.files && fileInput.files[0];
194
+ if (file) loadFile(file);
195
+ fileInput.value = "";
196
+ });
197
+
198
+ async function loadFile(file) {
199
+ try {
200
+ sourceEl.value = await file.text();
201
+ sourceTag = file.name || "file";
202
+ srcLabel.textContent = sourceTag;
203
+ updateIngestEnabled();
204
+ } catch (err) {
205
+ statusEl.textContent = "couldn't read that file (" + (err && err.message ? err.message : err) + ")";
206
+ }
207
+ }
208
+
209
+ // Drag-and-drop straight onto the input pane — a text file loads its
210
+ // contents; anything else is ignored (the textarea's own paste handles
211
+ // dropped text selections for free).
212
+ ["dragenter", "dragover"].forEach((ev) => inPane.addEventListener(ev, (e) => {
213
+ e.preventDefault();
214
+ inPane.classList.add("drag");
215
+ }));
216
+ ["dragleave", "drop"].forEach((ev) => inPane.addEventListener(ev, (e) => {
217
+ if (ev === "dragleave" && inPane.contains(e.relatedTarget)) return;
218
+ inPane.classList.remove("drag");
219
+ }));
220
+ inPane.addEventListener("drop", (e) => {
221
+ e.preventDefault();
222
+ const file = e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files[0];
223
+ if (file) { setMode(true); loadFile(file); }
224
+ });
225
+
226
+ sourceEl.addEventListener("input", () => {
227
+ // Typing or pasting makes this "pasted text" again, not a named file.
228
+ if (srcLabel.textContent !== "pasted text") { sourceTag = "pasted text"; }
229
+ updateIngestEnabled();
230
+ });
231
+
232
+ function updateIngestEnabled() {
233
+ ingestBtn.disabled = !session || !sourceEl.value.trim();
234
+ }
235
+
236
+ // ---- the canonical facts pane ------------------------------------------
237
+ function clearFactsPane() {
238
+ factsEl.textContent = "";
239
+ grounded = 0;
240
+ factCountEl.textContent = "";
241
+ downloadBtn.disabled = true;
242
+ const empty = document.createElement("p");
243
+ empty.className = "empty";
244
+ empty.textContent = "Nothing ingested yet. The facts it grounds will appear here as it reads.";
245
+ factsEl.appendChild(empty);
246
+ }
247
+
248
+ function appendFactRow(fact) {
249
+ if (!grounded) factsEl.textContent = ""; // drop the empty note on the first real row
250
+ const parts = factTripleParts(fact);
251
+ const row = document.createElement("div");
252
+ row.className = "fact";
253
+ const subj = document.createElement("span");
254
+ subj.className = "subj";
255
+ subj.textContent = parts.subject;
256
+ const pred = document.createElement("span");
257
+ pred.className = "pred";
258
+ pred.textContent = parts.predicate;
259
+ const obj = document.createElement("span");
260
+ obj.className = "obj";
261
+ obj.textContent = parts.object;
262
+ row.appendChild(subj);
263
+ row.appendChild(pred);
264
+ row.appendChild(obj);
265
+ if (parts.provenance) {
266
+ const prov = document.createElement("span");
267
+ prov.className = "prov";
268
+ prov.textContent = parts.provenance;
269
+ row.appendChild(prov);
270
+ }
271
+ factsEl.appendChild(row);
272
+ grounded += 1;
273
+ factCountEl.textContent = grounded + (grounded === 1 ? " fact" : " facts");
274
+ downloadBtn.disabled = false;
275
+ factsEl.scrollTop = factsEl.scrollHeight;
276
+ }
277
+
278
+ // ---- ingest: the one seam call -----------------------------------------
279
+ let busy = false;
280
+ ingestBtn.addEventListener("click", async () => {
281
+ const text = sourceEl.value.trim();
282
+ if (!text || busy || !session) return;
283
+ busy = true;
284
+ ingestBtn.disabled = true;
285
+ // A fresh ingest starts a fresh store, so the right pane and the canonical
286
+ // download always describe exactly the text now in the box.
287
+ session = window.tmctIngest.createIngestSession();
288
+ clearFactsPane();
289
+ statusEl.textContent = "reading\\u2026";
290
+ try {
291
+ const summary = await session.ingest(text, {
292
+ onFact: (fact) => { appendFactRow(fact); return new Promise((r) => setTimeout(r, 0)); },
293
+ });
294
+ statusEl.textContent = summary.sentences + " sentence" + (summary.sentences === 1 ? "" : "s")
295
+ + " read, " + summary.recognized + " grounded, " + summary.skipped + " skipped"
296
+ + (summary.skipped ? " (not a recognized fact shape \\u2014 honest, expected)" : "");
297
+ if (!summary.recognized) {
298
+ const empty = factsEl.querySelector(".empty");
299
+ if (!empty) {
300
+ factsEl.textContent = "";
301
+ const note = document.createElement("p");
302
+ note.className = "empty";
303
+ note.textContent = "No sentence here was a fact it could ground. Try a plain statement like \\u201cA beagle is a kind of dog.\\u201d";
304
+ factsEl.appendChild(note);
305
+ }
306
+ }
307
+ } catch (err) {
308
+ statusEl.textContent = "something went wrong reading that (" + (err && err.message ? err.message : err) + ")";
309
+ } finally {
310
+ busy = false;
311
+ updateIngestEnabled();
312
+ }
313
+ });
314
+
315
+ // ---- download the canonical facts as JSONL -----------------------------
316
+ downloadBtn.addEventListener("click", async () => {
317
+ if (!session || !window.tmctIngest.exportFactsJsonl) return;
318
+ let jsonl;
319
+ try {
320
+ jsonl = await window.tmctIngest.exportFactsJsonl(session.memoryDir);
321
+ } catch (err) {
322
+ statusEl.textContent = "couldn't build the download (" + (err && err.message ? err.message : err) + ")";
323
+ return;
324
+ }
325
+ const blob = new Blob([jsonl], { type: "application/x-ndjson" });
326
+ const url = URL.createObjectURL(blob);
327
+ const link = document.createElement("a");
328
+ link.href = url;
329
+ link.download = "tmct-facts.jsonl";
330
+ document.body.appendChild(link);
331
+ link.click();
332
+ link.remove();
333
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
334
+ });
335
+
336
+ clearBtn.addEventListener("click", () => {
337
+ sourceEl.value = "";
338
+ sourceTag = "pasted text";
339
+ srcLabel.textContent = modeDocBtn.getAttribute("aria-pressed") === "true" ? "drop or browse for a file" : "pasted text";
340
+ session = window.tmctIngest ? window.tmctIngest.createIngestSession() : null;
341
+ clearFactsPane();
342
+ statusEl.textContent = "cleared";
343
+ updateIngestEnabled();
344
+ sourceEl.focus();
345
+ });
346
+
347
+ // ---- engine boot -------------------------------------------------------
348
+ const WINK_LOAD_TIMEOUT_MS = 8000;
349
+ async function tryLoadWink() {
350
+ let settled = false;
351
+ const timeout = new Promise((_, reject) => setTimeout(() => { if (!settled) reject(new Error("wink load stalled")); }, WINK_LOAD_TIMEOUT_MS));
352
+ try {
353
+ const mod = await Promise.race([import("./vendor/wink.js"), timeout]);
354
+ settled = true;
355
+ window.tmctIngest.registerWinkModel(() => ({ winkNLP: mod.winkNLP, model: mod.model }));
356
+ return "loaded";
357
+ } catch (err) {
358
+ settled = true;
359
+ console.warn("tmct ingest: the wink vendor asset failed to load; the recognizer needs it to split and parse sentences", err);
360
+ return "unavailable";
361
+ }
362
+ }
363
+
364
+ async function boot() {
365
+ if (!window.tmctIngest) {
366
+ statusEl.textContent = "the ingest engine didn't load \\u2014 this page needs its build step (npm run demo:build)";
367
+ return;
368
+ }
369
+ const winkStatus = await tryLoadWink();
370
+ session = window.tmctIngest.createIngestSession();
371
+ setMode(false);
372
+ updateIngestEnabled();
373
+ statusEl.textContent = winkStatus === "loaded"
374
+ ? "ready \\u2014 paste or drop text, then ingest"
375
+ : "wink-nlp unavailable \\u2014 the recognizer can't split sentences without it";
376
+ sourceEl.focus();
377
+ }
378
+
379
+ window.tmctIngestReady = boot().catch((err) => {
380
+ console.error("tmct ingest failed to boot", err);
381
+ statusEl.textContent = "the ingest page failed to start (" + (err && err.message ? err.message : err) + ")";
382
+ });
383
+ })();
384
+ </script>
385
+ </body>
386
+ </html>
387
+ `;
388
+ }
@@ -524,6 +524,26 @@ export function renderLedgerHtml({ rows, terms, edges, focus, contradictions, wo
524
524
  const placeholder = termSet.has("ishmael")
525
525
  ? "who is the grandfather of ishmael"
526
526
  : (exampleTerm ? `ask the graph… e.g. what is ${exampleTerm.term}` : "ask the graph…");
527
+ // The paste-and-drop ingest panel plus the JSONL export ride the LIVE
528
+ // engine (window.tmctLedger): both teach into and read the dock's own
529
+ // session store, so they appear only where that bundle is offered — the
530
+ // demo site, never the CLI's self-contained tmct viz page.
531
+ const ingestHtml = ledgerBundleAvailable
532
+ ? `<div class="docktools">
533
+ <button type="button" id="ingestToggle" class="dockbtn">ingest text&hellip;</button>
534
+ <button type="button" id="exportFacts" class="dockbtn">export facts</button>
535
+ </div>
536
+ <div class="ingestpanel" id="ingestPanel" hidden>
537
+ <textarea id="ingestText" spellcheck="false" aria-label="Text to ingest into the graph"
538
+ placeholder="Paste text or drop a .txt/.md file. Each sentence it recognizes as a fact is added to the graph; the rest are skipped honestly."></textarea>
539
+ <div class="ingestrow">
540
+ <button type="button" class="dockbtn" id="ingestBrowse">browse&hellip;</button>
541
+ <input type="file" id="ingestFile" accept=".txt,.md,text/plain,text/markdown" hidden>
542
+ <button type="button" class="dockbtn primary" id="ingestRun">ingest</button>
543
+ <span class="ingeststatus mono" id="ingestStatus" aria-live="polite"></span>
544
+ </div>
545
+ </div>`
546
+ : "";
527
547
  const dockHtml = hasMemChat
528
548
  ? `<div class="chat">
529
549
  <div class="chatlog" id="chatlog" aria-live="polite"></div>
@@ -531,6 +551,7 @@ export function renderLedgerHtml({ rows, terms, edges, focus, contradictions, wo
531
551
  <span class="prompt mono">tmct&gt;</span>
532
552
  <input id="chatq" type="text" placeholder="${escapeHtml(placeholder)}" aria-label="Ask the graph">
533
553
  </form>
554
+ ${ingestHtml}
534
555
  </div>`
535
556
  : `<div class="chat chat-off"><p class="chatnote">chat unavailable — run <span class="mono">npm run build:ask-bundle</span> to enable the in-page ask engine.</p></div>`;
536
557
 
@@ -655,6 +676,16 @@ ${THEME_TOKENS_CSS}
655
676
  .chatask input { flex: 1; font-family: ${MONO_STACK}; font-size: .78rem; background: var(--bg); color: var(--ink); border: 1px solid var(--line); border-radius: 6px; padding: .32rem .6rem; min-width: 0; }
656
677
  .chatask input:disabled { opacity: .6; }
657
678
  .chatnote { font-family: ${MONO_STACK}; font-size: .72rem; color: var(--muted); margin: 0; }
679
+ .docktools { display: flex; gap: .5rem; margin-top: .55rem; }
680
+ .dockbtn { font-family: ${MONO_STACK}; font-size: .68rem; color: var(--muted); border: 1px solid var(--line); border-radius: 6px; padding: .22rem .6rem; background: var(--bg); cursor: pointer; }
681
+ .dockbtn:hover { color: var(--ink); border-color: var(--ink); }
682
+ .dockbtn.primary { background: var(--ink); color: var(--bg); border-color: var(--ink); }
683
+ .ingestpanel { margin-top: .55rem; display: flex; flex-direction: column; gap: .45rem; }
684
+ .ingestpanel[hidden] { display: none; }
685
+ .ingestpanel textarea { width: 100%; box-sizing: border-box; min-height: 96px; resize: vertical; font-family: ${MONO_STACK}; font-size: .76rem; line-height: 1.45; background: var(--bg); color: var(--ink); border: 1px solid var(--line); border-radius: 6px; padding: .5rem .6rem; }
686
+ .ingestpanel textarea.drag { outline: 2px dashed var(--corpus); outline-offset: -4px; }
687
+ .ingestrow { display: flex; align-items: center; gap: .5rem; }
688
+ .ingeststatus { font-size: .68rem; color: var(--muted); }
658
689
  @media (prefers-reduced-motion: no-preference) {
659
690
  .seg, .chip, .look { transition: border-color .12s ease, background-color .12s ease; }
660
691
  /* A live teach re-render swaps the dash section wholesale (dashboardHtml
@@ -1067,6 +1098,85 @@ ${ledgerBundleAvailable ? `<script src="./ledger-browser.bundle.js"></script>` :
1067
1098
  }
1068
1099
  });
1069
1100
  });
1101
+
1102
+ // ---- ingest + export: bring your own text, take the graph away ---------
1103
+ // The ingest panel runs pasted/dropped/browsed text through the SAME dock
1104
+ // session, one sentence at a time (tmctLedger.splitSentences, then
1105
+ // s.turn), keeping only the sentences the recognizer grounds — then
1106
+ // re-derives the whole ledger so the new facts can be examined in place.
1107
+ // Export serializes that same session store to canonical JSONL.
1108
+ const ingestToggle = el("ingestToggle");
1109
+ if (ingestToggle) {
1110
+ const panel = el("ingestPanel");
1111
+ const ta = el("ingestText");
1112
+ const runBtn = el("ingestRun");
1113
+ const statusEl = el("ingestStatus");
1114
+ const fileInput = el("ingestFile");
1115
+ ingestToggle.addEventListener("click", () => {
1116
+ panel.hidden = !panel.hidden;
1117
+ if (!panel.hidden) ta.focus();
1118
+ });
1119
+ el("ingestBrowse").addEventListener("click", () => fileInput.click());
1120
+ fileInput.addEventListener("change", async () => {
1121
+ const f = fileInput.files && fileInput.files[0];
1122
+ if (f) { try { ta.value = await f.text(); } catch { statusEl.textContent = "couldn't read that file"; } }
1123
+ fileInput.value = "";
1124
+ });
1125
+ ["dragenter", "dragover"].forEach((ev) => ta.addEventListener(ev, (e) => { e.preventDefault(); ta.classList.add("drag"); }));
1126
+ ["dragleave", "drop"].forEach((ev) => ta.addEventListener(ev, () => ta.classList.remove("drag")));
1127
+ ta.addEventListener("drop", async (e) => {
1128
+ e.preventDefault();
1129
+ const f = e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files[0];
1130
+ if (f) { try { ta.value = await f.text(); } catch { statusEl.textContent = "couldn't read that file"; } }
1131
+ });
1132
+ runBtn.addEventListener("click", () => {
1133
+ const text = ta.value.trim();
1134
+ if (!text) return;
1135
+ statusEl.textContent = "reading\\u2026";
1136
+ runBtn.disabled = true;
1137
+ withLock(async () => {
1138
+ try {
1139
+ const s = await ensureSession();
1140
+ const sentences = tmctLedger.splitSentences(text);
1141
+ let grounded = 0;
1142
+ for (const sentence of sentences) {
1143
+ const r = await s.turn(sentence);
1144
+ if (r.record && r.record.miss === false && r.record.via === "assert") grounded += 1;
1145
+ }
1146
+ const fresh = tmctLedger.computeLedgerDataFromPayload(s.memoryDir.payload, {});
1147
+ applyLedgerData(fresh);
1148
+ const skipped = sentences.length - grounded;
1149
+ statusEl.textContent = sentences.length + " sentence" + (sentences.length === 1 ? "" : "s")
1150
+ + ", " + grounded + " added" + (skipped ? ", " + skipped + " skipped" : "");
1151
+ } catch {
1152
+ statusEl.textContent = "something went wrong ingesting that";
1153
+ } finally {
1154
+ runBtn.disabled = false;
1155
+ }
1156
+ });
1157
+ });
1158
+ }
1159
+
1160
+ const exportBtn = el("exportFacts");
1161
+ if (exportBtn) {
1162
+ exportBtn.addEventListener("click", () => {
1163
+ withLock(async () => {
1164
+ try {
1165
+ const s = await ensureSession();
1166
+ const jsonl = await tmctLedger.exportFactsJsonl(s.memoryDir);
1167
+ const blob = new Blob([jsonl], { type: "application/x-ndjson" });
1168
+ const url = URL.createObjectURL(blob);
1169
+ const link = document.createElement("a");
1170
+ link.href = url;
1171
+ link.download = "tmct-facts.jsonl";
1172
+ document.body.appendChild(link);
1173
+ link.click();
1174
+ link.remove();
1175
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
1176
+ } catch { /* a failed export leaves the page untouched */ }
1177
+ });
1178
+ });
1179
+ }
1070
1180
  } else if (chatForm && typeof tmctMemoryAsk !== "undefined") {
1071
1181
  const memHandle = tmctMemoryAsk.createInMemoryStore();
1072
1182
  memHandle.payload = PAYLOAD;
@@ -0,0 +1,64 @@
1
+ // session-log-format.mjs — the ONE Markdown shape every session-transcript
2
+ // writer renders turns into: the Node CLI/TUI's own .tmct/session-<id>.md
3
+ // (chat-session.mjs) and the browser chat page's "export .md" button
4
+ // (chat-page-viz.mjs). A `#` title carrying the version and a short session
5
+ // label, one `###` heading per turn at millisecond time-of-day precision,
6
+ // the user's line as a verbatim `>` blockquote, the reply in a fenced
7
+ // block, and a closing session-end line.
8
+ //
9
+ // Every export here is a pure, self-contained function — no imports, and no
10
+ // references outside its own body except calling its siblings in this file
11
+ // by name — so the browser writer can splice each one's own `.toString()`
12
+ // straight into chat.html's inline script, the same discipline
13
+ // provBucketFor/provenanceChipFor already hold in chat-page-viz.mjs.
14
+
15
+ /** An ISO-8601 string or epoch-ms number, as its own "HH:MM:SS.mmm" time of
16
+ * day — read straight off the timestamp's own UTC digits, never converted
17
+ * to a local zone, so it always names the wall-clock instant the session
18
+ * actually ran the turn on. */
19
+ export function sessionLogTimeOfDay(ts) {
20
+ const iso = typeof ts === "number" ? new Date(ts).toISOString() : String(ts);
21
+ return iso.slice(11, 23);
22
+ }
23
+
24
+ /** The session file's opening block: a `#` title naming the version and a
25
+ * short session label (the id's first segment), a byline with the
26
+ * calendar date, the started time, and — when given — the repo path, then
27
+ * the `---` divider before the first turn. `repo` is optional: a browser
28
+ * export has none, so that clause is simply left out rather than shown
29
+ * empty. */
30
+ export function sessionLogHeaderMarkdown({ version, sessionId, startedAt, repo }) {
31
+ const iso = typeof startedAt === "number" ? new Date(startedAt).toISOString() : String(startedAt);
32
+ const shortId = String(sessionId || "").split("-")[0].slice(0, 8);
33
+ const date = iso.slice(0, 10);
34
+ const time = sessionLogTimeOfDay(iso);
35
+ const bylineParts = [date, "started " + time];
36
+ if (repo) bylineParts.push("repo " + repo);
37
+ const byline = "*" + bylineParts.join(" · ") + "*";
38
+ return "# tmct chat " + version + " — session " + shortId + "\n\n" + byline + "\n\n---\n\n";
39
+ }
40
+
41
+ /** One turn's own Markdown block: a `###` heading naming the time of day and
42
+ * the turn number, the VERBATIM user line as a `>` blockquote (no
43
+ * rewriting, no truncation — whatever the recognizer actually saw), and
44
+ * the reply in a fenced text block. Ends in a blank line so consecutive
45
+ * turn blocks concatenate directly into one document, byte-identical to
46
+ * the reference sample. An empty answer (the closing "/exit" marker) opens
47
+ * and closes the fence with nothing between, rather than a stray blank
48
+ * line inside it. */
49
+ export function sessionLogTurnMarkdown({ startedAt, turnNumber, query, answer }) {
50
+ const time = sessionLogTimeOfDay(startedAt);
51
+ const body = answer ? answer + "\n" : "";
52
+ return "### " + time + " · turn " + turnNumber + "\n\n"
53
+ + "> " + query + "\n\n"
54
+ + "```text\n" + body + "```\n\n";
55
+ }
56
+
57
+ /** The closing block: a `---` divider and the session-end line naming the
58
+ * end time and the total turn count (the closing "/exit" marker counts as
59
+ * the last turn, so this number is always that marker's own turn number).
60
+ * No leading blank line — the preceding turn block already supplied one. */
61
+ export function sessionLogEndMarkdown({ endedAt, turnCount }) {
62
+ const time = sessionLogTimeOfDay(endedAt);
63
+ return "---\n\n*session end " + time + " — " + turnCount + " turn" + (turnCount === 1 ? "" : "s") + "*\n";
64
+ }