@polycode-projects/the-mechanical-code-talker 5.0.6 → 5.0.7

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.
Files changed (38) hide show
  1. package/README.md +6 -0
  2. package/bin/tmct.mjs +63 -2
  3. package/package.json +1 -1
  4. package/src/adapters/memory/core.mjs +23 -0
  5. package/src/domain/ask-vocab.mjs +19 -0
  6. package/src/domain/ask.mjs +8 -1
  7. package/src/domain/interpret/strategies/keywords.mjs +30 -1
  8. package/src/domain/memory/capability.mjs +15 -11
  9. package/src/domain/router/drive.mjs +36 -17
  10. package/src/domain/router/resolver.mjs +63 -17
  11. package/src/domain/spider-fly-world.mjs +2 -2
  12. package/src/domain/sprite-templates.mjs +19 -7
  13. package/src/domain/syllogise.mjs +16 -6
  14. package/src/domain/town-square-world.mjs +1 -1
  15. package/src/services/adventure.mjs +8 -1
  16. package/src/services/chat-page-viz.mjs +118 -23
  17. package/src/services/chat-session.mjs +60 -10
  18. package/src/services/chat.mjs +228 -24
  19. package/src/services/extract-facts.mjs +47 -7
  20. package/src/services/ingest-viz.mjs +108 -25
  21. package/src/services/ledger-viz.mjs +4 -2
  22. package/src/services/memory-panel-viz.mjs +44 -0
  23. package/src/services/mud-viz.mjs +17 -0
  24. package/src/services/mudiii-scene.mjs +271 -24
  25. package/src/services/mudiii-turn.mjs +65 -9
  26. package/src/services/mudiii-viz.mjs +302 -125
  27. package/src/services/plan-viz.mjs +23 -2
  28. package/src/services/predator-prey.mjs +82 -33
  29. package/src/services/research-viz.mjs +12 -19
  30. package/src/services/spider-fly-turn.mjs +7 -1
  31. package/src/services/spider-fly-viz.mjs +10 -3
  32. package/src/services/viz-ticker.mjs +15 -2
  33. package/src/surfaces/http/server-http.mjs +90 -13
  34. package/src/surfaces/web/memory-ask-browser.bundle.js +125 -125
  35. package/src/surfaces/web/mud-browser-entry.mjs +33 -1
  36. package/src/surfaces/web/tmct-surface.mjs +18 -6
  37. package/src/tools/handlers/tmct-ask.mjs +15 -2
  38. package/src/tools/server.mjs +31 -2
@@ -1700,6 +1700,21 @@ export async function retractSubClassOf(repoDir, subject, object, {
1700
1700
  };
1701
1701
  }
1702
1702
 
1703
+ /** subject -> Set(objects) over a subClassOf edge list, the adjacency
1704
+ * `findIsaChain` walks. Building it is O(edges); a search from one subject is
1705
+ * O(that subject's own reachable set). A caller chasing MANY subjects over the
1706
+ * same edges should build this once and hand it to `findIsaChain` in place of
1707
+ * the edge list, or every small search pays for the whole graph again. */
1708
+ export function buildSubClassSuccessors(subClassEdges) {
1709
+ const subSucc = new Map();
1710
+ for (const [a, b] of subClassEdges || []) {
1711
+ if (!a || !b || a === b) continue;
1712
+ if (!subSucc.has(a)) subSucc.set(a, new Set());
1713
+ subSucc.get(a).add(b);
1714
+ }
1715
+ return subSucc;
1716
+ }
1717
+
1703
1718
  /**
1704
1719
  * PROOF SEARCH (not a third rule — a bounded rooted chase for a single "does
1705
1720
  * `subj` reach one of `targets`?" query). Walks OUTWARD from `subj` only,
@@ -1714,12 +1729,7 @@ export async function retractSubClassOf(repoDir, subject, object, {
1714
1729
  */
1715
1730
  export function findIsaChain(subj, targets, typeEdges, subClassEdges, { maxHops = 6 } = {}) {
1716
1731
  const targetSet = targets instanceof Set ? targets : new Set(targets || []);
1717
- const subSucc = new Map();
1718
- for (const [a, b] of subClassEdges || []) {
1719
- if (!a || !b || a === b) continue;
1720
- if (!subSucc.has(a)) subSucc.set(a, new Set());
1721
- subSucc.get(a).add(b);
1722
- }
1732
+ const subSucc = subClassEdges instanceof Map ? subClassEdges : buildSubClassSuccessors(subClassEdges);
1723
1733
 
1724
1734
  let frontier = [];
1725
1735
  for (const [x, c] of typeEdges || []) {
@@ -225,7 +225,7 @@ const TOWN_SQUARE_MARKET = layout({
225
225
 
226
226
  /** The chapel corner: an L of buildings in the north-west, a fence line across
227
227
  * the south, three oaks. The only shipped layout with two predators, so it is
228
- * where the avoid branch actually runs. */
228
+ * where two hunters working the same board show up. */
229
229
  const TOWN_SQUARE_CHAPEL = layout({
230
230
  name: "town-square-chapel",
231
231
  gridSize: 14,
@@ -1893,7 +1893,14 @@ export async function runWorldCommand(cmd, { world, memoryDir, env, graph, cache
1893
1893
  );
1894
1894
  }
1895
1895
  const digest = await worldDigest(object, { memoryDir, memory, rows, state, graph, actingSubject });
1896
- const body = digest ?? `nothing more about the ${object} is written down yet.`;
1896
+ // A thing that IS here with nothing written about it, and a word that only
1897
+ // turns up in the room's own prose, are different answers. Sharing one line
1898
+ // let "look at the door" reply "nothing more about the door is written down
1899
+ // yet" in a house whose world model has no door at all, which reads as
1900
+ // confirmation that a door is standing there.
1901
+ const body = digest ?? (notHere
1902
+ ? `there's no ${object} here — the word turns up in what's written about this place, but nothing by that name is in the scene.`
1903
+ : `nothing more about the ${object} is written down yet.`);
1897
1904
  const containerNote = !person && isContainer(rows, object) ? ` ${containerStatusPhrase(object, { state })}` : "";
1898
1905
  // Framing follows the VERB the player typed, not the object's type: talking
1899
1906
  // to a lamp still reads as an attempted conversation (nothing replies, but
@@ -36,7 +36,7 @@ import {
36
36
  import { provBucketFor } from "./ledger-viz.mjs";
37
37
  import { createTicker, prefersReducedMotion } from "./viz-ticker.mjs";
38
38
  import { sessionLogTimeOfDay, sessionLogHeaderMarkdown, sessionLogTurnMarkdown } from "./session-log-format.mjs";
39
- import { bandLabelFor, statsSummaryLine, clearSiteAssetCaches, fetchWithProgress, renderStatsPanelInto } from "./memory-panel-viz.mjs";
39
+ import { bandLabelFor, statsSummaryLine, clearSiteAssetCaches, fetchWithProgress, loadSeedPayload, renderStatsPanelInto } from "./memory-panel-viz.mjs";
40
40
 
41
41
  const DEFAULT_TITLE = "the-mechanical-code-talker — talk to it";
42
42
 
@@ -446,7 +446,7 @@ export function transcriptMarkdown(turns, meta, headerMd, turnMd) {
446
446
  * live digest-bank twin (see chat-browser-entry.mjs) rather than to a
447
447
  * client-side digest panel of this page's own; an empty list degrades to the
448
448
  * flat list exactly as before this page could digest at all. */
449
- export function renderChatHtml({ title = DEFAULT_TITLE, digestStructures = [], seedStamp = "" } = {}) {
449
+ export function renderChatHtml({ title = DEFAULT_TITLE, digestStructures = [], seedStamp = "", seedBytes = 0 } = {}) {
450
450
  const digestStructuresJson = JSON.stringify(Array.isArray(digestStructures) ? digestStructures : []);
451
451
  const legendHtml = PROV_LEGEND.map(
452
452
  ([key, label]) => `<span class="legend-item"><i class="dot dot-${provKey(key)}"></i>${escapeHtml(label)}</span>`,
@@ -624,6 +624,15 @@ ${THEME_TOKENS_CSS}
624
624
  a pill like its neighbours, with the number itself at reading size. */
625
625
  .fact-pill { display: inline-flex; align-items: baseline; gap: .34rem; font-family: ${MONO_STACK}; font-size: .68rem; letter-spacing: .06em; text-transform: uppercase; color: var(--muted); border: 1px solid var(--corpus-t1); border-radius: 99px; padding: .2rem .8rem; background: var(--corpus-soft); white-space: nowrap; }
626
626
  .fact-pill .fact-pill-value { font-size: .96rem; letter-spacing: 0; font-variant-numeric: tabular-nums; font-weight: 600; color: var(--ink); }
627
+ /* While the starter memory streams in, the pill says so and breathes. The
628
+ graph is a moving state: what it knows now is less than what it will know
629
+ in a moment, and a question asked mid-load gets answered against what has
630
+ actually arrived. */
631
+ .fact-pill[data-state="loading"] { animation: pill-breathe 1.6s ease-in-out infinite; }
632
+ .fact-pill[data-state="loading"] .fact-pill-value { font-size: .68rem; letter-spacing: .06em; text-transform: uppercase; font-weight: 600; }
633
+ .fact-pill[data-state="failed"] { border-color: var(--miss-t1, var(--corpus-t1)); }
634
+ @keyframes pill-breathe { 0%, 100% { opacity: 1; } 50% { opacity: .55; } }
635
+ @media (prefers-reduced-motion: reduce) { .fact-pill[data-state="loading"] { animation: none; } }
627
636
 
628
637
  .chrome { display: flex; align-items: center; gap: .4rem; flex-wrap: wrap; }
629
638
  .chrome-btn { font-family: ${SERIF_STACK}; font-size: .8rem; color: var(--muted); border: 1px solid var(--line); border-radius: 99px; padding: .22rem .75rem; background: var(--card); text-decoration: none; display: inline-flex; align-items: center; gap: .32rem; white-space: nowrap; line-height: 1.35; }
@@ -699,9 +708,9 @@ ${shareOverlayHtml({ withTape: true })}
699
708
  <span class="eyebrow">${demoEyebrowHtml("chat", "chat")}</span>
700
709
  </div>
701
710
  <div class="chrome">
702
- <span class="fact-pill" id="factPill" aria-live="polite"
711
+ <span class="fact-pill" id="factPill" aria-live="polite" data-state="loading"
703
712
  title="every fact this session's memory holds right now — the starter memory it shipped with plus anything you have taught, researched or ingested">
704
- <span class="fact-pill-value" id="factPillValue">&mdash;</span> facts
713
+ <span class="fact-pill-value" id="factPillValue">loading</span><span id="factPillUnit"> starter memory&hellip;</span>
705
714
  </span>
706
715
  <button type="button" class="state-pill" id="statePill" data-tone="idle"
707
716
  title="the shared-world connection; click to open the network panel">
@@ -780,6 +789,7 @@ ${shareOverlayHtml({ withTape: true })}
780
789
  const statsSummaryLine = ${statsSummaryLine.toString()};
781
790
  const clearSiteAssetCaches = ${clearSiteAssetCaches.toString()};
782
791
  const fetchWithProgress = ${fetchWithProgress.toString()};
792
+ const loadSeedPayload = ${loadSeedPayload.toString()};
783
793
  const renderStatsPanelInto = ${renderStatsPanelInto.toString()};
784
794
  const createTicker = ${createTicker.toString()};
785
795
  const prefersReducedMotion = ${prefersReducedMotion.toString()};
@@ -811,7 +821,9 @@ ${shareOverlayHtml({ withTape: true })}
811
821
  const inputEl = el("composerInput");
812
822
  const sendBtn = el("composerSend");
813
823
  const statusEl = el("status");
824
+ const factPillEl = el("factPill");
814
825
  const factPillValueEl = el("factPillValue");
826
+ const factPillUnitEl = el("factPillUnit");
815
827
  const statsPanelEl = el("statsPanelStats");
816
828
  const researchedPanelEl = el("researchedPanel");
817
829
  const wikiModeFieldset = el("wikiMode");
@@ -1020,29 +1032,74 @@ ${shareOverlayHtml({ withTape: true })}
1020
1032
  // seed to hash (the desktop shell's own render).
1021
1033
  const SEED_STAMP = ${JSON.stringify(seedStamp)};
1022
1034
  const SEED_QUERY = SEED_STAMP ? "?b=" + SEED_STAMP : "";
1035
+ // The exact byte count of the chat-seed.json this build shipped, measured by
1036
+ // the builder. It is the denominator the progress percentage needs and the
1037
+ // response itself cannot supply: over a compressed transfer Content-Length
1038
+ // names the wire size, while the stream a reader drains yields decompressed
1039
+ // bytes, so dividing one by the other would read past 100% within a second.
1040
+ // Both numbers here are decompressed bytes, so the ratio is a real fraction
1041
+ // of a real file, never an estimate against elapsed time.
1042
+ const SEED_BYTES = ${Number(seedBytes) || 0};
1023
1043
 
1024
1044
  let seedPayload = null;
1025
1045
  let seedFacts = 0;
1026
- // One retry with a cache-busting query param: a CDN edge can serve a
1027
- // corrupted or truncated precompressed response (a transient bad cache
1028
- // entry, not a code defect — real bytes decompress fine, and the same URL
1029
- // fetched moments later is clean), and JSON.parse throwing is the only
1030
- // signal of that. The bust param forces a fresh fetch past that one entry.
1046
+ let seedLoadedBytes = 0;
1047
+ let seedTotalBytes = SEED_BYTES;
1048
+ let seedPercentShown = -1;
1049
+
1050
+ /**
1051
+ * What the starter memory is doing right now, published as tmct.seed for the
1052
+ * page's own pill and for anything driving this page:
1053
+ *
1054
+ * loading the seed is still coming down (percent, when a real total exists)
1055
+ * indexing it arrived and parsed; the session is being opened from it
1056
+ * ready the session holds it — a question now grounds against the whole seed
1057
+ * failed it did not arrive; this session starts empty, and says so
1058
+ * skipped nothing was asked for (ingest's seed toggle, off)
1059
+ *
1060
+ * There is no moment before "ready" that the page refuses to speak: a
1061
+ * question asked mid-load is answered against what the store actually holds
1062
+ * at that moment, which is the open-world assumption doing its job. The
1063
+ * phases exist so the page can SAY the graph is still growing, and so a test
1064
+ * that asserts on seeded content can wait for the seed to be in.
1065
+ */
1066
+ function setSeedPhase(phase, extra) {
1067
+ window.tmct.seed = Object.assign({ state: phase, facts: seedFacts }, extra || {});
1068
+ renderFactPill();
1069
+ }
1070
+ setSeedPhase("loading");
1071
+
1072
+ function noteSeedBytes(loaded, total) {
1073
+ seedLoadedBytes = loaded;
1074
+ seedTotalBytes = total;
1075
+ const percent = seedProgressPercent();
1076
+ // One DOM write per whole percentage point, not one per streamed chunk.
1077
+ if (percent === seedPercentShown) return;
1078
+ seedPercentShown = percent;
1079
+ renderFactPill();
1080
+ }
1081
+
1082
+ /** The seed's real progress, 0-100, or null when no true total is known —
1083
+ * the pill then shows that it is loading and no number at all, rather than
1084
+ * a figure inferred from how long it has been going. */
1085
+ function seedProgressPercent() {
1086
+ if (!(seedTotalBytes > 0)) return null;
1087
+ return Math.min(100, Math.floor((seedLoadedBytes / seedTotalBytes) * 100));
1088
+ }
1089
+
1031
1090
  async function fetchSeed() {
1032
- for (let attempt = 1; attempt <= 2; attempt++) {
1033
- try {
1034
- const bust = attempt === 1 ? "" : (SEED_QUERY ? "&" : "?") + "retry=1";
1035
- const blob = await fetchWithProgress("./chat-seed.json" + SEED_QUERY + bust, (loaded, total) => noteProgress("seed", loaded, total));
1036
- seedPayload = JSON.parse(await blob.text());
1037
- seedFacts = (seedPayload.individuals || []).filter((i) => i.class === "Fact").length;
1038
- return;
1039
- } catch (err) {
1040
- if (attempt === 2) {
1041
- seedPayload = null;
1042
- console.warn("tmct chat: chat-seed.json unavailable — starting unseeded", err);
1043
- }
1044
- }
1091
+ const outcome = await loadSeedPayload(fetchWithProgress, "./chat-seed.json", SEED_QUERY, (loaded, total) => {
1092
+ noteProgress("seed", loaded, total || SEED_BYTES);
1093
+ noteSeedBytes(loaded, total || SEED_BYTES);
1094
+ });
1095
+ seedPayload = outcome.payload;
1096
+ seedFacts = outcome.status.facts;
1097
+ if (outcome.status.state === "failed") {
1098
+ console.error("tmct chat: chat-seed.json unavailable — starting unseeded (" + outcome.status.error + ")");
1099
+ setSeedPhase("failed", { error: outcome.status.error, attempts: outcome.status.attempts });
1100
+ return;
1045
1101
  }
1102
+ setSeedPhase("indexing", { attempts: outcome.status.attempts });
1046
1103
  }
1047
1104
  const cloneSeed = () => {
1048
1105
  if (!seedPayload) return null;
@@ -1154,7 +1211,7 @@ ${shareOverlayHtml({ withTape: true })}
1154
1211
  catch { return; }
1155
1212
  }
1156
1213
  lastStatsTotal = Number(stats.total || 0);
1157
- factPillValueEl.textContent = lastStatsTotal.toLocaleString();
1214
+ renderFactPill();
1158
1215
  renderStatsPanelInto(statsPanelEl, stats, {
1159
1216
  bandLabel: bandLabelFor,
1160
1217
  onForget: persist ? forgetEverything : null,
@@ -1261,6 +1318,41 @@ ${shareOverlayHtml({ withTape: true })}
1261
1318
  return liveReference === "always" ? "always" : liveReference === "supplement" ? "supplement" : liveReference ? "on" : "off";
1262
1319
  }
1263
1320
 
1321
+ /** The header pill, in whichever of its three states the page is in: the
1322
+ * starter memory still streaming in, the live fact count once there is one,
1323
+ * or a starter memory that never arrived. It is the same pill throughout —
1324
+ * the count it settles on replaces the loading words in place, so a visitor
1325
+ * watching it sees the memory arrive rather than a separate spinner
1326
+ * vanishing. */
1327
+ function renderFactPill() {
1328
+ const seedState = window.tmct.seed ? window.tmct.seed.state : "loading";
1329
+ if (seedState === "loading") {
1330
+ const percent = seedProgressPercent();
1331
+ factPillEl.dataset.state = "loading";
1332
+ factPillValueEl.textContent = percent === null ? "loading" : percent + "%";
1333
+ factPillUnitEl.textContent = percent === null ? " starter memory\\u2026" : " of starter memory";
1334
+ return;
1335
+ }
1336
+ // "ready" lands a moment before the first memoryStats read returns, and
1337
+ // there is no count to show until it does. Holding the indexing words over
1338
+ // that gap keeps the pill from flashing a zero it does not mean.
1339
+ if (seedState === "indexing" || (seedState === "ready" && lastStatsTotal === null)) {
1340
+ factPillEl.dataset.state = "loading";
1341
+ factPillValueEl.textContent = "indexing";
1342
+ factPillUnitEl.textContent = " " + seedFacts.toLocaleString() + " facts\\u2026";
1343
+ return;
1344
+ }
1345
+ if (seedState === "failed" && lastStatsTotal === null) {
1346
+ factPillEl.dataset.state = "failed";
1347
+ factPillValueEl.textContent = "no";
1348
+ factPillUnitEl.textContent = " starter memory";
1349
+ return;
1350
+ }
1351
+ factPillEl.dataset.state = seedState === "failed" ? "failed" : "ready";
1352
+ factPillValueEl.textContent = Number(lastStatsTotal || 0).toLocaleString();
1353
+ factPillUnitEl.textContent = " facts";
1354
+ }
1355
+
1264
1356
  function renderStatus() {
1265
1357
  const seedPart = seedPayload
1266
1358
  ? "starter memory: " + seedFacts + " facts"
@@ -2323,6 +2415,9 @@ ${shareOverlayHtml({ withTape: true })}
2323
2415
  } else {
2324
2416
  await newSession();
2325
2417
  }
2418
+ // The session now holds whatever the seed brought, so the phase settles
2419
+ // here rather than when the bytes landed: "ready" means queryable.
2420
+ if (window.tmct.seed.state !== "failed") setSeedPhase("ready");
2326
2421
  const stats = await window.tmct.page.memoryStats(window.tmct.session.memoryDir);
2327
2422
  if (savedRecord) restoredCount = stats.taught.length;
2328
2423
  const restoredNote = savedRecord
@@ -40,6 +40,12 @@ export const SESSION_LOG_DIR = ".tmct";
40
40
  /** The base (no-focus) prompt. With a focus set the shell shows `tmct(label)>`. */
41
41
  export const PROMPT = "tmct> ";
42
42
 
43
+ /** What a teach or a retract carries in a session that keeps nothing (--ephemeral,
44
+ * tmct.toml's [graph] read_only, or the "memory" backend). */
45
+ export const DISCARDED_WRITE_NOTE =
46
+ "(this session keeps nothing — the fact is gone when it ends. Run without --ephemeral, "
47
+ + "or on a stored backend, to keep it.)";
48
+
43
49
  // ---- repo-root resolution: default the target to the GIT ROOT, not raw cwd ----
44
50
 
45
51
  /** The git top-level for `cwd`, or null if not in a repo (or git is unavailable).
@@ -142,6 +148,12 @@ export async function createSession({
142
148
  // lifetime (four mud.html characters means four sessions, not one session
143
149
  // switching identity turn to turn).
144
150
  actingSubject,
151
+ // The session-log filesystem seam: mkdir/createWriteStream, injectable so a
152
+ // test can exercise the unwritable-log guard by rejecting at the seam
153
+ // itself rather than by making a real directory unwritable — chmod-based
154
+ // unwritability is invisible to a process running as root (CI's container
155
+ // user), which the real filesystem can't be made to enforce portably.
156
+ logFs = { mkdir, createWriteStream },
145
157
  // Marks a world already LIVE for this session, bypassing openAdventure's
146
158
  // "play <world>" opener — that opener requires a shipped "player"
147
159
  // individual (its own protection against mis-firing on a non-adventure
@@ -250,6 +262,8 @@ export async function createSession({
250
262
  // Ephemeral: keep config.graphFile pointing at the READ graph, but divert the
251
263
  // write base (repo → logs/memory/sessions) to a throwaway temp dir. The committed
252
264
  // target is never touched; the demo's memory simply doesn't persist across runs.
265
+ // The banner still names the repo the user asked about, not the scratch dir.
266
+ const readRepo = repo;
253
267
  if (ephemeral) repo = await mkdtemp(join(tmpdir(), "tmct-ephemeral-"));
254
268
 
255
269
  // Load the graph once up front — the banner needs the module count, and focus/`it`
@@ -286,12 +300,28 @@ export async function createSession({
286
300
  const sessionId = uuidv7();
287
301
  const logDir = join(repo, SESSION_LOG_DIR);
288
302
  const sessionsDir = join(repo, SESSIONS_DIR_REL);
289
- await mkdir(logDir, { recursive: true });
290
- await mkdir(sessionsDir, { recursive: true });
303
+ // Every session records what it answered, so an unwritable target is the end
304
+ // of the session. Say so once, in one place, whichever step hits it: the
305
+ // mkdir, the stream's open, or the header write below.
306
+ const unwritable = (e) => new Error(
307
+ `cannot write the session log under ${logDir} (${e?.code || e?.message || e}). `
308
+ + "Every session records what it answered, so chat needs write access there. "
309
+ + "Point --repo at a writable directory, or run with --ephemeral to keep the session in a temp dir.",
310
+ );
311
+ try {
312
+ await logFs.mkdir(logDir, { recursive: true });
313
+ await logFs.mkdir(sessionsDir, { recursive: true });
314
+ } catch (e) { throw unwritable(e); }
291
315
  const logFile = join(logDir, `session-${sessionId}.md`);
292
316
  const sidecarFile = join(sessionsDir, `session-${sessionId}.jsonl`);
293
- const stream = createWriteStream(logFile, { flags: "a" });
294
- const sidecar = createWriteStream(sidecarFile, { flags: "a" });
317
+ const stream = logFs.createWriteStream(logFile, { flags: "a" });
318
+ const sidecar = logFs.createWriteStream(sidecarFile, { flags: "a" });
319
+ // A stream with no "error" listener turns a failed open into an unhandled
320
+ // 'error' event, which is a raw Node stack trace and a dead process. The
321
+ // write callbacks below reject with the same error, so the listener only has
322
+ // to keep the event handled.
323
+ stream.on("error", () => {});
324
+ sidecar.on("error", () => {});
295
325
  // Awaited writes: each chunk is handed to the OS before the turn completes, so a
296
326
  // killed session keeps everything up to the last completed turn — in both files.
297
327
  const flush = (s, text) =>
@@ -300,8 +330,14 @@ export async function createSession({
300
330
  const writeSidecar = (obj) => flush(sidecar, JSON.stringify(obj) + "\n");
301
331
 
302
332
  const startIso = new Date().toISOString();
303
- await writeLog(sessionLogHeaderMarkdown({ version, sessionId, startedAt: startIso, repo }));
304
- await writeSidecar({ type: "session", id: sessionId, started: startIso, repo, tmctVersion: version });
333
+ try {
334
+ await writeLog(sessionLogHeaderMarkdown({ version, sessionId, startedAt: startIso, repo }));
335
+ await writeSidecar({ type: "session", id: sessionId, started: startIso, repo, tmctVersion: version });
336
+ } catch (e) {
337
+ stream.destroy();
338
+ sidecar.destroy();
339
+ throw unwritable(e);
340
+ }
305
341
 
306
342
  // Read-time graph upsert (sessions.mjs): after every turn, the session becomes /
307
343
  // stays a first-class Session individual in graph.json (crash-safe: turn n is in
@@ -357,12 +393,20 @@ export async function createSession({
357
393
  // entities (the degenerate trap). Both get orienting, non-over-promising banner
358
394
  // + greeting messaging rather than a silent dead-end.
359
395
  const noCodeGraph = moduleCount === 0;
396
+ // A discarding session must not say the conversation is kept. Ephemeral
397
+ // diverts every write to a temp dir and suppresses the graph upsert; the
398
+ // "memory" backend keeps the store in-process. Both end when the process does.
399
+ const discardsWrites = ephemeral || backendChoice === "memory";
400
+ const whereItGoes = discardsWrites
401
+ ? "nothing is written back — this session's facts and log are dropped when it ends"
402
+ : `the conversation is remembered to ${DEFAULT_GRAPH_REL} — log ${logFile}`;
360
403
  const bannerLines = [
361
404
  noCodeGraph
362
405
  // No code graph: honest, orienting messaging — never an error before the prompt.
363
- ? `tmct chat — ${repo} — no code graph loaded — ${empty ? "starting empty" : "graph has no code entities"}; ` +
364
- `the conversation is remembered to ${DEFAULT_GRAPH_REL} — log ${logFile}`
365
- : `tmct chat — ${repo} — ${moduleCount} module(s) — log ${logFile}`,
406
+ ? `tmct chat — ${readRepo} — no code graph loaded — ${empty ? "starting empty" : "graph has no code entities"}; ` +
407
+ `${whereItGoes}`
408
+ : `tmct chat — ${readRepo} — ${moduleCount} module(s) — `
409
+ + (discardsWrites ? `${whereItGoes}` : `log ${logFile}`),
366
410
  // the honest seed line appears ONLY on the run that actually seeded — the count
367
411
  // is the TOTAL appended, split into the curated SEON ontology + the ConceptNet band
368
412
  // (+ any other active extension bundle, e.g. an activated tier-2 corpus).
@@ -429,7 +473,13 @@ export async function createSession({
429
473
  turns += 1;
430
474
  return { answer: `Something went wrong answering that (${message}). Try rephrasing, or /help.`, end: false, prompt: promptFor(focus) };
431
475
  }
432
- const { answer, record, focus: nextFocus, last: nextLast, end, narrate: nextNarrate, liveReference: nextLiveReference } = result;
476
+ const { record, focus: nextFocus, last: nextLast, end, narrate: nextNarrate, liveReference: nextLiveReference } = result;
477
+ // "noted — remembered" reports a durable write. In a session that keeps
478
+ // nothing it is the last word on a fact that dies with the process, so
479
+ // say where the fact actually went.
480
+ const answer = discardsWrites && (record.via === "assert" || record.via === "retract")
481
+ ? `${result.answer}\n${DISCARDED_WRITE_NOTE}`
482
+ : result.answer;
433
483
  focus = nextFocus;
434
484
  last = nextLast;
435
485
  if ("planState" in result) planState = result.planState;