@polycode-projects/the-mechanical-code-talker 4.0.0 → 4.1.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 (108) hide show
  1. package/README.md +178 -9
  2. package/corpus/reference/index.json.gz +0 -0
  3. package/corpus/reference/manifest.json +8 -8
  4. package/corpus/reference/shards/ref-00.jsonl.gz +0 -0
  5. package/corpus/sprites/src/sprite-facts.jsonl +401 -0
  6. package/corpus/worlds/index.json.gz +0 -0
  7. package/corpus/worlds/manifest.json +49 -9
  8. package/corpus/worlds/shards/greyvale-museum.jsonl.gz +0 -0
  9. package/corpus/worlds/shards/lantern-cottage.jsonl.gz +0 -0
  10. package/corpus/worlds/shards/mud-hollow.jsonl.gz +0 -0
  11. package/corpus/worlds/shards/mud-warren.jsonl.gz +0 -0
  12. package/corpus/worlds/shards/spider-fly.jsonl.gz +0 -0
  13. package/corpus/worlds/src/greyvale-museum.jsonl +136 -0
  14. package/corpus/worlds/src/lantern-cottage.jsonl +60 -0
  15. package/corpus/worlds/src/mud-hollow.jsonl +82 -0
  16. package/corpus/worlds/src/mud-warren.jsonl +124 -0
  17. package/corpus/worlds/src/spider-fly.jsonl +1 -1
  18. package/data/sprites/animal-icon.toml +11 -5
  19. package/data/sprites/book-icon.toml +9 -5
  20. package/data/sprites/cabinet-icon.toml +10 -5
  21. package/data/sprites/cellar-icon.toml +16 -6
  22. package/data/sprites/container-icon.toml +7 -3
  23. package/data/sprites/desk-icon.toml +8 -5
  24. package/data/sprites/dog-icon.toml +8 -5
  25. package/data/sprites/dog-with-colour-icon.toml +13 -12
  26. package/data/sprites/drawing-room-icon.toml +14 -5
  27. package/data/sprites/egg-icon.toml +6 -3
  28. package/data/sprites/fly-icon.toml +10 -5
  29. package/data/sprites/furniture-icon.toml +5 -3
  30. package/data/sprites/garden-icon.toml +10 -3
  31. package/data/sprites/key-icon.toml +3 -1
  32. package/data/sprites/kitchen-icon.toml +15 -6
  33. package/data/sprites/lamp-icon.toml +9 -4
  34. package/data/sprites/letter-icon.toml +7 -4
  35. package/data/sprites/library-icon.toml +14 -3
  36. package/data/sprites/pan-icon.toml +7 -3
  37. package/data/sprites/person-icon.toml +6 -2
  38. package/data/sprites/poodle-icon.toml +1 -1
  39. package/data/sprites/portable-icon.toml +6 -4
  40. package/data/sprites/portrait-icon.toml +7 -4
  41. package/data/sprites/room-icon.toml +11 -2
  42. package/data/sprites/spider-icon.toml +9 -3
  43. package/data/sprites/study-icon.toml +10 -2
  44. package/package.json +5 -4
  45. package/src/adapters/memory/core.mjs +20 -0
  46. package/src/domain/ask-vocab.mjs +71 -0
  47. package/src/domain/ask.mjs +168 -0
  48. package/src/domain/game-config.mjs +11 -0
  49. package/src/domain/grammar/ace.mjs +40 -4
  50. package/src/domain/grammar/lexicon-core.json +2 -1
  51. package/src/domain/grammar/lexicon.mjs +18 -0
  52. package/src/domain/mud-facts.mjs +15 -0
  53. package/src/domain/reference-pack.mjs +31 -7
  54. package/src/domain/router/drive.mjs +35 -9
  55. package/src/domain/router/registry.mjs +24 -4
  56. package/src/domain/router/resolver.mjs +102 -40
  57. package/src/domain/scene-compose.mjs +117 -0
  58. package/src/domain/spider-fly-world.mjs +37 -1
  59. package/src/domain/sprite-facts.mjs +0 -0
  60. package/src/domain/sprite-request.mjs +156 -0
  61. package/src/domain/sprite-templates.mjs +169 -20
  62. package/src/services/adventure-editor.mjs +8 -14
  63. package/src/services/adventure-viz.mjs +209 -157
  64. package/src/services/adventure.mjs +526 -391
  65. package/src/services/chat-page-viz.mjs +69 -25
  66. package/src/services/chat.mjs +200 -51
  67. package/src/services/code-explorer-viz.mjs +102 -62
  68. package/src/services/extract-facts.mjs +4 -7
  69. package/src/services/ingest-viz.mjs +68 -82
  70. package/src/services/ledger-viz.mjs +136 -67
  71. package/src/services/memory-panel-viz.mjs +62 -0
  72. package/src/services/mud-editor.mjs +10 -15
  73. package/src/services/mud-turn.mjs +6 -6
  74. package/src/services/mud-viz.mjs +1016 -208
  75. package/src/services/p2p-room.mjs +90 -23
  76. package/src/services/plan-pddl.mjs +3 -1
  77. package/src/services/plan-viz.mjs +123 -64
  78. package/src/services/research-viz.mjs +160 -108
  79. package/src/services/spider-fly-turn.mjs +15 -23
  80. package/src/services/spider-fly-viz.mjs +146 -161
  81. package/src/services/spider-fly.mjs +69 -11
  82. package/src/services/sprite-catalog-viz.mjs +414 -240
  83. package/src/services/viz-boot.mjs +71 -0
  84. package/src/services/viz-room-graph.mjs +203 -0
  85. package/src/services/viz-theme.mjs +90 -1
  86. package/src/services/viz-ticker.mjs +22 -0
  87. package/src/surfaces/web/adventure-browser-entry.mjs +49 -33
  88. package/src/surfaces/web/chat-browser-entry.mjs +30 -105
  89. package/src/surfaces/web/code-explorer-browser-entry.mjs +168 -24
  90. package/src/surfaces/web/ingest-browser-entry.mjs +3 -13
  91. package/src/surfaces/web/ledger-browser-entry.mjs +7 -47
  92. package/src/surfaces/web/memory-ask-browser.bundle.js +127 -124
  93. package/src/surfaces/web/memory-stats.mjs +11 -0
  94. package/src/surfaces/web/mud-browser-entry.mjs +71 -29
  95. package/src/surfaces/web/plan-browser-entry.mjs +22 -40
  96. package/src/surfaces/web/research-browser-entry.mjs +26 -41
  97. package/src/surfaces/web/spider-fly-browser-entry.mjs +45 -28
  98. package/src/surfaces/web/sprites-browser-entry.mjs +14 -27
  99. package/src/surfaces/web/turn-session.mjs +120 -0
  100. package/src/tools/definitions.mjs +30 -0
  101. package/src/tools/handlers/index.mjs +6 -3
  102. package/src/tools/handlers/kit.mjs +19 -2
  103. package/src/tools/handlers/tmct-ask.mjs +11 -6
  104. package/src/tools/handlers/tmct-ingest.mjs +5 -1
  105. package/src/tools/handlers/tmct-related.mjs +4 -4
  106. package/src/tools/handlers/tmct-sprite.mjs +147 -0
  107. package/src/tools/memory-fallthrough.mjs +9 -2
  108. package/src/tools/server.mjs +25 -1
@@ -4,6 +4,7 @@
4
4
  // store shape.
5
5
  import { loadMemory, readFactRows } from "../../adapters/memory/core.mjs";
6
6
  import { provenanceTagToSource } from "../../domain/memory/trust.mjs";
7
+ import { serializeFactsJsonl } from "../../adapters/memory/export-jsonl.mjs";
7
8
 
8
9
  /**
9
10
  * The memory a running session holds, broken down by where each fact came
@@ -51,3 +52,13 @@ export async function memoryStats(memoryDir) {
51
52
  }
52
53
  return { total: rows.length, bandCounts, taught };
53
54
  }
55
+
56
+ /**
57
+ * The session's whole triple store as JSONL — one
58
+ * { subject, predicate, object, provenance } object per line, the same shape
59
+ * `tmct extract` and `tmct memory --export` emit. Reads the live memory the
60
+ * same way memoryStats does; a page offers this as a download.
61
+ */
62
+ export async function exportFactsJsonl(memoryDir) {
63
+ return serializeFactsJsonl(await loadMemory(memoryDir));
64
+ }
@@ -28,7 +28,6 @@
28
28
  // auto-playing at once — this file makes no ordering promise between two
29
29
  // concurrent calls into the same memoryDir, the same way two callers writing
30
30
  // into any shared store concurrently would need their own queue.
31
- import { runTurn } from "../../services/chat.mjs";
32
31
  import {
33
32
  createInMemoryStore, appendFacts, appendRule, loadMemory, readFactRows, removeFacts,
34
33
  } from "../../adapters/memory/core.mjs";
@@ -38,8 +37,9 @@ import {
38
37
  foldWorldState, worldActionRows, worldDigestRows, roomAffordances,
39
38
  personKnowledgeLines, personKnownFoodLines,
40
39
  diggableDirections, castInRoom, displayNameOf, isOutOfPlay, outOfPlayReasonOf, outOfPlayPhrase,
41
- roomKindOf,
40
+ roomKindOf, isMudStatePredicate, worldEpochFact, snapshotSubject,
42
41
  } from "../../services/adventure.mjs";
42
+ import { waveFact, playedByFact, P2P_PREDICATES } from "../../domain/p2p/facts.mjs";
43
43
  import { relatedForTerm } from "../../domain/skos-view.mjs";
44
44
  import { runMudTurn } from "../../services/mud-turn.mjs";
45
45
  import { parseMudEditorText, planMudEditorSync } from "../../services/mud-editor.mjs";
@@ -47,6 +47,7 @@ import { mudSpeciesOf } from "../../domain/game-config.mjs";
47
47
  import { worldProvenanceTag } from "../../domain/worlds-pack.mjs";
48
48
  import { resolveSpriteForClass, SPRITE_REGISTRY, classAncestorChain } from "../../domain/sprite-map.mjs";
49
49
  import { resolveSpriteAsset } from "../../domain/sprite-templates.mjs";
50
+ import { createTurnSession } from "./turn-session.mjs";
50
51
 
51
52
  /** A live, shared mud world several characters can each act in. `worldPayload`
52
53
  * is `{ name, facts, rules, opening }` — the same shape adventure-browser-
@@ -64,13 +65,18 @@ import { resolveSpriteAsset } from "../../domain/sprite-templates.mjs";
64
65
  * visitedRoomIds, turnsTaken, isOutOfPlay, outOfPlayReason }`. `snapshot()` is
65
66
  * the one OMNISCIENT read this module exposes — the central world map's own
66
67
  * data source, never a per-window one. */
67
- export async function createMudSession(worldPayload, { characters = [] } = {}) {
68
+ export async function createMudSession(worldPayload, { characters = [], epoch = 0 } = {}) {
68
69
  const memoryDir = createInMemoryStore();
69
70
  const tag = worldProvenanceTag(worldPayload.name);
70
71
  const seedFacts = worldFactsForCast(worldPayload.facts, characters);
71
72
  await appendFacts(memoryDir, seedFacts.map((f) => ({
72
73
  subject: f.subject, predicate: f.predicate, object: f.object, provenance: tag,
73
74
  })));
75
+ // A recast opens the same deterministic ids over a fresh store while peers
76
+ // may still hold the old run's snapshots. The epoch marker is what makes
77
+ // every fold — local and merged — treat this seed as the newer state. An
78
+ // unrecast boot writes nothing, so a solo session's store is unchanged.
79
+ if (epoch > 0) await appendFacts(memoryDir, [{ ...worldEpochFact(epoch), provenance: tag }]);
74
80
  for (const rule of worldPayload.rules) {
75
81
  await appendRule(memoryDir, { name: rule.name, kind: rule.ruleKind, slots: rule.slots, provenance: tag });
76
82
  }
@@ -104,9 +110,10 @@ export async function createMudSession(worldPayload, { characters = [] } = {}) {
104
110
  // "it"/"there" belongs to that window's own conversation, and its own
105
111
  // discovered-room history is the real fog of war this page promises —
106
112
  // sharing either across characters would leak one window's state into
107
- // another's.
108
- let focus = null;
109
- let last = null;
113
+ // another's. createTurnSession owns focus/last for us; planState is the
114
+ // one piece that is NOT per-character (see planHolder above), so it is
115
+ // threaded through buildExtraOptions/captureExtraState instead of living
116
+ // in the session's own internal slot.
110
117
  const visitedRoomIds = new Set();
111
118
  // This character's OWN turns, not the page's shared tick counter: two
112
119
  // windows playing at different speeds, or one paused while the other
@@ -116,6 +123,20 @@ export async function createMudSession(worldPayload, { characters = [] } = {}) {
116
123
  const startRoom = await roomOf(character);
117
124
  if (startRoom) visitedRoomIds.add(startRoom);
118
125
 
126
+ const turnSession = createTurnSession({
127
+ memoryDir, graph, lexicon, sessionId: character,
128
+ vocabHint: 'Try a world command ("dig north", "eat the carrot-1"), or ask "what food do you know about".',
129
+ buildExtraOptions: () => ({
130
+ uiContext: "browser", actingSubject: character, planState: planHolder.state,
131
+ }),
132
+ captureExtraState: async (result, state) => {
133
+ if ("planState" in result) planHolder.state = state.planState;
134
+ turnsTaken += 1;
135
+ const here = await roomOf(character);
136
+ if (here) visitedRoomIds.add(here);
137
+ },
138
+ });
139
+
119
140
  windows[character] = {
120
141
  character,
121
142
 
@@ -124,27 +145,7 @@ export async function createMudSession(worldPayload, { characters = [] } = {}) {
124
145
  * character via actingSubject. A throwing runTurn must never end the
125
146
  * session; this window has no other chance to show this turn's
126
147
  * answer. */
127
- async turn(line) {
128
- let result;
129
- try {
130
- result = await runTurn(line, {
131
- config: null, source: null, graph, focus, last, memoryDir,
132
- sessionId: character, env: {}, lexicon, uiContext: "browser",
133
- actingSubject: character, planState: planHolder.state,
134
- vocabHint: 'Try a world command ("dig north", "eat the carrot-1"), or ask "what food do you know about".',
135
- });
136
- } catch (e) {
137
- const message = e instanceof Error ? e.message : String(e);
138
- return { answer: `Something went wrong answering that (${message}). Try rephrasing.`, end: false };
139
- }
140
- focus = result.focus;
141
- last = result.last;
142
- if ("planState" in result) planHolder.state = result.planState;
143
- turnsTaken += 1;
144
- const here = await roomOf(character);
145
- if (here) visitedRoomIds.add(here);
146
- return { answer: result.answer, end: Boolean(result.end) };
147
- },
148
+ turn: turnSession.turn,
148
149
 
149
150
  /** One whole scripted turn (mud-turn.mjs's runMudTurn): investigate,
150
151
  * walk toward known food, or roll at the edge (dig). `k` is the turn
@@ -220,7 +221,7 @@ export async function createMudSession(worldPayload, { characters = [] } = {}) {
220
221
  const editTurn = state.turnCount + 1;
221
222
  if (toAppend.length) {
222
223
  await appendFacts(memoryDir, toAppend.map((f) => ({
223
- subject: f.kind === "other" ? f.subject : `${f.subject}@turn${editTurn}`,
224
+ subject: f.kind === "other" ? f.subject : snapshotSubject(f.subject, editTurn, state.epoch),
224
225
  predicate: f.predicate,
225
226
  object: f.object,
226
227
  provenance: f.kind === "other" ? tag : `${tag}:turn${editTurn}`,
@@ -232,7 +233,44 @@ export async function createMudSession(worldPayload, { characters = [] } = {}) {
232
233
  return { unrecognized, added: toAppend.length, removed };
233
234
  }
234
235
 
235
- return { memoryDir, windows, snapshot, applyEdit };
236
+ // Wall-clock resolution is 1ms, and both writers below are content-addressed
237
+ // by (subject, predicate, object) — a second wave from the same character in
238
+ // the same room is the SAME fact id, so it only registers as a change if its
239
+ // provenance tag differs. Two writes inside one tick would share a timestamp,
240
+ // share a tag, and the second would silently vanish. p2p-room.mjs nudges its
241
+ // own clock for exactly this reason; this is the same nudge for the writes
242
+ // that happen before a room exists.
243
+ let lastWriteMs = -Infinity;
244
+ function stampNow() {
245
+ const nudged = Math.max(Date.now(), lastWriteMs + 1);
246
+ lastWriteMs = nudged;
247
+ return new Date(nudged).toISOString();
248
+ }
249
+
250
+ /** `character` waves in whichever room it currently stands in — an ordinary
251
+ * add-only fact, so a page with no network renders it exactly like a page
252
+ * sharing the world with three others. Returns the room waved in, or null
253
+ * when the character stands nowhere (out of play). Nothing is ever
254
+ * retracted: "currently waving" is a recency read over this fact's own
255
+ * provenance timestamp. */
256
+ async function wave(character) {
257
+ const here = await roomOf(character);
258
+ if (!here) return null;
259
+ await appendFacts(memoryDir, [waveFact(character, here, stampNow())]);
260
+ return here;
261
+ }
262
+
263
+ /** Claim `characters` for `peerId` — one add-only `mgx:playedBy` fact each.
264
+ * Claims never overwrite: two peers claiming the same animal both write,
265
+ * and every reader settles it the same way by taking the oldest claim. */
266
+ async function claimCharacters(characters, peerId) {
267
+ const at = stampNow();
268
+ const claims = (characters || []).map((character) => playedByFact(character, peerId, at));
269
+ if (claims.length) await appendFacts(memoryDir, claims);
270
+ return claims.length;
271
+ }
272
+
273
+ return { memoryDir, windows, snapshot, applyEdit, wave, claimCharacters };
236
274
  }
237
275
 
238
276
  /** `count` entries drawn at random from `roster`, in random order, without
@@ -324,6 +362,10 @@ globalThis.tmctMud = {
324
362
  personKnowledgeLines, personKnownFoodLines,
325
363
  diggableDirections, castInRoom, displayNameOf, isOutOfPlay, outOfPlayReasonOf, outOfPlayPhrase,
326
364
  roomKindOf,
365
+ // The shared-world reach-throughs: which predicates carry live world state,
366
+ // and the P2P layer's own four. mud.html hands both to `mudSyncableFacts`,
367
+ // which is written to take the check rather than import the engine itself.
368
+ isMudStatePredicate, P2P_PREDICATES,
327
369
  // The edit mode's own reach-throughs: the SKOS neighbourhood and the is-a
328
370
  // chain its cursor-suggestion pills read, neither of which is splice-safe.
329
371
  relatedForTerm,
@@ -24,7 +24,6 @@
24
24
  // engine. `maxDepth` is overridable PER CALL (not just at session creation)
25
25
  // so the page's own max-search-depth control can re-run "solve it" on the
26
26
  // CURRENT board without tearing down and re-teaching the whole puzzle.
27
- import { runTurn } from "../../services/chat.mjs";
28
27
  import { createInMemoryStore } from "../../adapters/memory/core.mjs";
29
28
  import { parseEntities } from "../../domain/codegraph.mjs";
30
29
  import { loadLexicon } from "../../domain/grammar/lexicon.mjs";
@@ -32,6 +31,7 @@ import { DEFAULT_GAME_CONFIG } from "../../domain/game-config.mjs";
32
31
  import { hanoiLessonSentences } from "../../domain/hanoi-lesson.mjs";
33
32
  import { computeBlocksLayout, planToPageData, renderInputsFromPlan } from "../../services/plan-viz.mjs";
34
33
  import { planToPddl } from "../../services/plan-pddl.mjs";
34
+ import { createTurnSession } from "./turn-session.mjs";
35
35
  // Re-exported so the page can register a CDN-loaded wink-nlp pair before the
36
36
  // first teach, the same seam chat-browser-entry.mjs exposes as
37
37
  // tmctChat.registerWinkModel — see wink-model.mjs's own header. The hanoi
@@ -57,52 +57,34 @@ export async function createPlanSession({ diskCount = 3, maxDepth = DEFAULT_GAME
57
57
  const graph = parseEntities({ individuals: [], objectProperties: [] });
58
58
  const lexicon = loadLexicon();
59
59
  const sessionId = globalThis.crypto?.randomUUID?.() ?? String(Date.now());
60
- const planHolder = { state: null };
61
- let focus = null;
62
- let last = null;
63
60
 
64
- /** One dispatched chat turn the SAME runTurn the CLI and every other
65
- * viz page's own chat dock run, over this session's own memoryDir. A
66
- * throwing runTurn must never kill the session the page has no other
67
- * chance to show this turn's answer. `maxDepth` overrides the session's
68
- * own default for just this one call (the page's own max-search-depth
69
- * control threads it on every call, including a plain typed "solve
70
- * it"), so raising or lowering it never requires re-teaching the board.
71
- * A returned `plan` carries `becauseText` folded in from the session's
72
- * own plan slot the plan-lane contract's returned object never carries
73
- * it itself (only planHolder.state does), and the PDDL panel's own
74
- * "because —" line needs it. */
75
- async function turn(line, { maxDepth: maxDepthOverride } = {}) {
76
- const gameConfig = {
77
- ...DEFAULT_GAME_CONFIG,
78
- planning: { ...DEFAULT_GAME_CONFIG.planning, maxDepth: maxDepthOverride ?? maxDepth },
79
- };
80
- let result;
81
- try {
82
- result = await runTurn(line, {
83
- config: null, source: null, graph, focus, last, memoryDir, sessionId,
84
- env: {}, lexicon, vocabHint: "", planState: planHolder.state, gameConfig,
85
- });
86
- } catch (e) {
87
- const message = e instanceof Error ? e.message : String(e);
88
- return { answer: `Something went wrong answering that (${message}). Try rephrasing, or /help.`, end: false, record: null, plan: null };
89
- }
90
- focus = result.focus;
91
- last = result.last;
92
- if ("planState" in result) planHolder.state = result.planState;
93
- const plan = result.plan
94
- ? { ...result.plan, becauseText: planHolder.state?.becauseText ?? null }
95
- : null;
96
- return { answer: result.answer, end: Boolean(result.end), record: result.record ?? null, plan };
97
- }
61
+ // `maxDepth` overrides the session's own default for just this one call
62
+ // (the page's own max-search-depth control threads it on every call,
63
+ // including a plain typed "solve it"), so raising or lowering it never
64
+ // requires re-teaching the board. `captureExtraState` folds `becauseText`
65
+ // onto the returned `plan` from the session's own plan slot — the
66
+ // plan-lane contract's returned object never carries it itself (only
67
+ // planState does), and the PDDL panel's own "because —" line needs it.
68
+ const session = createTurnSession({
69
+ memoryDir, graph, lexicon, sessionId, vocabHint: "",
70
+ buildExtraOptions: (state, callArgs) => ({
71
+ gameConfig: {
72
+ ...DEFAULT_GAME_CONFIG,
73
+ planning: { ...DEFAULT_GAME_CONFIG.planning, maxDepth: callArgs?.maxDepth ?? maxDepth },
74
+ },
75
+ }),
76
+ captureExtraState: (result, state) => {
77
+ if (result.plan) result.plan = { ...result.plan, becauseText: state.planState?.becauseText ?? null };
78
+ },
79
+ });
98
80
 
99
81
  let plan = null;
100
82
  for (const sentence of hanoiLessonSentences(diskCount)) {
101
- const r = await turn(sentence);
83
+ const r = await session.turn(sentence);
102
84
  if (r.plan) plan = r.plan;
103
85
  }
104
86
 
105
- return { memoryDir, sessionId, diskCount, maxDepth, plan, turn };
87
+ return { memoryDir, sessionId, diskCount, maxDepth, plan, turn: session.turn };
106
88
  }
107
89
 
108
90
  // Re-exported so the page's own rendering script (plan-viz.mjs's inlined
@@ -26,10 +26,9 @@
26
26
  // Gitignored, Pages-demo-site-only output — scripts/build-demo-site.mjs builds
27
27
  // it fresh on every deploy, never committed, the same posture the chat/ingest
28
28
  // bundles document for their own output.
29
- import { runTurn, vocabExampleHint, factAnswer, factReadBack } from "../../services/chat.mjs";
29
+ import { vocabExampleHint, factAnswer, factReadBack } from "../../services/chat.mjs";
30
30
  import { clampResearchConfig } from "../../services/research.mjs";
31
- import { createInMemoryStore, normFactTerm, loadMemory, readFactRows, removeFacts } from "../../adapters/memory/core.mjs";
32
- import { serializeFactsJsonl } from "../../adapters/memory/export-jsonl.mjs";
31
+ import { createInMemoryStore, normFactTerm, loadMemory, readFactRows, removeFacts, applySeedPayload } from "../../adapters/memory/core.mjs";
33
32
  import { provenanceTagToSource } from "../../domain/memory/trust.mjs";
34
33
  import { parseEntities } from "../../domain/codegraph.mjs";
35
34
  import { loadLexicon } from "../../domain/grammar/lexicon.mjs";
@@ -39,6 +38,8 @@ import { registerLiveReferenceProvider, registerResearchProvider } from "../../a
39
38
  import { groundTextToFacts } from "./ingest-browser-entry.mjs";
40
39
  import { openPersistedStore } from "./idb-persist.mjs";
41
40
  import { digestTermFromPayloadBrowser } from "./digest-client.mjs";
41
+ import { createTurnSession } from "./turn-session.mjs";
42
+ import { exportFactsJsonl } from "./memory-stats.mjs";
42
43
 
43
44
  // The Fact individual's first-write-wins timestamp, read straight off the
44
45
  // stored attribute (mgx:createdAt) so a "recently learned" ordering never
@@ -205,22 +206,16 @@ export async function researchSnapshot(memoryDir, sessionIds = {}, { recentCap =
205
206
  */
206
207
  export function createResearchSession({ seedPayload = null, vocabSeeded = false, liveReference = false, onLiveLookup = null, synthesisBudget = 12, digestStructures = null } = {}) {
207
208
  const memoryDir = createInMemoryStore();
208
- if (seedPayload) memoryDir.payload = { ...memoryDir.payload, ...seedPayload };
209
+ applySeedPayload(memoryDir, seedPayload);
209
210
 
210
211
  const graph = parseEntities({ individuals: [], objectProperties: [] });
211
212
  const lexicon = loadLexicon();
212
213
  const vocabHint = vocabExampleHint(vocabSeeded);
213
- const chatSessionId = globalThis.crypto?.randomUUID?.() ?? String(Date.now());
214
214
  // A DISTINCT id so an ingested fact's teach tag is told apart from a typed
215
215
  // teach turn's by session id alone — the whole reason the two growth paths
216
216
  // stay separable in the source panel.
217
217
  const ingestSessionId = globalThis.crypto?.randomUUID?.() ?? String(Date.now() + 1);
218
- const sessionIds = { chatSessionId, ingestSessionId };
219
218
 
220
- let focus = null;
221
- let last = null;
222
- let planState = null;
223
- let researchState = null;
224
219
  const normLive = (v) => (v === "always" ? "always" : v === "supplement" ? "supplement" : Boolean(v));
225
220
  let liveReferenceOn = normLive(liveReference);
226
221
  let synthesisBudgetOn = Number.isFinite(synthesisBudget) ? synthesisBudget : 12;
@@ -230,6 +225,26 @@ export function createResearchSession({ seedPayload = null, vocabSeeded = false,
230
225
  // (they ride its persisted state), so its queue stays internally consistent.
231
226
  let researchConfig = clampResearchConfig();
232
227
 
228
+ // The chat-engine turn (research/teach/ask), threading focus/last/planState/
229
+ // researchState across calls the way every browser chat dock does. A throw
230
+ // never kills the session — the page has no other chance to show this
231
+ // turn's answer.
232
+ const turnSession = createTurnSession({
233
+ memoryDir, graph, lexicon, vocabHint,
234
+ sessionId: globalThis.crypto?.randomUUID?.() ?? String(Date.now()),
235
+ buildExtraOptions: () => ({
236
+ researchConfig, liveReference: liveReferenceOn, onLiveLookup,
237
+ uiContext: "browser", synthesisBudget: synthesisBudgetOn,
238
+ }),
239
+ captureExtraState: (result) => {
240
+ if (typeof result.liveReference === "boolean" || result.liveReference === "supplement" || result.liveReference === "always") {
241
+ liveReferenceOn = result.liveReference;
242
+ }
243
+ },
244
+ });
245
+ const chatSessionId = turnSession.sessionId;
246
+ const sessionIds = { chatSessionId, ingestSessionId };
247
+
233
248
  return {
234
249
  memoryDir,
235
250
  chatSessionId,
@@ -245,28 +260,7 @@ export function createResearchSession({ seedPayload = null, vocabSeeded = false,
245
260
  * unset keys keep their current value. */
246
261
  setResearchConfig(partial) { researchConfig = clampResearchConfig({ ...researchConfig, ...(partial || {}) }); },
247
262
 
248
- /** One chat-engine turn (research/teach/ask). A throw never kills the
249
- * session — the page has no other chance to show this turn's answer. */
250
- async turn(line) {
251
- let result;
252
- try {
253
- result = await runTurn(line, {
254
- config: null, source: null, graph, focus, last, memoryDir, sessionId: chatSessionId,
255
- env: {}, lexicon, vocabHint, planState, researchState, researchConfig,
256
- liveReference: liveReferenceOn, onLiveLookup,
257
- uiContext: "browser", synthesisBudget: synthesisBudgetOn,
258
- });
259
- } catch (e) {
260
- const message = e instanceof Error ? e.message : String(e);
261
- return { answer: `Something went wrong with that (${message}). Try rephrasing.`, end: false, record: null, plan: null, research: undefined };
262
- }
263
- focus = result.focus;
264
- last = result.last;
265
- if ("planState" in result) planState = result.planState;
266
- if ("researchState" in result) researchState = result.researchState;
267
- if (typeof result.liveReference === "boolean" || result.liveReference === "supplement" || result.liveReference === "always") liveReferenceOn = result.liveReference;
268
- return { answer: result.answer, end: Boolean(result.end), record: result.record ?? null, plan: result.plan ?? null, research: result.research };
269
- },
263
+ turn: (line) => turnSession.turn(line),
270
264
 
271
265
  /** Ingest a document into the SAME store, under the ingest session id so
272
266
  * its facts carry the "ingest" source rather than "taught". */
@@ -333,15 +327,6 @@ export function createResearchSession({ seedPayload = null, vocabSeeded = false,
333
327
  };
334
328
  }
335
329
 
336
- /**
337
- * The session's whole triple store as JSONL — the same
338
- * { subject, predicate, object, provenance } shape `tmct extract` and
339
- * `tmct memory --export` emit, offered to the page as the canonical download.
340
- */
341
- export async function exportFactsJsonl(memoryDir) {
342
- return serializeFactsJsonl(await loadMemory(memoryDir));
343
- }
344
-
345
330
  globalThis.tmctResearch = {
346
331
  createResearchSession, researchSnapshot, exportFactsJsonl,
347
332
  registerWinkModel, registerReferencePackProvider, registerLiveReferenceProvider, registerResearchProvider,
@@ -43,14 +43,17 @@
43
43
  // spider-fly.mjs's own header comment confirms grid movement never reads
44
44
  // them back (hand-written pathfinding over has-exit-* facts, not the taught
45
45
  // action-rule DSL), so nothing here depends on them being loaded.
46
- import { runTurn } from "../../services/chat.mjs";
46
+ import { createTurnSession } from "./turn-session.mjs";
47
+ import { registerWinkModel } from "../../adapters/wink-model.mjs";
47
48
  import {
48
49
  createInMemoryStore, normFactTerm, appendFacts, loadMemory, readFactRows,
49
50
  } from "../../adapters/memory/core.mjs";
50
51
  import { parseEntities } from "../../domain/codegraph.mjs";
52
+ import { worldRelationGraphPayload } from "../../domain/ask.mjs";
51
53
  import { loadLexicon } from "../../domain/grammar/lexicon.mjs";
52
54
  import {
53
55
  worldFactRows, WORLD_NAME, WORLD_OPENING, cellId, parseCellId, DIRECTION_DELTA, visibleCells,
56
+ isLiveRenderableAgent, agentKindOf,
54
57
  } from "../../domain/spider-fly-world.mjs";
55
58
  import { foldSpiderFlyState, runSpiderFlyTick, startSpiderFlyGame, liveWebs, DEFAULT_VISION_RADIUS } from "../../services/spider-fly.mjs";
56
59
  import { pillsForSpiderFly, oneStepDirectionBetween } from "../../services/spider-fly-turn.mjs";
@@ -84,13 +87,27 @@ export async function createSpiderFlySession({ flyCount = 1 } = {}) {
84
87
  else if (f.predicate === "mgx:mass") initialAgents[f.subject] = { ...initialAgents[f.subject], mass: Number(f.object) };
85
88
  }
86
89
 
87
- const graph = parseEntities({ individuals: [], objectProperties: [] });
90
+ // The graph the chat dock's own ask() traverses. There is no code graph
91
+ // here, so it holds the LIVE BOARD instead: one individual per agent, classed
92
+ // by its id, carrying its current cell, mass and mood. That is what makes
93
+ // "list the locations of flies and spiders" a real ask() capability call
94
+ // rather than another hand-written filter over the same rows. Rebuilt before
95
+ // every chat turn, since every tick moves the pieces.
96
+ let graph = parseEntities({ individuals: [], objectProperties: [] });
97
+ const readBoard = async () => {
98
+ const rows = readFactRows(await loadMemory(memoryDir));
99
+ return { rows, state: foldSpiderFlyState(rows) };
100
+ };
101
+ async function refreshWorldGraph() {
102
+ const { rows, state } = await readBoard();
103
+ graph = parseEntities(worldRelationGraphPayload(rows, {
104
+ classOf: (id) => (isLiveRenderableAgent(id, state) ? agentKindOf(id) : null),
105
+ }));
106
+ }
107
+ await refreshWorldGraph();
88
108
  const lexicon = loadLexicon();
89
109
  const sessionId = globalThis.crypto?.randomUUID?.() ?? String(Date.now());
90
110
 
91
- let focus = null;
92
- let last = null;
93
- let planState = { spiderFly: { turn: 0 } };
94
111
  // The live, in-page-slider-adjustable knobs (mass-loss-rate/spawn-rate/
95
112
  // vision-radius per class, and every other spiderFly tunable) — starts at
96
113
  // the shipped defaults, mutated only through setConfig() below, and
@@ -100,6 +117,19 @@ export async function createSpiderFlySession({ flyCount = 1 } = {}) {
100
117
  // change only changes what happens FROM HERE ON, same as tmct.toml would.
101
118
  let config = { ...DEFAULT_GAME_CONFIG.spiderFly };
102
119
 
120
+ // The chat dock's turn dispatch — createTurnSession owns the focus/last/
121
+ // planState fold and the throw-safe catch fallback every browser entry
122
+ // needs; tick() below reaches into the SAME planState (via setPlanState)
123
+ // so a raw tick and a chat-driven tick can never disagree about the turn
124
+ // count either lane sees next.
125
+ const turnSession = createTurnSession({
126
+ memoryDir, graph, lexicon, sessionId, vocabHint: "",
127
+ // `graph` is re-read here, not captured above: createTurnSession binds its
128
+ // own once at creation, and this board is rebuilt every turn.
129
+ buildExtraOptions: () => ({ graph, gameConfig: { ...DEFAULT_GAME_CONFIG, spiderFly: config } }),
130
+ });
131
+ turnSession.setPlanState({ spiderFly: { turn: 0 } });
132
+
103
133
  return {
104
134
  memoryDir,
105
135
  sessionId,
@@ -111,30 +141,18 @@ export async function createSpiderFlySession({ flyCount = 1 } = {}) {
111
141
  * { turn, agents, ecology } shape unmodified. */
112
142
  async tick() {
113
143
  const result = await runSpiderFlyTick(memoryDir, { config });
114
- planState = { spiderFly: { turn: result.turn } };
144
+ turnSession.setPlanState({ spiderFly: { turn: result.turn } });
115
145
  return result;
116
146
  },
117
147
 
118
148
  /** One dispatched chat turn — the SAME runTurn the CLI and the home
119
- * page's own chat run, over this session's own memoryDir. A throwing
120
- * runTurn must never kill the session — the page has no other chance
121
- * to show this turn's answer. */
149
+ * page's own chat run, over this session's own memoryDir, via the
150
+ * shared turn-dispatch wrapper (createTurnSession above) every browser
151
+ * entry now uses. The board graph is rebuilt first, so a question about
152
+ * where the pieces are reads this turn's positions and not last turn's. */
122
153
  async turn(line) {
123
- let result;
124
- try {
125
- result = await runTurn(line, {
126
- config: null, source: null, graph, focus, last, memoryDir, sessionId,
127
- env: {}, lexicon, vocabHint: "", planState,
128
- gameConfig: { ...DEFAULT_GAME_CONFIG, spiderFly: config },
129
- });
130
- } catch (e) {
131
- const message = e instanceof Error ? e.message : String(e);
132
- return { answer: `Something went wrong answering that (${message}). Try rephrasing, or /help.`, end: false, record: null, plan: null };
133
- }
134
- focus = result.focus;
135
- last = result.last;
136
- if ("planState" in result) planState = result.planState;
137
- return { answer: result.answer, end: Boolean(result.end), record: result.record ?? null, plan: result.plan ?? null };
154
+ await refreshWorldGraph();
155
+ return turnSession.turn(line);
138
156
  },
139
157
 
140
158
  /** A read-only fold of the CURRENT board — no engine advance, no goal
@@ -143,11 +161,10 @@ export async function createSpiderFlySession({ flyCount = 1 } = {}) {
143
161
  * chat-driven tick. Web individuals are never listed as agents (that's
144
162
  * spider-1/fly-1/... only) — they surface only through activeWebs. */
145
163
  async snapshot() {
146
- const rows = readFactRows(await loadMemory(memoryDir));
147
- const state = foldSpiderFlyState(rows);
164
+ const { state } = await readBoard();
148
165
  const agents = {};
149
166
  for (const [id, place] of state.placements) {
150
- if (state.removed.has(id) || /^web-\d+$/.test(id)) continue;
167
+ if (!isLiveRenderableAgent(id, state)) continue;
151
168
  agents[id] = { cell: place.cell, mass: state.mass.get(id)?.value ?? null };
152
169
  }
153
170
  return { turn: state.turnCount, agents, activeWebs: liveWebs(state.webs, state.turnCount) };
@@ -183,5 +200,5 @@ export async function createSpiderFlySession({ flyCount = 1 } = {}) {
183
200
  globalThis.tmctSpiderFly = {
184
201
  createSpiderFlySession, normFactTerm, resolveSpriteForClass, SPRITE_REGISTRY, resolveSpriteAsset,
185
202
  cellId, parseCellId, DIRECTION_DELTA, visibleCells, DEFAULT_VISION_RADIUS,
186
- pillsForSpiderFly, oneStepDirectionBetween, DEFAULT_GAME_CONFIG,
203
+ pillsForSpiderFly, oneStepDirectionBetween, DEFAULT_GAME_CONFIG, registerWinkModel,
187
204
  };
@@ -9,16 +9,22 @@
9
9
  // (src/domain/sprite-facts.mjs's rows). A question the engine can't ground in
10
10
  // those rows gets the same refusal the CLI gives — never a guess.
11
11
  //
12
- // The page's own inline script answers the closed set of catalog-specific
13
- // question shapes (sprite-catalog-viz.mjs's answerSpriteQuestion) BEFORE
14
- // handing a line to this session, so this bundle carries no sprite-specific
15
- // grammar of its own.
16
- import { runTurn } from "../../services/chat.mjs";
12
+ // Every line the dock takes goes to that session the page intercepts
13
+ // nothing, so a catalog question is answered by the same membership, count and
14
+ // property lanes chat.mjs runs for any other caller, reading the sprite-facts
15
+ // predicates straight.
16
+ //
17
+ // The scene composer's parser rides along here too: extractSceneItems resolves
18
+ // a typed class name through ask.mjs's resolveObject, so it needs the real
19
+ // resolver in the page rather than a self-contained function the page could
20
+ // splice in as text.
17
21
  import { createInMemoryStore, appendFacts, normFactTerm } from "../../adapters/memory/core.mjs";
22
+ import { extractSceneItems } from "../../domain/scene-compose.mjs";
18
23
  import { parseEntities } from "../../domain/codegraph.mjs";
19
24
  import { loadLexicon } from "../../domain/grammar/lexicon.mjs";
20
25
  import { SPRITE_FACTS_PROVENANCE } from "../../domain/sprite-facts.mjs";
21
26
  import { registerWinkModel } from "../../adapters/wink-model.mjs";
27
+ import { createTurnSession } from "./turn-session.mjs";
22
28
 
23
29
  /** A live in-memory chat session seeded with the embedded sprite-facts rows.
24
30
  * Returns { memoryDir, sessionId, factCount, turn }. */
@@ -32,32 +38,13 @@ export async function createSpriteCatalogSession({ factRows = [] } = {}) {
32
38
  const lexicon = loadLexicon();
33
39
  const sessionId = globalThis.crypto?.randomUUID?.() ?? String(Date.now());
34
40
 
35
- let focus = null;
36
- let last = null;
41
+ const session = createTurnSession({ memoryDir, graph, lexicon, sessionId, vocabHint: "" });
37
42
 
38
43
  return {
39
44
  memoryDir,
40
45
  sessionId,
41
46
  factCount: factRows.length,
42
-
43
- /** One dispatched chat turn — the SAME runTurn the CLI runs, over this
44
- * session's own memoryDir. A throwing runTurn must never kill the
45
- * session — the page has no other chance to show this turn's answer. */
46
- async turn(line) {
47
- let result;
48
- try {
49
- result = await runTurn(line, {
50
- config: null, source: null, graph, focus, last, memoryDir, sessionId,
51
- env: {}, lexicon, vocabHint: "",
52
- });
53
- } catch (e) {
54
- const message = e instanceof Error ? e.message : String(e);
55
- return { answer: `Something went wrong answering that (${message}). Try rephrasing, or /help.`, end: false, record: null };
56
- }
57
- focus = result.focus;
58
- last = result.last;
59
- return { answer: result.answer, end: Boolean(result.end), record: result.record ?? null };
60
- },
47
+ turn: session.turn,
61
48
  };
62
49
  }
63
50
 
@@ -65,4 +52,4 @@ export async function createSpriteCatalogSession({ factRows = [] } = {}) {
65
52
  // the self-hosted wink pair (./vendor/wink.js) exactly the way chat.html/
66
53
  // ledger.html/plan.html register theirs — the bundle itself never imports
67
54
  // wink-nlp (wink-model.mjs's own header explains why).
68
- globalThis.tmctSprites = { createSpriteCatalogSession, registerWinkModel, normFactTerm };
55
+ globalThis.tmctSprites = { createSpriteCatalogSession, registerWinkModel, normFactTerm, extractSceneItems };