@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.
- package/README.md +68 -12
- package/bin/tmct.mjs +5 -2
- package/corpus/sprites/src/sprite-facts.jsonl +18 -0
- package/corpus/worlds/manifest.json +5 -5
- package/corpus/worlds/shards/ashcombe-hall.jsonl.gz +0 -0
- package/corpus/worlds/src/ashcombe-hall.jsonl +27 -0
- package/data/sprites/book-icon.toml +12 -0
- package/data/sprites/cellar-icon.toml +12 -0
- package/data/sprites/drawing-room-icon.toml +13 -0
- package/data/sprites/garden-icon.toml +12 -0
- package/data/sprites/kitchen-icon.toml +13 -0
- package/data/sprites/library-icon.toml +12 -0
- package/data/sprites/pan-icon.toml +11 -0
- package/data/sprites/study-icon.toml +12 -0
- package/package.json +7 -2
- package/src/adapters/corpus/wikipedia-live.mjs +182 -26
- package/src/adapters/corpus/worlds-pack.mjs +8 -2
- package/src/adapters/memory/core.mjs +8 -1
- package/src/adapters/toml-config.mjs +6 -0
- package/src/domain/cli-verbs.mjs +2 -0
- package/src/domain/memory/trust.mjs +32 -2
- package/src/domain/sense-split.mjs +203 -0
- package/src/domain/worlds-pack.mjs +50 -0
- package/src/services/adventure-autoplay.mjs +5 -2
- package/src/services/adventure-viz.mjs +301 -33
- package/src/services/adventure.mjs +162 -14
- package/src/services/chat-page-viz.mjs +341 -197
- package/src/services/chat-session.mjs +24 -9
- package/src/services/chat.mjs +580 -47
- package/src/services/code-explorer-viz.mjs +198 -76
- package/src/services/extract-facts.mjs +384 -82
- package/src/services/fold.mjs +1 -1
- package/src/services/ingest-viz.mjs +637 -0
- package/src/services/ledger-viz.mjs +209 -0
- package/src/services/memory-panel-viz.mjs +159 -0
- package/src/services/research.mjs +266 -0
- package/src/services/sentences.mjs +19 -0
- package/src/services/session-log-format.mjs +64 -0
- package/src/services/sessions.mjs +56 -22
- package/src/services/spider-fly-turn.mjs +54 -1
- package/src/services/spider-fly-viz.mjs +41 -23
- package/src/surfaces/web/adventure-browser-entry.mjs +9 -5
- package/src/surfaces/web/chat-browser-entry.mjs +32 -11
- package/src/surfaces/web/code-explorer-browser-entry.mjs +27 -11
- package/src/surfaces/web/ingest-browser-entry.mjs +208 -0
- package/src/surfaces/web/ledger-browser-entry.mjs +24 -5
- package/src/surfaces/web/memory-ask-browser.bundle.js +134 -125
- package/src/surfaces/web/memory-stats.mjs +53 -0
- package/src/tools/definitions.mjs +14 -0
- package/src/tools/handlers/index.mjs +2 -0
- package/src/tools/handlers/tmct-ingest.mjs +43 -0
- 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>.
|
|
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}.
|
|
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
|
|
298
|
-
//
|
|
299
|
-
|
|
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>.
|
|
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
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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
|
}
|