@polycode-projects/the-mechanical-code-talker 2.10.3 → 2.11.0

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 (52) hide show
  1. package/README.md +68 -12
  2. package/bin/tmct.mjs +5 -2
  3. package/corpus/sprites/src/sprite-facts.jsonl +18 -0
  4. package/corpus/worlds/manifest.json +5 -5
  5. package/corpus/worlds/shards/ashcombe-hall.jsonl.gz +0 -0
  6. package/corpus/worlds/src/ashcombe-hall.jsonl +27 -0
  7. package/data/sprites/book-icon.toml +12 -0
  8. package/data/sprites/cellar-icon.toml +12 -0
  9. package/data/sprites/drawing-room-icon.toml +13 -0
  10. package/data/sprites/garden-icon.toml +12 -0
  11. package/data/sprites/kitchen-icon.toml +13 -0
  12. package/data/sprites/library-icon.toml +12 -0
  13. package/data/sprites/pan-icon.toml +11 -0
  14. package/data/sprites/study-icon.toml +12 -0
  15. package/package.json +7 -2
  16. package/src/adapters/corpus/wikipedia-live.mjs +182 -26
  17. package/src/adapters/corpus/worlds-pack.mjs +8 -2
  18. package/src/adapters/memory/core.mjs +8 -1
  19. package/src/adapters/toml-config.mjs +6 -0
  20. package/src/domain/cli-verbs.mjs +2 -0
  21. package/src/domain/memory/trust.mjs +32 -2
  22. package/src/domain/sense-split.mjs +203 -0
  23. package/src/domain/worlds-pack.mjs +50 -0
  24. package/src/services/adventure-autoplay.mjs +5 -2
  25. package/src/services/adventure-viz.mjs +301 -33
  26. package/src/services/adventure.mjs +162 -14
  27. package/src/services/chat-page-viz.mjs +341 -197
  28. package/src/services/chat-session.mjs +24 -9
  29. package/src/services/chat.mjs +580 -47
  30. package/src/services/code-explorer-viz.mjs +198 -76
  31. package/src/services/extract-facts.mjs +384 -82
  32. package/src/services/fold.mjs +1 -1
  33. package/src/services/ingest-viz.mjs +637 -0
  34. package/src/services/ledger-viz.mjs +209 -0
  35. package/src/services/memory-panel-viz.mjs +159 -0
  36. package/src/services/research.mjs +266 -0
  37. package/src/services/sentences.mjs +19 -0
  38. package/src/services/session-log-format.mjs +64 -0
  39. package/src/services/sessions.mjs +56 -22
  40. package/src/services/spider-fly-turn.mjs +54 -1
  41. package/src/services/spider-fly-viz.mjs +41 -23
  42. package/src/surfaces/web/adventure-browser-entry.mjs +9 -5
  43. package/src/surfaces/web/chat-browser-entry.mjs +32 -11
  44. package/src/surfaces/web/code-explorer-browser-entry.mjs +27 -11
  45. package/src/surfaces/web/ingest-browser-entry.mjs +208 -0
  46. package/src/surfaces/web/ledger-browser-entry.mjs +24 -5
  47. package/src/surfaces/web/memory-ask-browser.bundle.js +134 -125
  48. package/src/surfaces/web/memory-stats.mjs +53 -0
  49. package/src/tools/definitions.mjs +14 -0
  50. package/src/tools/handlers/index.mjs +2 -0
  51. package/src/tools/handlers/tmct-ingest.mjs +43 -0
  52. package/src/tools/server.mjs +5 -2
@@ -0,0 +1,266 @@
1
+ // research.mjs — the "research <topic>" lane: a Simple English Wikipedia
2
+ // queue that grounds one topic per turn. Depth 0 is the requested topic
3
+ // (opensearch + summary, ingested as graph facts); the topics its lead
4
+ // section links to queue at depth 1, capped by the request's own
5
+ // "limit N" or the configured default. Every completed search reports back
6
+ // as its own chat turn — the queue advances one step per "research next"
7
+ // (or a bare "next" while nothing else owns it), which is exactly what the
8
+ // web pages' auto-play button submits.
9
+ //
10
+ // No node builtins — this module ships in the browser bundles unchanged.
11
+ // The provider (network) and the ingest step (memory writes) are both
12
+ // injected by the caller (chat.mjs), so this file owns only the queue
13
+ // mechanics, the request grammar and the reported prose.
14
+ //
15
+ // Consent posture: an explicit "research <topic>" request IS the network
16
+ // consent for its own fetches. Unlike the clean-miss rescue (which fires on
17
+ // an ordinary question and therefore hides behind /wiki on), nobody types
18
+ // "research owls" without meaning "go and look owls up" — the reply names
19
+ // the source it reached either way. The /wiki toggle keeps governing every
20
+ // other lane unchanged.
21
+ //
22
+ // The abstention invariant holds throughout: a topic whose fetch or
23
+ // grounding fails reports the miss plainly, stores nothing, and the queue
24
+ // moves on. No fact is ever fabricated to keep a research run tidy.
25
+
26
+ import { normFactTerm } from "../domain/hash.mjs";
27
+ import { loadLexicon, lookupNoun } from "../domain/grammar/lexicon.mjs";
28
+
29
+ /** The search key a topic folds to: normFactTerm, then the lexicon lemma
30
+ * when the noun is known ("owls" → "owl") — the same fold the live
31
+ * clean-miss gate applies, and what keeps the provider's topic-drift guard
32
+ * happy with an inflected request. An unknown word keys on its own folded
33
+ * form (a topic the lexicon has never met is a fine thing to research). */
34
+ export function researchTopicKey(topic, lexicon = null) {
35
+ const t = normFactTerm(topic);
36
+ if (!t) return "";
37
+ try {
38
+ const lex = lexicon ?? loadLexicon();
39
+ const entry = lookupNoun(lex, t);
40
+ if (entry) return normFactTerm(entry.lemma) || t;
41
+ } catch { /* lexicon unavailable — the folded form still works */ }
42
+ return t;
43
+ }
44
+
45
+ /** The most linked topics any request or config may queue at depth 1 —
46
+ * the fair-use cap on a research run's total round trips. */
47
+ export const RESEARCH_FANOUT_MAX = 12;
48
+
49
+ export const RESEARCH_DEFAULTS = Object.freeze({
50
+ fanoutLimit: 5,
51
+ depthLimit: 1,
52
+ minIntervalMs: 2000,
53
+ });
54
+
55
+ const clampInt = (n, lo, hi) => Math.min(hi, Math.max(lo, Math.floor(n)));
56
+
57
+ /** tmct.toml's `[research]` table → the lane's effective knobs, shipped
58
+ * defaults filling every unset key (the same posture resolveGameConfig
59
+ * takes with `[games.*]`). `fanout_limit` caps at RESEARCH_FANOUT_MAX;
60
+ * `depth_limit` is 0 (no fan-out) or 1 (the depths engineered today);
61
+ * `min_interval_ms` may only RAISE the polite floor between round trips,
62
+ * never lower it. */
63
+ export function resolveResearchConfig(toml = null) {
64
+ const raw = toml?.research || {};
65
+ const cfg = { ...RESEARCH_DEFAULTS };
66
+ const fanout = Number(raw.fanout_limit);
67
+ if (Number.isFinite(fanout)) cfg.fanoutLimit = clampInt(fanout, 0, RESEARCH_FANOUT_MAX);
68
+ const depth = Number(raw.depth_limit);
69
+ if (Number.isFinite(depth)) cfg.depthLimit = clampInt(depth, 0, 1);
70
+ const interval = Number(raw.min_interval_ms);
71
+ if (Number.isFinite(interval)) cfg.minIntervalMs = Math.max(RESEARCH_DEFAULTS.minIntervalMs, interval);
72
+ return cfg;
73
+ }
74
+
75
+ // The verbs that step/inspect/end a run, checked before the start shape so
76
+ // "research next" never parses as a topic called "next".
77
+ const RESEARCH_NEXT_RE = /^research[,:]?\s+(?:next|continue|more)\s*[.!?]*$/i;
78
+ const RESEARCH_STATUS_RE = /^research[,:]?\s+status\s*[.!?]*$/i;
79
+ const RESEARCH_STOP_RE = /^research[,:]?\s+(?:stop|cancel|quit|end)\s*[.!?]*$/i;
80
+ const RESEARCH_START_RE = /^research[,:]?\s+(.+?)(?:[,;]?\s+(?:with\s+)?limit\s+(\d{1,3}))?\s*[.!?]*$/i;
81
+ // A bare continuation word steps the queue too, but only when a run is
82
+ // actually pending and no plan lane owns the word — parseResearchRequest
83
+ // reports it as its own kind so the caller can apply that gate.
84
+ const BARE_NEXT_RE = /^(?:next|continue|carry on|keep going)\s*[.!?]*$/i;
85
+
86
+ /** The research request a line carries, or null. Kinds: start {topic,
87
+ * limit?}, next, bareNext, status, stop. The topic keeps the user's own
88
+ * words minus a leading article and any wrapping quotes; limit is only
89
+ * present when the request named one. */
90
+ export function parseResearchRequest(line) {
91
+ const q = String(line || "").trim();
92
+ if (!q) return null;
93
+ if (BARE_NEXT_RE.test(q)) return { kind: "bareNext" };
94
+ if (RESEARCH_NEXT_RE.test(q)) return { kind: "next" };
95
+ if (RESEARCH_STATUS_RE.test(q)) return { kind: "status" };
96
+ if (RESEARCH_STOP_RE.test(q)) return { kind: "stop" };
97
+ const m = q.match(RESEARCH_START_RE);
98
+ if (!m) return null;
99
+ const topic = m[1].trim()
100
+ .replace(/^["'‘’“”]+|["'‘’“”]+$/g, "")
101
+ .replace(/^(?:an?|the)\s+/i, "")
102
+ .trim();
103
+ if (!topic) return null;
104
+ const out = { kind: "start", topic };
105
+ if (m[2] !== undefined) out.limit = Number(m[2]);
106
+ return out;
107
+ }
108
+
109
+ /** The provenance tag every fact a research run stores carries:
110
+ * `research:<topic>@<depth>` — memory/trust.mjs parses it back to the
111
+ * referenceLive kind, so live-fetched research content scores exactly like
112
+ * any other live Wikipedia load, below the curated packs. */
113
+ export function researchProvenanceTag(topicKey, depth) {
114
+ return `research:${topicKey}@${depth}`;
115
+ }
116
+
117
+ /** The cited per-topic report — the same title/licence/revision-pinned-URL
118
+ * discipline renderLiveReferenceAnswer holds, naming this lane's source. */
119
+ export function renderResearchAnswer(term, article) {
120
+ return `${term} — ${article.summary} (source: research article "${article.title}", `
121
+ + `Simple English Wikipedia, CC BY-SA 4.0 — ${article.url}?oldid=${article.revid})`;
122
+ }
123
+
124
+ /** The queue as plain data for a UI: pending titles, per-topic fact counts,
125
+ * skips, and whether the run is complete. Null for no run. */
126
+ export function researchSnapshot(state) {
127
+ if (!state) return null;
128
+ return {
129
+ topic: state.topic,
130
+ limit: state.limit,
131
+ pending: [...state.pending],
132
+ done: state.done.map((d) => ({ title: d.title, facts: d.facts, depth: d.depth })),
133
+ skipped: [...state.skipped],
134
+ complete: state.pending.length === 0,
135
+ };
136
+ }
137
+
138
+ const totalFacts = (state) => state.done.reduce((sum, d) => sum + d.facts, 0);
139
+
140
+ function progressLine(state) {
141
+ const done = `${state.done.length} topic${state.done.length === 1 ? "" : "s"} grounded, ${totalFacts(state)} fact${totalFacts(state) === 1 ? "" : "s"} stored`;
142
+ const skipped = state.skipped.length ? `, ${state.skipped.length} skipped` : "";
143
+ if (!state.pending.length) return `research on "${state.topic}" is complete — ${done}${skipped}.`;
144
+ return `${done}${skipped}; ${state.pending.length} linked topic${state.pending.length === 1 ? "" : "s"} still queued — "research next" fetches the next one.`;
145
+ }
146
+
147
+ async function startRun({ topic, limit }, { holder, provider, ingest, config, notify, lexicon }) {
148
+ const key = researchTopicKey(topic, lexicon);
149
+ if (!key) {
150
+ holder.state = null;
151
+ return { text: `I can't make a search key out of "${topic}".`, miss: true };
152
+ }
153
+ try { if (typeof notify === "function") notify(key); } catch { /* notify-only */ }
154
+ let article = null;
155
+ try { article = await provider.lookup(key); } catch { article = null; }
156
+ if (!article) {
157
+ holder.state = null;
158
+ return {
159
+ text: `I couldn't ground "${topic}" from Simple English Wikipedia just now — no matching article, or the network didn't answer. Nothing was stored.`,
160
+ miss: true,
161
+ };
162
+ }
163
+ let facts = 0;
164
+ try { facts = await ingest(key, article, researchProvenanceTag(key, 0)); } catch { facts = 0; }
165
+ const fanout = clampInt(
166
+ limit !== undefined && Number.isFinite(limit) ? limit : config.fanoutLimit,
167
+ 0,
168
+ RESEARCH_FANOUT_MAX,
169
+ );
170
+ let pending = [];
171
+ if (fanout > 0 && config.depthLimit > 0 && typeof provider.linkedTitles === "function") {
172
+ let linked = null;
173
+ try { linked = await provider.linkedTitles(article.title, { limit: fanout + 2 }); } catch { linked = null; }
174
+ const seen = new Set([key, normFactTerm(article.title)]);
175
+ for (const title of linked || []) {
176
+ const folded = normFactTerm(title);
177
+ if (!folded || seen.has(folded)) continue;
178
+ seen.add(folded);
179
+ pending.push(title);
180
+ if (pending.length >= fanout) break;
181
+ }
182
+ }
183
+ holder.state = {
184
+ topic, key, title: article.title, limit: fanout,
185
+ pending, done: [{ title: article.title, facts, depth: 0 }], skipped: [],
186
+ };
187
+ const queueLine = pending.length
188
+ ? `queued ${pending.length} linked topic${pending.length === 1 ? "" : "s"}: ${pending.join(", ")} — "research next" fetches the next one (the page's play button does this for you).`
189
+ : `no linked topics queued — research on "${topic}" is complete.`;
190
+ return {
191
+ text: `${renderResearchAnswer(key, article)}\nstored ${facts} fact${facts === 1 ? "" : "s"} from "${article.title}". ${queueLine}`,
192
+ miss: false,
193
+ };
194
+ }
195
+
196
+ async function stepRun({ holder, provider, ingest, notify }) {
197
+ const state = holder.state;
198
+ const title = state.pending[0];
199
+ state.pending = state.pending.slice(1);
200
+ try { if (typeof notify === "function") notify(title); } catch { /* notify-only */ }
201
+ let article = null;
202
+ try { article = await (provider.pageByTitle ? provider.pageByTitle(title) : provider.lookup(normFactTerm(title))); } catch { article = null; }
203
+ if (!article) {
204
+ state.skipped = [...state.skipped, title];
205
+ return {
206
+ text: `I couldn't fetch "${title}" from Simple English Wikipedia — skipped, nothing stored. ${progressLine(state)}`,
207
+ miss: true,
208
+ };
209
+ }
210
+ const key = normFactTerm(article.title) || normFactTerm(title);
211
+ let facts = 0;
212
+ try { facts = await ingest(key, article, researchProvenanceTag(state.key, 1)); } catch { facts = 0; }
213
+ state.done = [...state.done, { title: article.title, facts, depth: 1 }];
214
+ return {
215
+ text: `${renderResearchAnswer(key, article)}\nstored ${facts} fact${facts === 1 ? "" : "s"} from "${article.title}". ${progressLine(state)}`,
216
+ miss: false,
217
+ };
218
+ }
219
+
220
+ /**
221
+ * The whole lane behind one call — chat.mjs's dispatch stays one thin block.
222
+ * Returns null when the line carries no research request (or carries a bare
223
+ * "next" this lane must not claim), else { text, miss, note, goal } with
224
+ * `holder.state` updated in place; the caller snapshots it for the UI and
225
+ * threads it to the next turn.
226
+ *
227
+ * `ctx`: { holder, provider, ingest(key, article, tag) -> stored count,
228
+ * config (resolveResearchConfig's shape), memoryDir, planActive,
229
+ * pagerActive, notify, lexicon }.
230
+ */
231
+ export async function researchTurn(line, ctx) {
232
+ const req = parseResearchRequest(line);
233
+ if (!req) return null;
234
+ const { holder, memoryDir, planActive, pagerActive } = ctx;
235
+ const pendingRun = Boolean(holder.state && holder.state.pending.length);
236
+ // A bare "next" belongs to an active plan first, then to paging — this
237
+ // lane only claims it when a research queue is the one thing running.
238
+ if (req.kind === "bareNext" && (!pendingRun || planActive || pagerActive)) return null;
239
+ const goal = "research a topic on Simple English Wikipedia and remember what it grounds";
240
+ const wrap = (r, note) => ({ ...r, goal, note });
241
+ if (req.kind === "status") {
242
+ if (!holder.state) return wrap({ text: 'no research is running — "research <topic>" starts one.', miss: true }, "RESEARCH — status with no run standing");
243
+ return wrap({ text: progressLine(holder.state), miss: false }, "RESEARCH — queue status read-out");
244
+ }
245
+ if (req.kind === "stop") {
246
+ if (!holder.state) return wrap({ text: 'no research is running — "research <topic>" starts one.', miss: true }, "RESEARCH — stop with no run standing");
247
+ const state = holder.state;
248
+ holder.state = null;
249
+ const dropped = state.pending.length;
250
+ return wrap({
251
+ text: `stopped research on "${state.topic}" — ${state.done.length} topic${state.done.length === 1 ? "" : "s"} grounded, ${totalFacts(state)} fact${totalFacts(state) === 1 ? "" : "s"} stored${dropped ? `, ${dropped} queued topic${dropped === 1 ? "" : "s"} dropped` : ""}.`,
252
+ miss: false,
253
+ }, "RESEARCH — run stopped, queue dropped");
254
+ }
255
+ if (!memoryDir) {
256
+ return wrap({ text: "research needs a memory store to write into, and this session has none.", miss: true }, "RESEARCH — declined, no memory store");
257
+ }
258
+ if (req.kind === "next" || req.kind === "bareNext") {
259
+ if (!pendingRun) {
260
+ if (holder.state) return wrap({ text: progressLine(holder.state), miss: false }, "RESEARCH — next on a completed run reads the summary");
261
+ return wrap({ text: 'no research is running — "research <topic>" starts one.', miss: true }, "RESEARCH — next with no run standing");
262
+ }
263
+ return wrap(await stepRun(ctx), "RESEARCH — one queued topic fetched and grounded");
264
+ }
265
+ return wrap(await startRun(req, ctx), "RESEARCH — depth-0 topic fetched, linked topics queued");
266
+ }
@@ -43,3 +43,22 @@ export function splitSentencesPreservingPaths(text) {
43
43
  }
44
44
  return out;
45
45
  }
46
+
47
+ /** Bracketed reference residue an encyclopedia paragraph leaves in prose:
48
+ * numeric footnote markers ([3], [12]), single-letter notes ([a]), and the
49
+ * named ones ([note 4], [citation needed], [source?], [page 2]). Removed
50
+ * case-insensitively so the sentence downstream reads as plain text, with the
51
+ * gap tidied so "period.[3] Sales" becomes "period. Sales", not
52
+ * "period. Sales". A file path never carries a bracket, so a dotted module
53
+ * identifier ("src/core/store.mjs") is left whole — only bracketed spans are
54
+ * touched. Deliberately NOT wired into the shared splitter; a caller that
55
+ * wants clean prose applies it before or after splitting. */
56
+ export function stripCitationResidue(text) {
57
+ return String(text ?? "")
58
+ .replace(/\[\s*\d+\s*\]/g, "")
59
+ .replace(/\[\s*[a-z]\s*\]/gi, "")
60
+ .replace(/\[\s*(?:note|citation|ref|source|page|pp?)\b[^\]]*\]/gi, "")
61
+ .replace(/ +([.,;:!?])/g, "$1")
62
+ .replace(/ {2,}/g, " ")
63
+ .trim();
64
+ }
@@ -0,0 +1,64 @@
1
+ // session-log-format.mjs — the ONE Markdown shape every session-transcript
2
+ // writer renders turns into: the Node CLI/TUI's own .tmct/session-<id>.md
3
+ // (chat-session.mjs) and the browser chat page's "export .md" button
4
+ // (chat-page-viz.mjs). A `#` title carrying the version and a short session
5
+ // label, one `###` heading per turn at millisecond time-of-day precision,
6
+ // the user's line as a verbatim `>` blockquote, the reply in a fenced
7
+ // block, and a closing session-end line.
8
+ //
9
+ // Every export here is a pure, self-contained function — no imports, and no
10
+ // references outside its own body except calling its siblings in this file
11
+ // by name — so the browser writer can splice each one's own `.toString()`
12
+ // straight into chat.html's inline script, the same discipline
13
+ // provBucketFor/provenanceChipFor already hold in chat-page-viz.mjs.
14
+
15
+ /** An ISO-8601 string or epoch-ms number, as its own "HH:MM:SS.mmm" time of
16
+ * day — read straight off the timestamp's own UTC digits, never converted
17
+ * to a local zone, so it always names the wall-clock instant the session
18
+ * actually ran the turn on. */
19
+ export function sessionLogTimeOfDay(ts) {
20
+ const iso = typeof ts === "number" ? new Date(ts).toISOString() : String(ts);
21
+ return iso.slice(11, 23);
22
+ }
23
+
24
+ /** The session file's opening block: a `#` title naming the version and a
25
+ * short session label (the id's first segment), a byline with the
26
+ * calendar date, the started time, and — when given — the repo path, then
27
+ * the `---` divider before the first turn. `repo` is optional: a browser
28
+ * export has none, so that clause is simply left out rather than shown
29
+ * empty. */
30
+ export function sessionLogHeaderMarkdown({ version, sessionId, startedAt, repo }) {
31
+ const iso = typeof startedAt === "number" ? new Date(startedAt).toISOString() : String(startedAt);
32
+ const shortId = String(sessionId || "").split("-")[0].slice(0, 8);
33
+ const date = iso.slice(0, 10);
34
+ const time = sessionLogTimeOfDay(iso);
35
+ const bylineParts = [date, "started " + time];
36
+ if (repo) bylineParts.push("repo " + repo);
37
+ const byline = "*" + bylineParts.join(" · ") + "*";
38
+ return "# tmct chat " + version + " — session " + shortId + "\n\n" + byline + "\n\n---\n\n";
39
+ }
40
+
41
+ /** One turn's own Markdown block: a `###` heading naming the time of day and
42
+ * the turn number, the VERBATIM user line as a `>` blockquote (no
43
+ * rewriting, no truncation — whatever the recognizer actually saw), and
44
+ * the reply in a fenced text block. Ends in a blank line so consecutive
45
+ * turn blocks concatenate directly into one document, byte-identical to
46
+ * the reference sample. An empty answer (the closing "/exit" marker) opens
47
+ * and closes the fence with nothing between, rather than a stray blank
48
+ * line inside it. */
49
+ export function sessionLogTurnMarkdown({ startedAt, turnNumber, query, answer }) {
50
+ const time = sessionLogTimeOfDay(startedAt);
51
+ const body = answer ? answer + "\n" : "";
52
+ return "### " + time + " · turn " + turnNumber + "\n\n"
53
+ + "> " + query + "\n\n"
54
+ + "```text\n" + body + "```\n\n";
55
+ }
56
+
57
+ /** The closing block: a `---` divider and the session-end line naming the
58
+ * end time and the total turn count (the closing "/exit" marker counts as
59
+ * the last turn, so this number is always that marker's own turn number).
60
+ * No leading blank line — the preceding turn block already supplied one. */
61
+ export function sessionLogEndMarkdown({ endedAt, turnCount }) {
62
+ const time = sessionLogTimeOfDay(endedAt);
63
+ return "---\n\n*session end " + time + " — " + turnCount + " turn" + (turnCount === 1 ? "" : "s") + "*\n";
64
+ }
@@ -1,7 +1,7 @@
1
1
  // sessions.mjs — chat sessions as first-class temporal graph data, like commits.
2
2
  //
3
3
  // A `tmct chat` session leaves two artifacts under the target repo:
4
- // .tmct/session-<uuidv7>.log — the human-readable transcript (chat.mjs)
4
+ // .tmct/session-<uuidv7>.md — the human-readable transcript (chat-session.mjs, session-log-format.mjs)
5
5
  // .tmct/sessions/session-<uuidv7>.jsonl — the STRUCTURED sidecar this module owns:
6
6
  // {"type":"session", id, started, repo, tmctVersion} (header line)
7
7
  // {"type":"turn", ts, query, via, resolvedIds, answeredIds, miss} (one per turn, flushed)
@@ -207,7 +207,7 @@ async function recordSessionMemory(graphFile, record, repoDirOverride = null) {
207
207
 
208
208
  let answers = new Map();
209
209
  try {
210
- answers = parseSessionLog(await readFile(join(repoDir, ".tmct", `session-${record.id}.log`), "utf8"));
210
+ answers = parseSessionLog(await readFile(join(repoDir, ".tmct", `session-${record.id}.md`), "utf8"));
211
211
  } catch { /* no transcript (direct API callers) — record the requests alone */ }
212
212
 
213
213
  const utterances = [];
@@ -294,38 +294,72 @@ export function parseSessionJsonl(text) {
294
294
  return { id: String(header.id), started: String(header.started || ""), ended, turns };
295
295
  }
296
296
 
297
- // A transcript turn opens with an ISO-8601 ms timestamp line followed by the
298
- // echoed "> <query>" line (chat.mjs's logLines shape).
299
- const LOG_TS_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
297
+ // A transcript turn heading (session-log-format.mjs's sessionLogTurnMarkdown):
298
+ // "### HH:MM:SS.mmm · turn N" time of day only, no calendar date (the date
299
+ // lives once, in the header byline). The header byline itself carries that
300
+ // date: "*YYYY-MM-DD · started HH:MM:SS.mmm[ · repo ...]*".
301
+ const MD_TURN_HEADING_RE = /^### (\d{2}:\d{2}:\d{2}\.\d{3}) · turn \d+$/;
302
+ const MD_BYLINE_DATE_RE = /^\*(\d{4}-\d{2}-\d{2}) ·/;
300
303
 
301
304
  export { turnKey };
302
305
 
306
+ /** The calendar date one UTC day after `dateStr` ("YYYY-MM-DD") — used to
307
+ * carry the transcript's running date forward across a midnight rollover
308
+ * (see parseSessionLog below). */
309
+ function nextUtcDate(dateStr) {
310
+ const d = new Date(`${dateStr}T00:00:00Z`);
311
+ d.setUTCDate(d.getUTCDate() + 1);
312
+ return d.toISOString().slice(0, 10);
313
+ }
314
+
303
315
  /**
304
- * Parse a human-readable session transcript (.tmct/session-<id>.log) into a
316
+ * Parse a human-readable session transcript (.tmct/session-<id>.md) into a
305
317
  * Map of turnKey(ts, query) → answer text. The transcript is the ONLY session
306
318
  * artifact that carries the answer PROSE (the structured sidecar records ids,
307
- * not text),
319
+ * not text).
320
+ *
321
+ * Each turn heading carries only a TIME of day (see MD_TURN_HEADING_RE above)
322
+ * — the calendar date is read once from the header byline and carried
323
+ * forward turn to turn, advancing a day whenever a heading's time reads
324
+ * EARLIER than the turn before it (a midnight rollover on a long session).
325
+ * The reconstructed `date + "T" + time + "Z"` is then byte-identical to the
326
+ * full ISO timestamp session-log-format.mjs's writer sliced the time out of,
327
+ * so it matches the sidecar's own `record.ts` under turnKey exactly.
308
328
  */
309
329
  export function parseSessionLog(text) {
310
330
  const lines = String(text ?? "").split("\n");
311
331
  const answers = new Map();
312
- let open = null; // { ts, query, answerLines }
313
- const close = () => {
314
- if (!open) return;
315
- while (open.answerLines.length && !open.answerLines.at(-1).trim()) open.answerLines.pop();
316
- answers.set(turnKey(open.ts, open.query), open.answerLines.join("\n"));
317
- open = null;
318
- };
319
- for (let i = 0; i < lines.length; i += 1) {
320
- if (LOG_TS_RE.test(lines[i]) && lines[i + 1]?.startsWith("> ")) {
321
- close();
322
- open = { ts: lines[i], query: lines[i + 1].slice(2), answerLines: [] };
323
- i += 1;
324
- } else if (open) {
325
- open.answerLines.push(lines[i]);
332
+ let date = null;
333
+ let lastTime = null;
334
+ let i = 0;
335
+ while (i < lines.length) {
336
+ if (date === null) {
337
+ const dateMatch = lines[i].match(MD_BYLINE_DATE_RE);
338
+ if (dateMatch) date = dateMatch[1];
339
+ }
340
+ const heading = date && lines[i].match(MD_TURN_HEADING_RE);
341
+ if (heading) {
342
+ const time = heading[1];
343
+ if (lastTime !== null && time < lastTime) date = nextUtcDate(date);
344
+ lastTime = time;
345
+ let j = i + 1;
346
+ if (lines[j] === "") j += 1;
347
+ const queryLine = lines[j];
348
+ if (queryLine?.startsWith("> ")) {
349
+ j += 1;
350
+ if (lines[j] === "") j += 1;
351
+ if (lines[j] === "```text") {
352
+ j += 1;
353
+ const answerLines = [];
354
+ while (j < lines.length && lines[j] !== "```") { answerLines.push(lines[j]); j += 1; }
355
+ answers.set(turnKey(`${date}T${time}Z`, queryLine.slice(2)), answerLines.join("\n"));
356
+ i = j + 1;
357
+ continue;
358
+ }
359
+ }
326
360
  }
361
+ i += 1;
327
362
  }
328
- close();
329
363
  return answers;
330
364
  }
331
365
 
@@ -18,7 +18,7 @@
18
18
  import {
19
19
  DIRECTION_DELTA, WORLD_NAME, cellId, parseCellId, inBounds, chebyshevDistance, oneStepDirectionBetween,
20
20
  } from "../domain/spider-fly-world.mjs";
21
- import { foldSpiderFlyState, runSpiderFlyTick, startSpiderFlyGame } from "./spider-fly.mjs";
21
+ import { foldSpiderFlyState, runSpiderFlyTick, startSpiderFlyGame, beliefSnapshotFor } from "./spider-fly.mjs";
22
22
  import { worldProvenanceTag } from "../domain/worlds-pack.mjs";
23
23
  import { getWorldsPackProvider } from "../adapters/corpus/worlds-pack.mjs";
24
24
  import { appendFacts, appendRule, loadMemory, readFactRows } from "../adapters/memory/core.mjs";
@@ -66,6 +66,12 @@ const SPIDER_FLY_TOLD_RE = new RegExp(
66
66
  "i",
67
67
  );
68
68
 
69
+ // The observable-facts read: "what does the fly see?" / "what can the
70
+ // spider see?" — a closed vocabulary shape, styled after the other
71
+ // game-lane regexes above, with the same optional numbered suffix
72
+ // (SPIDER_FLY_ADDRESS_LEAD_RE's own "-<n>") for a board past one of a kind.
73
+ const SPIDER_FLY_SEE_RE = /^what (?:does|can) the (spider|fly)(?:-(\d+))?\s+see[.!?\s]*$/i;
74
+
69
75
  const WORLD_OPENING_FALLBACK =
70
76
  "a spider waits in its web; a fly drifts in from the edge of the board. Neither is yours to move — watch, or address one by name in chat.";
71
77
 
@@ -342,6 +348,48 @@ async function runTickAndRender({ planHolder, memoryDir, cache, toldFacts = [],
342
348
  };
343
349
  }
344
350
 
351
+ // ---- the observable-facts read: "what does the fly see?" -----------------
352
+
353
+ /** One `[id, cellId | null]` belief entry as a sentence: `"spider-1 is at
354
+ * cell-3-4."` when observed/told, `"fly-2 has not been observed."`
355
+ * otherwise — the same wording spider-fly-viz.mjs's own click-expand panel
356
+ * (observedFactsHtml) renders, so the chat phrasing and the browser panel
357
+ * never disagree about what an agent can see. */
358
+ function observedFactSentence(id, believedCell) {
359
+ return believedCell ? `${id} is at ${believedCell}.` : `${id} has not been observed.`;
360
+ }
361
+
362
+ /** "what does the fly see?" / "what does the spider see?" rendered as plain
363
+ * text: the same beliefSnapshotFor read spider-fly.mjs's own tick loop and
364
+ * the browser panel already use, over the CURRENT board state — read-only,
365
+ * no tick runs, nothing is written. Candidates are every OTHER live agent
366
+ * of either kind; toldFacts is empty (a told position only ever arrives
367
+ * fresh alongside a tick — see runToldFactTurn — so there is none standing
368
+ * between ticks to read back here). */
369
+ async function spiderFlyBeliefAnswer(match, { memoryDir, gameConfig = DEFAULT_GAME_CONFIG }) {
370
+ const kind = match[1].toLowerCase();
371
+ const num = match[2];
372
+ const rows = readFactRows(await loadMemory(memoryDir));
373
+ const state = foldSpiderFlyState(rows);
374
+ const observerId = resolveAgentId(kind, num, state);
375
+ if (!observerId) return noSuchAgentAnswer(kind, "addressee");
376
+ const observerCell = parseCellId(state.placements.get(observerId).cell);
377
+ const candidateIds = [...liveIdsOfKind("spider", state), ...liveIdsOfKind("fly", state)];
378
+ const visionRadius = kind === "spider"
379
+ ? gameConfig?.spiderFly?.spiderVisionRadius
380
+ : gameConfig?.spiderFly?.flyVisionRadius;
381
+ const belief = beliefSnapshotFor(observerId, observerCell, candidateIds, state, { visionRadius });
382
+ const entries = Object.entries(belief);
383
+ const text = entries.length
384
+ ? `${observerId} sees: ${entries.map(([id, cell]) => observedFactSentence(id, cell)).join(" ")}`
385
+ : `${observerId} is alone on the board — nothing else to see.`;
386
+ return {
387
+ text,
388
+ lane: "game-inform",
389
+ note: `SPIDER-FLY — belief snapshot rendered for ${observerId} via beliefSnapshotFor (read-only, no tick run)`,
390
+ };
391
+ }
392
+
345
393
  /** The addressed teach-frame turn: resolve the addressee and the belief
346
394
  * subject, resolve the told cell, and run ONE tick with that told-fact fed
347
395
  * in. Told-facts are NOT persisted on the session slot across turns — each
@@ -526,6 +574,11 @@ export async function spiderFlyTurn(line, { planHolder, memoryDir, env, cache =
526
574
  return runToldFactTurn(told, { planHolder, memoryDir, cache, gameConfig });
527
575
  }
528
576
 
577
+ const seeMatch = String(line).trim().match(SPIDER_FLY_SEE_RE);
578
+ if (seeMatch) {
579
+ return spiderFlyBeliefAnswer(seeMatch, { memoryDir, gameConfig });
580
+ }
581
+
529
582
  if (SPIDER_FLY_TICK_RE.test(line)) {
530
583
  return runTickAndRender({ planHolder, memoryDir, cache, toldFacts: [], gameConfig });
531
584
  }