@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.
- package/README.md +66 -10
- package/bin/tmct.mjs +5 -2
- package/package.json +3 -1
- package/src/adapters/memory/core.mjs +8 -1
- package/src/domain/cli-verbs.mjs +2 -0
- package/src/domain/memory/trust.mjs +21 -2
- package/src/domain/sense-split.mjs +203 -0
- package/src/services/chat-page-viz.mjs +87 -19
- package/src/services/chat-session.mjs +12 -7
- package/src/services/chat.mjs +320 -30
- package/src/services/code-explorer-viz.mjs +16 -2
- package/src/services/extract-facts.mjs +293 -81
- package/src/services/fold.mjs +1 -1
- package/src/services/ingest-viz.mjs +388 -0
- package/src/services/ledger-viz.mjs +110 -0
- package/src/services/session-log-format.mjs +64 -0
- package/src/services/sessions.mjs +56 -22
- package/src/services/spider-fly-turn.mjs +54 -1
- package/src/services/spider-fly-viz.mjs +21 -19
- package/src/surfaces/web/chat-browser-entry.mjs +10 -6
- package/src/surfaces/web/ingest-browser-entry.mjs +126 -0
- package/src/surfaces/web/ledger-browser-entry.mjs +15 -2
- package/src/surfaces/web/memory-ask-browser.bundle.js +135 -126
- package/src/tools/definitions.mjs +14 -0
- package/src/tools/handlers/index.mjs +2 -0
- package/src/tools/handlers/tmct-ingest.mjs +43 -0
- package/src/tools/server.mjs +5 -2
|
@@ -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 — 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…</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. Each sentence it recognizes as a fact ("A beagle is a kind of dog.") 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…</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…</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…</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></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
|
+
}
|