@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.
- package/README.md +6 -0
- package/bin/tmct.mjs +63 -2
- package/package.json +1 -1
- package/src/adapters/memory/core.mjs +23 -0
- package/src/domain/ask-vocab.mjs +19 -0
- package/src/domain/ask.mjs +8 -1
- package/src/domain/interpret/strategies/keywords.mjs +30 -1
- package/src/domain/memory/capability.mjs +15 -11
- package/src/domain/router/drive.mjs +36 -17
- package/src/domain/router/resolver.mjs +63 -17
- package/src/domain/spider-fly-world.mjs +2 -2
- package/src/domain/sprite-templates.mjs +19 -7
- package/src/domain/syllogise.mjs +16 -6
- package/src/domain/town-square-world.mjs +1 -1
- package/src/services/adventure.mjs +8 -1
- package/src/services/chat-page-viz.mjs +118 -23
- package/src/services/chat-session.mjs +60 -10
- package/src/services/chat.mjs +228 -24
- package/src/services/extract-facts.mjs +47 -7
- package/src/services/ingest-viz.mjs +108 -25
- package/src/services/ledger-viz.mjs +4 -2
- package/src/services/memory-panel-viz.mjs +44 -0
- package/src/services/mud-viz.mjs +17 -0
- package/src/services/mudiii-scene.mjs +271 -24
- package/src/services/mudiii-turn.mjs +65 -9
- package/src/services/mudiii-viz.mjs +302 -125
- package/src/services/plan-viz.mjs +23 -2
- package/src/services/predator-prey.mjs +82 -33
- package/src/services/research-viz.mjs +12 -19
- package/src/services/spider-fly-turn.mjs +7 -1
- package/src/services/spider-fly-viz.mjs +10 -3
- package/src/services/viz-ticker.mjs +15 -2
- package/src/surfaces/http/server-http.mjs +90 -13
- package/src/surfaces/web/memory-ask-browser.bundle.js +125 -125
- package/src/surfaces/web/mud-browser-entry.mjs +33 -1
- package/src/surfaces/web/tmct-surface.mjs +18 -6
- package/src/tools/handlers/tmct-ask.mjs +15 -2
- package/src/tools/server.mjs +31 -2
package/src/domain/syllogise.mjs
CHANGED
|
@@ -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 =
|
|
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
|
|
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
|
-
|
|
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"
|
|
713
|
+
<span class="fact-pill-value" id="factPillValue">loading</span><span id="factPillUnit"> starter memory…</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
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
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
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
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
|
-
|
|
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
|
-
|
|
290
|
-
|
|
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
|
-
|
|
304
|
-
|
|
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 — ${
|
|
364
|
-
|
|
365
|
-
: `tmct chat — ${
|
|
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 {
|
|
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;
|