@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.
@@ -408,9 +408,21 @@ const BACKGROUND_FACT_PHRASES = {
408
408
  "mgx:atLocation": "is found in",
409
409
  };
410
410
 
411
+ /** True when a fact row is provably owned by a NON-world source (a merged
412
+ * corpus, a reference pack, a taught assert) rather than the loaded world. A
413
+ * row with no provenance is not "non-world" — a hand-built digest view carries
414
+ * none, and the room-look filter keeps those. World facts and their @turn
415
+ * snapshots tag as `world:<name>[:turnN]`. */
416
+ function isNonWorldSourced(row) {
417
+ const prov = String(row.provenance || "").trim();
418
+ return prov !== "" && !prov.startsWith("world:");
419
+ }
420
+
411
421
  /** The digest's fact view: current placements (folded), exits, typing and
412
422
  * every other surviving fact, with phrase predicates and sentence-cased
413
- * subjects so the pipeline's sentence splitter sees real sentences. Pure. */
423
+ * subjects so the pipeline's sentence splitter sees real sentences. Room text
424
+ * is world-sourced only — a merged corpus's overlap on a room's own vocabulary
425
+ * never leaks into the description. Pure. */
414
426
  export function worldDigestRows(rows, state) {
415
427
  const out = [];
416
428
  const seen = new Set();
@@ -440,6 +452,12 @@ export function worldDigestRows(rows, state) {
440
452
  }
441
453
  for (const row of rows || []) {
442
454
  if (SNAPSHOT_RE.test(row.subject)) continue; // folded above
455
+ // Room text comes from the world source only. A merged corpus overlaps a
456
+ // room's own vocabulary ("library rdfs:subClassOf literary study"), and
457
+ // without this those rows leak into the room description as stray sentences.
458
+ // A row with no provenance (a hand-built test view) is kept — the filter
459
+ // only drops rows a non-world source provably owns.
460
+ if (isNonWorldSourced(row)) continue;
443
461
  if (PLACEMENT_PREDICATES.has(row.predicate)) continue; // folded above
444
462
  if (VIEW_EXCLUDED_PREDICATES.has(row.predicate)) continue;
445
463
  const exit = EXIT_PREDICATE_RE.exec(row.predicate);
@@ -909,6 +927,58 @@ async function worldOpennessAnswer(line, { memoryDir }) {
909
927
  );
910
928
  }
911
929
 
930
+ // The in-game orientation asides — "where am I", "what can I do", "what is the
931
+ // quest/goal". Without a world-state answer these fall through to the ordinary
932
+ // lanes and misroute: "where am I" reads "I" as a module name, "what can I do"
933
+ // walls, and "what is the goal" answers from corpus vocabulary about the word
934
+ // "goal". A live world answers each from its own fold first.
935
+ const WORLD_WHERE_AM_I_RE = /^where\s+am\s+i(?:\s+now)?[?.!\s]*$/i;
936
+ const WORLD_OPTIONS_RE = /^(?:what\s+can\s+i\s+do(?:\s+(?:here|now))?|what\s+are\s+my\s+options|what\s+(?:should|do)\s+i\s+do(?:\s+(?:here|now))?|what\s+now)[?.!\s]*$/i;
937
+ const WORLD_QUEST_RE = /^(?:what(?:'s|\s+is)\s+(?:the\s+|my\s+)?(?:quest|goal|objective|mission|aim)|what\s+am\s+i\s+(?:trying\s+to\s+do|(?:supposed|meant)\s+to\s+do)|what\s+do\s+i\s+do\s+here)[?.!\s]*$/i;
938
+
939
+ /** The in-game orientation asides, answered from the world fold: the player's
940
+ * room, the room's real affordances, and the world's objective. Null when the
941
+ * line is none of them, so an ordinary question keeps its lane. */
942
+ async function worldContextAnswer(line, { memoryDir }) {
943
+ const l = String(line).trim();
944
+ const asksWhere = WORLD_WHERE_AM_I_RE.test(l);
945
+ const asksOptions = WORLD_OPTIONS_RE.test(l);
946
+ const asksQuest = WORLD_QUEST_RE.test(l);
947
+ if (!asksWhere && !asksOptions && !asksQuest) return null;
948
+ let rows;
949
+ try { rows = readFactRows(await loadMemory(memoryDir)); } catch { return null; }
950
+ const state = foldWorldState(rows);
951
+ const here = state.placements.get("player")?.object ?? null;
952
+
953
+ if (asksWhere) {
954
+ return here
955
+ ? answer(`you are in the ${here}.`, "ADVENTURE — where-am-I aside: the player's own room from the current world fold", { goal: "check where you are" })
956
+ : answer("the world has no written player position yet.", "ADVENTURE — where-am-I aside: no player placement", { miss: true, goal: "check where you are" });
957
+ }
958
+
959
+ if (asksOptions) {
960
+ const actions = here ? roomAffordances(rows, state, here) : [];
961
+ return answer(
962
+ actions.length ? `you can: ${actions.join(", ")}.` : `nothing obvious here — say "look" to look around${here ? ` the ${here}` : ""}.`,
963
+ `ADVENTURE — options aside: the ${here}'s roomAffordances, the same list "look" appends`,
964
+ { goal: "see what you can do here" },
965
+ );
966
+ }
967
+
968
+ const objectiveId = rows.find((r) => r.predicate === "mgx:is-objective" && r.object === "true")?.subject ?? null;
969
+ return objectiveId
970
+ ? answer(
971
+ `your goal is to find the ${objectiveId} and pick it up.`,
972
+ `ADVENTURE — quest aside: the world's objective (${objectiveId}), named without spoiling where it is`,
973
+ { goal: `find the ${objectiveId}` },
974
+ )
975
+ : answer(
976
+ `this world sets no explicit goal — explore it, and say "look" to see your options.`,
977
+ "ADVENTURE — quest aside: no objective marker in this world",
978
+ { goal: "explore the world" },
979
+ );
980
+ }
981
+
912
982
  async function inventoryAnswer({ memoryDir, graph }) {
913
983
  const memory = await loadMemory(memoryDir);
914
984
  const rows = readFactRows(memory);
@@ -1033,5 +1103,7 @@ export async function adventureTurn(line, { planHolder, memoryDir, sessionId = "
1033
1103
  if (whereAside) return whereAside;
1034
1104
  const opennessAside = await worldOpennessAnswer(line, { memoryDir });
1035
1105
  if (opennessAside) return opennessAside;
1106
+ const contextAside = await worldContextAnswer(line, { memoryDir });
1107
+ if (contextAside) return contextAside;
1036
1108
  return null; // a mid-game aside — the ordinary lanes answer, world untouched
1037
1109
  }
@@ -30,6 +30,7 @@
30
30
  // exist (both built earlier in that same script, for the embedded widget).
31
31
  import { THEME_TOKENS_CSS, SERIF_STACK, MONO_STACK, escapeHtml } from "./viz-theme.mjs";
32
32
  import { provBucketFor } from "./ledger-viz.mjs";
33
+ import { sessionLogTimeOfDay, sessionLogHeaderMarkdown, sessionLogTurnMarkdown } from "./session-log-format.mjs";
33
34
 
34
35
  const DEFAULT_TITLE = "the-mechanical-code-talker — talk to it";
35
36
 
@@ -115,25 +116,37 @@ export function loadProgressLine(parts) {
115
116
  }
116
117
 
117
118
  /**
118
- * The exported transcript as one Markdown document: a title line naming the
119
- * site version and the export date, then every turn in order as
120
- * "**you:** ..." / "**tmct:** ...", with the provenance tier in parentheses
121
- * when the turn carried one. Reads the page's transcript MODEL (an array of
122
- * { role, text, chipTier }), never the DOM the message column may
123
- * virtualize long chats someday, and an export must still carry every turn.
119
+ * The exported transcript as ONE Markdown document, in the SAME shape the
120
+ * Node CLI/TUI's own .tmct/session-<id>.md writes (session-log-format.mjs,
121
+ * spliced in beside this function below): a title naming the version and a
122
+ * short session id, one heading per turn at millisecond time-of-day
123
+ * precision, the question as a verbatim blockquote, the answer in a fenced
124
+ * block. No closing session-end line unlike a CLI session's close(), an
125
+ * export can happen mid-conversation, before anything has actually ended.
124
126
  *
125
- * Self-contained (no outer refs), `.toString()`-splice safe the same
126
- * discipline provenanceChipFor/loadProgressLine above hold.
127
+ * Reads the page's transcript MODEL (an array alternating { role: "you" |
128
+ * "tmct", text, chipTier, ts }, one entry per submit and per settled
129
+ * reply), never the DOM — the message column may virtualize long chats
130
+ * someday, and an export must still carry every turn.
131
+ *
132
+ * `headerMd`/`turnMd` are the injected session-log-format.mjs builders
133
+ * (spliced in as their own consts alongside this function) — injected
134
+ * rather than imported so this function stays `.toString()`-splice safe,
135
+ * the same discipline provenanceChipFor's injected `bucketFor` holds.
127
136
  */
128
- export function transcriptMarkdown(turns, meta) {
137
+ export function transcriptMarkdown(turns, meta, headerMd, turnMd) {
129
138
  const version = (meta && meta.version) || "dev";
130
- const date = (meta && meta.date) || "";
131
- const lines = ["# tmct chat — v" + version + (date ? " — " + date : ""), ""];
132
- for (const turn of turns || []) {
133
- const tier = turn.chipTier ? " (" + turn.chipTier + ")" : "";
134
- lines.push("**" + turn.role + ":** " + turn.text + tier, "");
139
+ const sessionId = (meta && meta.sessionId) || "";
140
+ const list = turns || [];
141
+ let doc = headerMd({ version: version, sessionId: sessionId, startedAt: list.length ? list[0].ts : Date.now() });
142
+ let turnNumber = 0;
143
+ for (let i = 0; i < list.length; i += 1) {
144
+ if (list[i].role !== "you") continue;
145
+ turnNumber += 1;
146
+ const reply = list[i + 1] && list[i + 1].role === "tmct" ? list[i + 1] : null;
147
+ doc += turnMd({ startedAt: list[i].ts, turnNumber: turnNumber, query: list[i].text, answer: reply ? reply.text : "" });
135
148
  }
136
- return lines.join("\n");
149
+ return doc;
137
150
  }
138
151
 
139
152
  /** The self-contained "talk to it" full-screen page. Pure — the same output
@@ -304,12 +317,14 @@ ${THEME_TOKENS_CSS}
304
317
  <button type="submit" id="composerSend" aria-label="Send" disabled>&#8594;</button>
305
318
  </div>
306
319
  <div class="composer-tools">
307
- <label class="liveLabel" title="Off by default. When on, a question nothing local can answer also asks en.wikipedia.org — two small requests per lookup, and the answer is cited (CC BY-SA).">
320
+ <label class="liveLabel" title="Off by default. When on, a question nothing local can answer also asks en.wikipedia.org — two small requests per lookup, and the answer is cited (CC BY-SA). Type /wiki supplement to also add a cited Wikipedia read-out under every grounded answer.">
308
321
  <input type="checkbox" id="liveToggle" role="switch" aria-label="ask Wikipedia when I don't know">
309
322
  <span class="toggle-track" aria-hidden="true"><span class="toggle-knob"></span></span>
310
323
  <span>ask Wikipedia when I don&#8217;t know</span>
311
324
  </label>
312
325
  <span class="tool-cluster">
326
+ <button type="button" id="ingestFile" class="tool-btn" title="load a .txt/.md file and teach every fact it recognizes into this session">ingest file</button>
327
+ <input type="file" id="ingestInput" accept=".txt,.md,text/plain,text/markdown" hidden>
313
328
  <button type="button" id="exportMd" class="tool-btn" title="download this conversation as Markdown">export .md</button>
314
329
  <button type="button" id="exportFacts" class="tool-btn" title="download this session's facts as JSONL (the tmct extract shape, provenance included)">export facts</button>
315
330
  <button type="button" id="printChat" class="tool-btn" title="print the whole conversation">print</button>
@@ -329,6 +344,9 @@ ${THEME_TOKENS_CSS}
329
344
  const provBucketFor = ${provBucketFor.toString()};
330
345
  const provenanceChipFor = ${provenanceChipFor.toString()};
331
346
  const loadProgressLine = ${loadProgressLine.toString()};
347
+ const sessionLogTimeOfDay = ${sessionLogTimeOfDay.toString()};
348
+ const sessionLogHeaderMarkdown = ${sessionLogHeaderMarkdown.toString()};
349
+ const sessionLogTurnMarkdown = ${sessionLogTurnMarkdown.toString()};
332
350
  const transcriptMarkdown = ${transcriptMarkdown.toString()};
333
351
  const el = (id) => document.getElementById(id);
334
352
 
@@ -763,8 +781,14 @@ ${THEME_TOKENS_CSS}
763
781
  window.tmctChatSession.turn(q)
764
782
  .then((result) => {
765
783
  settleAssistantBubble(pendingRow, result.answer, result.record);
766
- if (result.record && result.record.via === "assert") scheduleSave();
767
- return renderStatsPanel(); // a teach turn just grew this session's memory; a plain ask leaves it unchanged either way
784
+ // Persist on ANY store write, not just a teach turn: a learn-on-miss
785
+ // load (a child pack, a reference or live-Wikipedia article) and its
786
+ // auto-synthesis also append facts, and those were lost on reload when
787
+ // only via==="assert" saved. Commands write nothing, so they stay out.
788
+ // The save is debounced, so a read-through that changed nothing costs
789
+ // at most one coalesced write.
790
+ if (result.record && result.record.via !== "command") scheduleSave();
791
+ return renderStatsPanel(); // a teach or learned-load turn grew this session's memory; a plain ask leaves it unchanged either way
768
792
  })
769
793
  .catch((err) => settleAssistantBubble(pendingRow,
770
794
  "something went wrong answering that (" + (err && err.message ? err.message : err) + ") \\u2014 try rephrasing",
@@ -791,7 +815,8 @@ ${THEME_TOKENS_CSS}
791
815
  // @media print stylesheet above to un-pin the message column so every
792
816
  // turn reaches paper.
793
817
  el("exportMd").addEventListener("click", () => {
794
- const md = transcriptMarkdown(transcript, { version: siteVersion, date: new Date(Date.now()).toISOString().slice(0, 10) });
818
+ const sessionId = (window.tmctChatSession && window.tmctChatSession.sessionId) || "";
819
+ const md = transcriptMarkdown(transcript, { version: siteVersion, sessionId: sessionId }, sessionLogHeaderMarkdown, sessionLogTurnMarkdown);
795
820
  const blob = new Blob([md], { type: "text/markdown" });
796
821
  const url = URL.createObjectURL(blob);
797
822
  const link = document.createElement("a");
@@ -829,6 +854,49 @@ ${THEME_TOKENS_CSS}
829
854
  setTimeout(() => URL.revokeObjectURL(url), 1000);
830
855
  });
831
856
 
857
+ // "ingest file" feeds a whole .txt/.md through the SAME session, one
858
+ // sentence at a time (window.tmctChat.splitSentences, then session.turn),
859
+ // teaching every sentence the recognizer grounds and skipping the rest
860
+ // honestly — the same pipeline the ingest page runs, reaching the chat's own
861
+ // memory so the taught facts answer questions straight away.
862
+ el("ingestFile").addEventListener("click", () => el("ingestInput").click());
863
+ el("ingestInput").addEventListener("change", async (e) => {
864
+ const file = e.target.files && e.target.files[0];
865
+ e.target.value = "";
866
+ const session = window.tmctChatSession;
867
+ if (!file || busy || !session || !window.tmctChat.splitSentences) return;
868
+ let text;
869
+ try {
870
+ text = await file.text();
871
+ } catch (err) {
872
+ addSystemLine("couldn't read that file (" + (err && err.message ? err.message : err) + ").");
873
+ return;
874
+ }
875
+ const sentences = window.tmctChat.splitSentences(text);
876
+ if (!sentences.length) { addSystemLine("nothing to ingest in " + file.name + "."); return; }
877
+ setBusy(true);
878
+ statusEl.textContent = "ingesting " + file.name + "\\u2026";
879
+ let grounded = 0;
880
+ try {
881
+ for (const sentence of sentences) {
882
+ const result = await session.turn(sentence);
883
+ if (result.record && result.record.via === "assert" && !result.record.miss) grounded += 1;
884
+ }
885
+ } catch (err) {
886
+ addSystemLine("something went wrong ingesting " + file.name + " (" + (err && err.message ? err.message : err) + ").");
887
+ }
888
+ if (grounded) scheduleSave();
889
+ const skipped = sentences.length - grounded;
890
+ addSystemLine("ingested " + file.name + " \\u2014 " + sentences.length + " sentence"
891
+ + (sentences.length === 1 ? "" : "s") + " read, " + grounded + " fact"
892
+ + (grounded === 1 ? "" : "s") + " added"
893
+ + (skipped ? ", " + skipped + " skipped (not a recognized fact shape)" : "") + ".");
894
+ await renderStatsPanel();
895
+ renderStatus();
896
+ setBusy(false);
897
+ inputEl.focus();
898
+ });
899
+
832
900
  // "reset to seed" is the full re-initialisation: drop the persisted payload
833
901
  // outright and reload, so boot re-seeds from the page's shipped seed as if on
834
902
  // a first visit. Harder than "forget everything", which only swaps the live
@@ -28,6 +28,7 @@ import * as defaultSource from "../adapters/source.mjs";
28
28
  import { resolveExtensions, mergedLexiconExtra } from "./extensions.mjs";
29
29
  import { runTurn, hasSeededVocabulary, vocabExampleHint } from "./chat.mjs";
30
30
  import { resolveGameConfig } from "../domain/game-config.mjs";
31
+ import { sessionLogHeaderMarkdown, sessionLogTurnMarkdown, sessionLogEndMarkdown } from "./session-log-format.mjs";
31
32
 
32
33
  /** Where session logs live, relative to the target repo. `.tmct/` is the repo's
33
34
  * one artifact directory (gitignored, machine-local) — flip this single constant
@@ -243,7 +244,7 @@ export async function createSession({
243
244
  const sessionsDir = join(repo, SESSIONS_DIR_REL);
244
245
  await mkdir(logDir, { recursive: true });
245
246
  await mkdir(sessionsDir, { recursive: true });
246
- const logFile = join(logDir, `session-${sessionId}.log`);
247
+ const logFile = join(logDir, `session-${sessionId}.md`);
247
248
  const sidecarFile = join(sessionsDir, `session-${sessionId}.jsonl`);
248
249
  const stream = createWriteStream(logFile, { flags: "a" });
249
250
  const sidecar = createWriteStream(sidecarFile, { flags: "a" });
@@ -255,7 +256,7 @@ export async function createSession({
255
256
  const writeSidecar = (obj) => flush(sidecar, JSON.stringify(obj) + "\n");
256
257
 
257
258
  const startIso = new Date().toISOString();
258
- await writeLog(`# tmct chat ${version} session started ${startIso} repo ${repo}\n\n`);
259
+ await writeLog(sessionLogHeaderMarkdown({ version, sessionId, startedAt: startIso, repo }));
259
260
  await writeSidecar({ type: "session", id: sessionId, started: startIso, repo, tmctVersion: version });
260
261
 
261
262
  // Read-time graph upsert (sessions.mjs): after every turn, the session becomes /
@@ -363,14 +364,14 @@ export async function createSession({
363
364
  } catch (e) {
364
365
  const ts = new Date().toISOString();
365
366
  const message = e instanceof Error ? e.message : String(e);
366
- await writeLog(`${ts}\n> ${line}\nerror: ${message}\n`);
367
+ await writeLog(sessionLogTurnMarkdown({ startedAt: ts, turnNumber: turns + 1, query: line, answer: `error: ${message}` }));
367
368
  const errorRecord = { type: "error", ts, query: line, error: message };
368
369
  await writeSidecar(errorRecord);
369
370
  turnRecords.push(errorRecord);
370
371
  turns += 1;
371
372
  return { answer: `Something went wrong answering that (${message}). Try rephrasing, or /help.`, end: false, prompt: promptFor(focus) };
372
373
  }
373
- const { answer, logLines, record, focus: nextFocus, last: nextLast, end, narrate: nextNarrate, liveReference: nextLiveReference } = result;
374
+ const { answer, record, focus: nextFocus, last: nextLast, end, narrate: nextNarrate, liveReference: nextLiveReference } = result;
374
375
  focus = nextFocus;
375
376
  last = nextLast;
376
377
  if ("planState" in result) planState = result.planState;
@@ -378,8 +379,10 @@ export async function createSession({
378
379
  // same way a focus update does — apply them to this handle's
379
380
  // session-scoped state.
380
381
  if (typeof nextNarrate === "boolean") narrateOn = nextNarrate;
381
- if (typeof nextLiveReference === "boolean") liveReferenceOn = nextLiveReference;
382
- await writeLog(logLines.join("\n") + "\n");
382
+ // tri-state: false (off), true (rescue on a miss), or "supplement" (also
383
+ // append a cited read-out under every grounded answer).
384
+ if (typeof nextLiveReference === "boolean" || nextLiveReference === "supplement") liveReferenceOn = nextLiveReference;
385
+ await writeLog(sessionLogTurnMarkdown({ startedAt: record.ts, turnNumber: turns + 1, query: line, answer }));
383
386
  await writeSidecar(record);
384
387
  turnRecords.push(record);
385
388
  // One telemetry line per dispatched turn (OFF by default → no-op). query.raw is
@@ -401,7 +404,9 @@ export async function createSession({
401
404
  if (closed) return;
402
405
  closed = true;
403
406
  const endIso = new Date().toISOString();
404
- await writeLog(`${endIso}\n> /exit\nsession end ${endIso}\n`);
407
+ const closingTurnNumber = turns + 1;
408
+ await writeLog(sessionLogTurnMarkdown({ startedAt: endIso, turnNumber: closingTurnNumber, query: "/exit", answer: "" }));
409
+ await writeLog(sessionLogEndMarkdown({ endedAt: endIso, turnCount: closingTurnNumber }));
405
410
  await writeSidecar({ type: "end", ts: endIso });
406
411
  await upsertGraph(endIso);
407
412
  await new Promise((resolve) => stream.end(resolve));