@polycode-projects/the-mechanical-code-talker 5.0.5 → 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 (46) hide show
  1. package/README.md +78 -19
  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 +10 -5
  7. package/src/domain/codegraph.mjs +23 -9
  8. package/src/domain/game-config.mjs +12 -0
  9. package/src/domain/interpret/strategies/keywords.mjs +30 -1
  10. package/src/domain/memory/capability.mjs +15 -11
  11. package/src/domain/router/drive.mjs +36 -17
  12. package/src/domain/router/resolver.mjs +63 -17
  13. package/src/domain/spider-fly-world.mjs +2 -2
  14. package/src/domain/sprite-templates.mjs +19 -7
  15. package/src/domain/syllogise.mjs +16 -6
  16. package/src/domain/town-square-world.mjs +1 -1
  17. package/src/services/adventure-viz.mjs +5 -2
  18. package/src/services/adventure.mjs +8 -1
  19. package/src/services/chat-page-viz.mjs +123 -25
  20. package/src/services/chat-session.mjs +60 -10
  21. package/src/services/chat.mjs +328 -36
  22. package/src/services/code-explorer-viz.mjs +3 -2
  23. package/src/services/extract-facts.mjs +47 -7
  24. package/src/services/ingest-viz.mjs +113 -29
  25. package/src/services/ledger-viz.mjs +9 -4
  26. package/src/services/memory-panel-viz.mjs +44 -0
  27. package/src/services/mud-viz.mjs +21 -3
  28. package/src/services/mudiii-scene.mjs +407 -36
  29. package/src/services/mudiii-turn.mjs +65 -9
  30. package/src/services/mudiii-viz.mjs +810 -157
  31. package/src/services/p2p-room.mjs +1 -1
  32. package/src/services/plan-viz.mjs +26 -4
  33. package/src/services/predator-prey.mjs +141 -37
  34. package/src/services/research-viz.mjs +17 -23
  35. package/src/services/spider-fly-turn.mjs +7 -1
  36. package/src/services/spider-fly-viz.mjs +13 -5
  37. package/src/services/sprite-catalog-viz.mjs +3 -2
  38. package/src/services/viz-theme.mjs +20 -0
  39. package/src/services/viz-ticker.mjs +15 -2
  40. package/src/surfaces/http/server-http.mjs +90 -13
  41. package/src/surfaces/web/memory-ask-browser.bundle.js +125 -125
  42. package/src/surfaces/web/mud-browser-entry.mjs +33 -1
  43. package/src/surfaces/web/mudiii-browser-entry.mjs +70 -34
  44. package/src/surfaces/web/tmct-surface.mjs +18 -6
  45. package/src/tools/handlers/tmct-ask.mjs +15 -2
  46. package/src/tools/server.mjs +31 -2
@@ -28,7 +28,7 @@
28
28
  // input. scripts/build-demo-site.mjs calls it directly and writes the result
29
29
  // to public/chat.html, after chat-browser.bundle.js/chat-seed.json already
30
30
  // exist (both built earlier in that same script, for the embedded widget).
31
- import { THEME_TOKENS_CSS, SERIF_STACK, MONO_STACK, escapeHtml } from "./viz-theme.mjs";
31
+ import { THEME_TOKENS_CSS, SERIF_STACK, MONO_STACK, escapeHtml, demoEyebrowHtml, EYEBROW_LINKS_CSS } from "./viz-theme.mjs";
32
32
  import {
33
33
  SHARE_OVERLAY_CSS, shareOverlayHtml, shareStepStates, activeWaves, offerBlobIn, peerTerm,
34
34
  shareMessageFor, replyMessageFor, whatsAppShareUrl, isProbablyMobile, copyTextToClipboard, flashCopyTip,
@@ -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>`,
@@ -498,7 +498,9 @@ ${THEME_TOKENS_CSS}
498
498
  get folded onto a second line by a wide one. */
499
499
  .topbar { flex: 0 0 auto; display: flex; align-items: center; gap: 1rem; padding: .55rem 1.1rem; border-bottom: 1px solid var(--line); flex-wrap: wrap; }
500
500
  .brand { display: flex; align-items: baseline; gap: .55rem; }
501
+ .visually-hidden { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; }
501
502
  .eyebrow { font-family: ${MONO_STACK}; font-size: .78rem; letter-spacing: .08em; color: var(--muted); }
503
+ ${EYEBROW_LINKS_CSS}
502
504
  .legend { display: flex; gap: .8rem; margin-left: auto; font-family: ${MONO_STACK}; font-size: .68rem; color: var(--muted); }
503
505
  .legend-item { display: inline-flex; align-items: center; gap: .32rem; white-space: nowrap; }
504
506
  .dot { width: 7px; height: 7px; border-radius: 50%; display: inline-block; }
@@ -622,6 +624,15 @@ ${THEME_TOKENS_CSS}
622
624
  a pill like its neighbours, with the number itself at reading size. */
623
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; }
624
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; } }
625
636
 
626
637
  .chrome { display: flex; align-items: center; gap: .4rem; flex-wrap: wrap; }
627
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; }
@@ -691,14 +702,15 @@ ${SHARE_OVERLAY_CSS}
691
702
  <body>
692
703
  ${shareOverlayHtml({ withTape: true })}
693
704
  <div class="chatCol">
705
+ <h1 class="visually-hidden">talk to it</h1>
694
706
  <header class="topbar">
695
707
  <div class="brand">
696
- <span class="eyebrow">the-mechanical-code-talker</span>
708
+ <span class="eyebrow">${demoEyebrowHtml("chat", "chat")}</span>
697
709
  </div>
698
710
  <div class="chrome">
699
- <span class="fact-pill" id="factPill" aria-live="polite"
711
+ <span class="fact-pill" id="factPill" aria-live="polite" data-state="loading"
700
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">
701
- <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>
702
714
  </span>
703
715
  <button type="button" class="state-pill" id="statePill" data-tone="idle"
704
716
  title="the shared-world connection; click to open the network panel">
@@ -777,6 +789,7 @@ ${shareOverlayHtml({ withTape: true })}
777
789
  const statsSummaryLine = ${statsSummaryLine.toString()};
778
790
  const clearSiteAssetCaches = ${clearSiteAssetCaches.toString()};
779
791
  const fetchWithProgress = ${fetchWithProgress.toString()};
792
+ const loadSeedPayload = ${loadSeedPayload.toString()};
780
793
  const renderStatsPanelInto = ${renderStatsPanelInto.toString()};
781
794
  const createTicker = ${createTicker.toString()};
782
795
  const prefersReducedMotion = ${prefersReducedMotion.toString()};
@@ -808,7 +821,9 @@ ${shareOverlayHtml({ withTape: true })}
808
821
  const inputEl = el("composerInput");
809
822
  const sendBtn = el("composerSend");
810
823
  const statusEl = el("status");
824
+ const factPillEl = el("factPill");
811
825
  const factPillValueEl = el("factPillValue");
826
+ const factPillUnitEl = el("factPillUnit");
812
827
  const statsPanelEl = el("statsPanelStats");
813
828
  const researchedPanelEl = el("researchedPanel");
814
829
  const wikiModeFieldset = el("wikiMode");
@@ -1017,29 +1032,74 @@ ${shareOverlayHtml({ withTape: true })}
1017
1032
  // seed to hash (the desktop shell's own render).
1018
1033
  const SEED_STAMP = ${JSON.stringify(seedStamp)};
1019
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};
1020
1043
 
1021
1044
  let seedPayload = null;
1022
1045
  let seedFacts = 0;
1023
- // One retry with a cache-busting query param: a CDN edge can serve a
1024
- // corrupted or truncated precompressed response (a transient bad cache
1025
- // entry, not a code defect — real bytes decompress fine, and the same URL
1026
- // fetched moments later is clean), and JSON.parse throwing is the only
1027
- // 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
+
1028
1090
  async function fetchSeed() {
1029
- for (let attempt = 1; attempt <= 2; attempt++) {
1030
- try {
1031
- const bust = attempt === 1 ? "" : (SEED_QUERY ? "&" : "?") + "retry=1";
1032
- const blob = await fetchWithProgress("./chat-seed.json" + SEED_QUERY + bust, (loaded, total) => noteProgress("seed", loaded, total));
1033
- seedPayload = JSON.parse(await blob.text());
1034
- seedFacts = (seedPayload.individuals || []).filter((i) => i.class === "Fact").length;
1035
- return;
1036
- } catch (err) {
1037
- if (attempt === 2) {
1038
- seedPayload = null;
1039
- console.warn("tmct chat: chat-seed.json unavailable — starting unseeded", err);
1040
- }
1041
- }
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;
1042
1101
  }
1102
+ setSeedPhase("indexing", { attempts: outcome.status.attempts });
1043
1103
  }
1044
1104
  const cloneSeed = () => {
1045
1105
  if (!seedPayload) return null;
@@ -1151,7 +1211,7 @@ ${shareOverlayHtml({ withTape: true })}
1151
1211
  catch { return; }
1152
1212
  }
1153
1213
  lastStatsTotal = Number(stats.total || 0);
1154
- factPillValueEl.textContent = lastStatsTotal.toLocaleString();
1214
+ renderFactPill();
1155
1215
  renderStatsPanelInto(statsPanelEl, stats, {
1156
1216
  bandLabel: bandLabelFor,
1157
1217
  onForget: persist ? forgetEverything : null,
@@ -1258,6 +1318,41 @@ ${shareOverlayHtml({ withTape: true })}
1258
1318
  return liveReference === "always" ? "always" : liveReference === "supplement" ? "supplement" : liveReference ? "on" : "off";
1259
1319
  }
1260
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
+
1261
1356
  function renderStatus() {
1262
1357
  const seedPart = seedPayload
1263
1358
  ? "starter memory: " + seedFacts + " facts"
@@ -2320,6 +2415,9 @@ ${shareOverlayHtml({ withTape: true })}
2320
2415
  } else {
2321
2416
  await newSession();
2322
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");
2323
2421
  const stats = await window.tmct.page.memoryStats(window.tmct.session.memoryDir);
2324
2422
  if (savedRecord) restoredCount = stats.taught.length;
2325
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;