@polycode-projects/the-mechanical-code-talker 4.0.1 → 4.1.1

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 (65) hide show
  1. package/README.md +2 -1
  2. package/corpus/sprites/src/sprite-facts.jsonl +375 -8
  3. package/package.json +1 -1
  4. package/src/adapters/memory/core.mjs +20 -0
  5. package/src/domain/ask-vocab.mjs +71 -0
  6. package/src/domain/ask.mjs +168 -0
  7. package/src/domain/game-config.mjs +11 -0
  8. package/src/domain/mud-facts.mjs +15 -0
  9. package/src/domain/router/drive.mjs +35 -9
  10. package/src/domain/router/registry.mjs +24 -4
  11. package/src/domain/router/resolver.mjs +102 -40
  12. package/src/domain/scene-compose.mjs +117 -0
  13. package/src/domain/spider-fly-world.mjs +36 -0
  14. package/src/domain/sprite-facts.mjs +0 -0
  15. package/src/domain/sprite-request.mjs +156 -0
  16. package/src/domain/sprite-templates.mjs +161 -14
  17. package/src/services/adventure-editor.mjs +8 -14
  18. package/src/services/adventure-viz.mjs +119 -150
  19. package/src/services/adventure.mjs +97 -35
  20. package/src/services/chat-page-viz.mjs +64 -48
  21. package/src/services/chat.mjs +102 -34
  22. package/src/services/code-explorer-viz.mjs +52 -50
  23. package/src/services/ingest-viz.mjs +32 -74
  24. package/src/services/ledger-viz.mjs +87 -70
  25. package/src/services/memory-panel-viz.mjs +38 -0
  26. package/src/services/mud-editor.mjs +10 -15
  27. package/src/services/mud-turn.mjs +6 -6
  28. package/src/services/mud-viz.mjs +119 -225
  29. package/src/services/p2p-room.mjs +90 -23
  30. package/src/services/plan-pddl.mjs +3 -1
  31. package/src/services/plan-viz.mjs +13 -12
  32. package/src/services/research-viz.mjs +25 -67
  33. package/src/services/spider-fly-turn.mjs +14 -22
  34. package/src/services/spider-fly-viz.mjs +97 -136
  35. package/src/services/spider-fly.mjs +69 -11
  36. package/src/services/sprite-catalog-viz.mjs +274 -224
  37. package/src/services/viz-boot.mjs +71 -0
  38. package/src/services/viz-room-graph.mjs +203 -0
  39. package/src/services/viz-theme.mjs +75 -1
  40. package/src/services/viz-ticker.mjs +22 -0
  41. package/src/surfaces/web/adventure-browser-entry.mjs +62 -47
  42. package/src/surfaces/web/chat-browser-entry.mjs +51 -107
  43. package/src/surfaces/web/code-explorer-browser-entry.mjs +192 -35
  44. package/src/surfaces/web/engine-surface.mjs +82 -0
  45. package/src/surfaces/web/ingest-browser-entry.mjs +16 -17
  46. package/src/surfaces/web/ledger-browser-entry.mjs +24 -56
  47. package/src/surfaces/web/memory-ask-browser-entry.mjs +55 -13
  48. package/src/surfaces/web/memory-ask-browser.bundle.js +128 -125
  49. package/src/surfaces/web/memory-stats.mjs +11 -0
  50. package/src/surfaces/web/mud-browser-entry.mjs +70 -49
  51. package/src/surfaces/web/plan-browser-entry.mjs +39 -50
  52. package/src/surfaces/web/research-browser-entry.mjs +48 -46
  53. package/src/surfaces/web/spider-fly-browser-entry.mjs +76 -40
  54. package/src/surfaces/web/sprites-browser-entry.mjs +28 -32
  55. package/src/surfaces/web/tmct-surface.mjs +147 -0
  56. package/src/surfaces/web/turn-session.mjs +124 -0
  57. package/src/tools/definitions.mjs +30 -0
  58. package/src/tools/handlers/index.mjs +6 -3
  59. package/src/tools/handlers/kit.mjs +19 -2
  60. package/src/tools/handlers/tmct-ask.mjs +11 -6
  61. package/src/tools/handlers/tmct-ingest.mjs +5 -1
  62. package/src/tools/handlers/tmct-related.mjs +4 -4
  63. package/src/tools/handlers/tmct-sprite.mjs +147 -0
  64. package/src/tools/memory-fallthrough.mjs +9 -2
  65. package/src/tools/server.mjs +37 -6
@@ -43,14 +43,19 @@
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 { publishTmctSurface } from "./tmct-surface.mjs";
48
+ import { graphAsk, enginePlan } from "./engine-surface.mjs";
49
+ import { registerWinkModel } from "../../adapters/wink-model.mjs";
47
50
  import {
48
51
  createInMemoryStore, normFactTerm, appendFacts, loadMemory, readFactRows,
49
52
  } from "../../adapters/memory/core.mjs";
50
53
  import { parseEntities } from "../../domain/codegraph.mjs";
54
+ import { worldRelationGraphPayload } from "../../domain/ask.mjs";
51
55
  import { loadLexicon } from "../../domain/grammar/lexicon.mjs";
52
56
  import {
53
57
  worldFactRows, WORLD_NAME, WORLD_OPENING, cellId, parseCellId, DIRECTION_DELTA, visibleCells,
58
+ isLiveRenderableAgent, agentKindOf,
54
59
  } from "../../domain/spider-fly-world.mjs";
55
60
  import { foldSpiderFlyState, runSpiderFlyTick, startSpiderFlyGame, liveWebs, DEFAULT_VISION_RADIUS } from "../../services/spider-fly.mjs";
56
61
  import { pillsForSpiderFly, oneStepDirectionBetween } from "../../services/spider-fly-turn.mjs";
@@ -84,13 +89,27 @@ export async function createSpiderFlySession({ flyCount = 1 } = {}) {
84
89
  else if (f.predicate === "mgx:mass") initialAgents[f.subject] = { ...initialAgents[f.subject], mass: Number(f.object) };
85
90
  }
86
91
 
87
- const graph = parseEntities({ individuals: [], objectProperties: [] });
92
+ // The graph the chat dock's own ask() traverses. There is no code graph
93
+ // here, so it holds the LIVE BOARD instead: one individual per agent, classed
94
+ // by its id, carrying its current cell, mass and mood. That is what makes
95
+ // "list the locations of flies and spiders" a real ask() capability call
96
+ // rather than another hand-written filter over the same rows. Rebuilt before
97
+ // every chat turn, since every tick moves the pieces.
98
+ let graph = parseEntities({ individuals: [], objectProperties: [] });
99
+ const readBoard = async () => {
100
+ const rows = readFactRows(await loadMemory(memoryDir));
101
+ return { rows, state: foldSpiderFlyState(rows) };
102
+ };
103
+ async function refreshWorldGraph() {
104
+ const { rows, state } = await readBoard();
105
+ graph = parseEntities(worldRelationGraphPayload(rows, {
106
+ classOf: (id) => (isLiveRenderableAgent(id, state) ? agentKindOf(id) : null),
107
+ }));
108
+ }
109
+ await refreshWorldGraph();
88
110
  const lexicon = loadLexicon();
89
111
  const sessionId = globalThis.crypto?.randomUUID?.() ?? String(Date.now());
90
112
 
91
- let focus = null;
92
- let last = null;
93
- let planState = { spiderFly: { turn: 0 } };
94
113
  // The live, in-page-slider-adjustable knobs (mass-loss-rate/spawn-rate/
95
114
  // vision-radius per class, and every other spiderFly tunable) — starts at
96
115
  // the shipped defaults, mutated only through setConfig() below, and
@@ -100,6 +119,19 @@ export async function createSpiderFlySession({ flyCount = 1 } = {}) {
100
119
  // change only changes what happens FROM HERE ON, same as tmct.toml would.
101
120
  let config = { ...DEFAULT_GAME_CONFIG.spiderFly };
102
121
 
122
+ // The chat dock's turn dispatch — createTurnSession owns the focus/last/
123
+ // planState fold and the throw-safe catch fallback every browser entry
124
+ // needs; tick() below reaches into the SAME planState (via setPlanState)
125
+ // so a raw tick and a chat-driven tick can never disagree about the turn
126
+ // count either lane sees next.
127
+ const turnSession = createTurnSession({
128
+ memoryDir, graph, lexicon, sessionId, vocabHint: "",
129
+ // `graph` is re-read here, not captured above: createTurnSession binds its
130
+ // own once at creation, and this board is rebuilt every turn.
131
+ buildExtraOptions: () => ({ graph, gameConfig: { ...DEFAULT_GAME_CONFIG, spiderFly: config } }),
132
+ });
133
+ turnSession.setPlanState({ spiderFly: { turn: 0 } });
134
+
103
135
  return {
104
136
  memoryDir,
105
137
  sessionId,
@@ -107,34 +139,32 @@ export async function createSpiderFlySession({ flyCount = 1 } = {}) {
107
139
  initial: { turn: 0, agents: initialAgents, activeWebs: [] },
108
140
  taxonomyRows,
109
141
 
142
+ /** The board as a graph, as of the last refresh. Every tick moves the
143
+ * pieces, so a caller putting a question to it calls `refreshGraph()`
144
+ * first — which is exactly what `turn()` below already does. */
145
+ get graph() { return graph; },
146
+
147
+ /** Rebuild the board graph from the store's current rows. Exposed so a
148
+ * question asked outside the chat dock (tmct.ask) reads this turn's
149
+ * positions and not last turn's, the same way a typed one does. */
150
+ refreshGraph: refreshWorldGraph,
151
+
110
152
  /** Run one real engine turn directly. Returns spider-fly.mjs's own
111
153
  * { turn, agents, ecology } shape unmodified. */
112
154
  async tick() {
113
155
  const result = await runSpiderFlyTick(memoryDir, { config });
114
- planState = { spiderFly: { turn: result.turn } };
156
+ turnSession.setPlanState({ spiderFly: { turn: result.turn } });
115
157
  return result;
116
158
  },
117
159
 
118
160
  /** 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. */
161
+ * page's own chat run, over this session's own memoryDir, via the
162
+ * shared turn-dispatch wrapper (createTurnSession above) every browser
163
+ * entry now uses. The board graph is rebuilt first, so a question about
164
+ * where the pieces are reads this turn's positions and not last turn's. */
122
165
  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 };
166
+ await refreshWorldGraph();
167
+ return turnSession.turn(line);
138
168
  },
139
169
 
140
170
  /** A read-only fold of the CURRENT board — no engine advance, no goal
@@ -143,11 +173,10 @@ export async function createSpiderFlySession({ flyCount = 1 } = {}) {
143
173
  * chat-driven tick. Web individuals are never listed as agents (that's
144
174
  * spider-1/fly-1/... only) — they surface only through activeWebs. */
145
175
  async snapshot() {
146
- const rows = readFactRows(await loadMemory(memoryDir));
147
- const state = foldSpiderFlyState(rows);
176
+ const { state } = await readBoard();
148
177
  const agents = {};
149
178
  for (const [id, place] of state.placements) {
150
- if (state.removed.has(id) || /^web-\d+$/.test(id)) continue;
179
+ if (!isLiveRenderableAgent(id, state)) continue;
151
180
  agents[id] = { cell: place.cell, mass: state.mass.get(id)?.value ?? null };
152
181
  }
153
182
  return { turn: state.turnCount, agents, activeWebs: liveWebs(state.webs, state.turnCount) };
@@ -172,16 +201,23 @@ export async function createSpiderFlySession({ flyCount = 1 } = {}) {
172
201
  };
173
202
  }
174
203
 
175
- // cellId/parseCellId/DIRECTION_DELTA/visibleCells/DEFAULT_VISION_RADIUS are
176
- // re-exported so the page's own rendering script (spider-fly-viz.mjs) never
177
- // has to duplicate grid geometry or the vision-radius default: reconstructing
178
- // a spider's remaining silk-thread path from its returned direction list, and
179
- // computing the POV overlay's visible-cell mask, both need them.
180
- // pillsForSpiderFly/oneStepDirectionBetween are re-exported so the same page
181
- // can build its own dynamic deception-pill container without duplicating
182
- // spider-fly-turn.mjs's own pill logic.
183
- globalThis.tmctSpiderFly = {
184
- createSpiderFlySession, normFactTerm, resolveSpriteForClass, SPRITE_REGISTRY, resolveSpriteAsset,
185
- cellId, parseCellId, DIRECTION_DELTA, visibleCells, DEFAULT_VISION_RADIUS,
186
- pillsForSpiderFly, oneStepDirectionBetween, DEFAULT_GAME_CONFIG,
187
- };
204
+ // `tmct.page` is grid geometry and sprite resolution — the two things on this
205
+ // page the engine has no plain-English form for. Reconstructing a spider's
206
+ // remaining silk-thread path from its returned direction list and masking the
207
+ // POV overlay's visible cells both need the raw cell math; the deception pills
208
+ // need spider-fly-turn.mjs's own pill logic.
209
+ publishTmctSurface({
210
+ open: createSpiderFlySession,
211
+ // The board moves every tick, so a question rebuilds the graph first —
212
+ // the same refresh a typed turn does before it dispatches.
213
+ ask: async (request, options, session) => {
214
+ await session.refreshGraph();
215
+ return graphAsk(request, options, session);
216
+ },
217
+ plan: enginePlan,
218
+ page: {
219
+ normFactTerm, resolveSpriteForClass, SPRITE_REGISTRY, resolveSpriteAsset,
220
+ cellId, parseCellId, DIRECTION_DELTA, visibleCells, DEFAULT_VISION_RADIUS,
221
+ pillsForSpiderFly, oneStepDirectionBetween, DEFAULT_GAME_CONFIG, registerWinkModel,
222
+ },
223
+ });
@@ -9,19 +9,27 @@
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";
28
+ import { publishTmctSurface } from "./tmct-surface.mjs";
29
+ import { graphAsk, enginePlan } from "./engine-surface.mjs";
22
30
 
23
31
  /** A live in-memory chat session seeded with the embedded sprite-facts rows.
24
- * Returns { memoryDir, sessionId, factCount, turn }. */
32
+ * Returns { memoryDir, sessionId, graph, factCount, turn }. */
25
33
  export async function createSpriteCatalogSession({ factRows = [] } = {}) {
26
34
  const memoryDir = createInMemoryStore();
27
35
  await appendFacts(memoryDir, factRows.map((f) => ({
@@ -32,37 +40,25 @@ export async function createSpriteCatalogSession({ factRows = [] } = {}) {
32
40
  const lexicon = loadLexicon();
33
41
  const sessionId = globalThis.crypto?.randomUUID?.() ?? String(Date.now());
34
42
 
35
- let focus = null;
36
- let last = null;
43
+ const session = createTurnSession({ memoryDir, graph, lexicon, sessionId, vocabHint: "" });
37
44
 
38
45
  return {
39
46
  memoryDir,
40
47
  sessionId,
48
+ graph,
41
49
  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
- },
50
+ turn: session.turn,
61
51
  };
62
52
  }
63
53
 
64
- // registerWinkModel is re-exported so the page's own inline script can hand in
65
- // the self-hosted wink pair (./vendor/wink.js) exactly the way chat.html/
66
- // ledger.html/plan.html register theirs — the bundle itself never imports
67
- // wink-nlp (wink-model.mjs's own header explains why).
68
- globalThis.tmctSprites = { createSpriteCatalogSession, registerWinkModel, normFactTerm };
54
+ // `tmct.page` keeps the wink seam (the page hands in the self-hosted pair from
55
+ // ./vendor/wink.js, exactly the way chat.html/ledger.html/plan.html register
56
+ // theirs — the bundle itself never imports wink-nlp), the term normalizer, and
57
+ // the scene composer's parser, which reads a typed line into drawable items
58
+ // rather than answering anything.
59
+ publishTmctSurface({
60
+ open: createSpriteCatalogSession,
61
+ ask: graphAsk,
62
+ plan: enginePlan,
63
+ page: { registerWinkModel, normFactTerm, extractSceneItems },
64
+ });
@@ -0,0 +1,147 @@
1
+ // tmct-surface.mjs — the one contract every demo page has with the engine.
2
+ //
3
+ // Each browser entry used to publish its own bag of primitives under its own
4
+ // name (tmctChat, tmctMud, tmctLedger, tmctSpiderFly, ...), so a visitor
5
+ // reading one page's source met a different wall of raw functions on every
6
+ // page, wired together by hand. mud-browser-entry.mjs's own header stated the
7
+ // reason for its 25: "so mud-viz.mjs's own inlined script never duplicates
8
+ // sprite resolution or the digest/affordance/knowledge readers". Right
9
+ // instinct, wrong unit — the page still had to know 25 names to say one
10
+ // sentence.
11
+ //
12
+ // This publishes ONE `globalThis.tmct` instead, and every page reaches the
13
+ // engine the same way:
14
+ //
15
+ // await tmct.open(...) open this page's session
16
+ // tmct.session the live session, once opened
17
+ // await tmct.turn(line) one conversational turn, the dock's entry point
18
+ // await tmct.ask(request) one grounded question, prose plus its evidence
19
+ // await tmct.plan(request) one compound request, planned over what's loaded
20
+ //
21
+ // Whatever a page still needs that has NO plain-English form lives on
22
+ // `tmct.page` — canvas geometry, sprite templates, a wink model to register, a
23
+ // digest structure table. The split is the point: `tmct.ask(...)` is tmct
24
+ // answering, `tmct.page.cellId(...)` is this page drawing. A reader can tell
25
+ // them apart at a glance, which is exactly what eleven flat bags made
26
+ // impossible.
27
+ //
28
+ // `ask` and `plan` are supplied per page rather than fixed here, because what
29
+ // a page can ground a question against genuinely differs: code-explorer holds
30
+ // a code graph, spider-fly holds a live board projected into one, the ledger
31
+ // dock holds a memory store and no graph at all. A page that supplies neither
32
+ // gets an honest refusal naming why, never a guess — the same promise the
33
+ // engine itself makes.
34
+
35
+ const NO_SESSION = "no session is open on this page yet — await tmct.open() first";
36
+
37
+ // The demo ledger page carries TWO bundles at once: the committed
38
+ // question-only one inlined into the HTML (memory-ask-browser-entry.mjs) and
39
+ // the demo site's own full turn engine as a separate script tag
40
+ // (ledger-browser-entry.mjs). Both publish this surface, and which one the
41
+ // browser runs first is not something either can promise. So the
42
+ // question-only one publishes as a `fallback` and the full engine always
43
+ // wins, whichever order they load in. Registered globally rather than
44
+ // module-locally because the two bundles carry their own copy of this module.
45
+ const FALLBACK = Symbol.for("tmct.surface.fallback");
46
+
47
+ /**
48
+ * `publishTmctSurface({ open, ask, plan, turn, page, target })` installs
49
+ * `target.tmct` (default `globalThis`) and returns it.
50
+ *
51
+ * `open(...args)` is the page's own session factory and is the only required
52
+ * argument — `createChatSession`, `createMudSession`, `createPlanSession` and
53
+ * the rest, called with whatever arguments they already take. Its result
54
+ * becomes `tmct.session`.
55
+ *
56
+ * `turn(line, options, session)` (optional) overrides how a line reaches the
57
+ * session. The default calls `session.turn(line, options)`, which is every
58
+ * page but mud — mud runs several characters over one world, so it routes on
59
+ * `{ as: character }` to that character's own window.
60
+ *
61
+ * `ask(request, options, session)` and `plan(request, options, session)`
62
+ * (optional) are the page's grounded-question and capability-planner routes.
63
+ * Left out, the matching verb refuses and says which route the page does have.
64
+ * Every verb takes the same `(what, options)` shape, so the research page's
65
+ * source-scoped `tmct.ask(q, { sources })` reads like the plan page's
66
+ * `tmct.turn("solve it", { maxDepth })`.
67
+ *
68
+ * `page` is the residual bag: this page's own rendering helpers, reachable as
69
+ * `tmct.page.*`.
70
+ *
71
+ * `fallback: true` publishes only where no fuller surface is standing, and
72
+ * yields to one that arrives later — the ledger page's two bundles.
73
+ */
74
+ export function publishTmctSurface({
75
+ open, ask = null, plan = null, turn = null, page = {}, fallback = false, target = globalThis,
76
+ } = {}) {
77
+ if (typeof open !== "function") {
78
+ throw new Error("publishTmctSurface: `open` must be this page's session factory");
79
+ }
80
+ const standing = target.tmct;
81
+ if (fallback && standing && !standing[FALLBACK]) return standing;
82
+ let session = null;
83
+
84
+ const liveSession = () => {
85
+ if (!session) throw new Error(NO_SESSION);
86
+ return session;
87
+ };
88
+
89
+ const surface = {
90
+ // Variadic, so each page's factory keeps the signature it already has —
91
+ // adventure and mud both take the world payload first, then their options.
92
+ async open(...args) {
93
+ session = await open(...args);
94
+ return session;
95
+ },
96
+
97
+ get session() { return session; },
98
+
99
+ async turn(line, options) {
100
+ const live = liveSession();
101
+ if (turn) return turn(line, options, live);
102
+ // Not every page holds a conversation. The ingest page grounds pasted
103
+ // prose into facts and answers nothing, so it says so rather than
104
+ // failing on a method it never had.
105
+ if (typeof live.turn !== "function") {
106
+ return {
107
+ answer: "this page runs no conversational turn — see tmct.session for what it does run",
108
+ end: false, record: null, plan: null, research: null,
109
+ };
110
+ }
111
+ return live.turn(line, options);
112
+ },
113
+
114
+ async ask(request, options) {
115
+ if (!ask) {
116
+ return {
117
+ answer: "this page has no separate ask route — it answers questions through tmct.turn()",
118
+ data: undefined,
119
+ miss: true,
120
+ };
121
+ }
122
+ return ask(request, options, liveSession());
123
+ },
124
+
125
+ async plan(request, options) {
126
+ if (!plan) {
127
+ return {
128
+ refused: true,
129
+ why: "this page's bundle carries no capability planner",
130
+ driver: null, calls: [], composed: null, observed: null,
131
+ };
132
+ }
133
+ return plan(request, options, liveSession());
134
+ },
135
+
136
+ page,
137
+
138
+ /** True where this is the question-only stand-in rather than a page's full
139
+ * engine. The ledger page reads it to tell its two docks apart: the live
140
+ * one teaches, the stand-in only answers. */
141
+ fallback,
142
+ };
143
+
144
+ surface[FALLBACK] = fallback;
145
+ target.tmct = surface;
146
+ return surface;
147
+ }
@@ -0,0 +1,124 @@
1
+ // turn-session.mjs — the turn-dispatch wrapper nine near-identical browser
2
+ // sessions each hand-rolled (chat-browser-entry.mjs, code-explorer-browser-
3
+ // entry.mjs, ledger-browser-entry.mjs, adventure-browser-entry.mjs, spider-
4
+ // fly-browser-entry.mjs, plan-browser-entry.mjs, research-browser-entry.mjs,
5
+ // mud-browser-entry.mjs, sprites-browser-entry.mjs): a closure over
6
+ // focus/last/planState/researchState, a `try { runTurn(...) } catch` that
7
+ // turns a throw into a safe answer instead of ending the session (the page
8
+ // has no other chance to show that turn's reply), the post-call state
9
+ // capture, and a return shape covering every field one caller or another
10
+ // reads back.
11
+ //
12
+ // The nine differ in what they hand `runTurn` beyond the common core
13
+ // (`uiContext`, `synthesisBudget`, `gameConfig`, `researchConfig`,
14
+ // `actingSubject`, a per-character `sessionId`...) and in what they do with a
15
+ // turn's result besides the standard focus/last/planState/researchState fold
16
+ // (sync an externally-held plan holder, grow a visited-rooms set, bump a
17
+ // per-character turn tally). `buildExtraOptions`/`captureExtraState` below
18
+ // are exactly those two seams — everything else is common enough to live
19
+ // here once.
20
+ //
21
+ // The catch fallback text converges on the FULLEST of the nine: several
22
+ // callers had already drifted to a shorter one (mud-browser-entry.mjs drops
23
+ // "or /help"; research-browser-entry.mjs drops "or /help" AND "answering
24
+ // that") — proof, not just a risk, that duplicating this string invites
25
+ // exactly this drift.
26
+ import { runTurn as defaultRunTurn } from "../../services/chat.mjs";
27
+
28
+ function turnErrorFallback(message) {
29
+ return `Something went wrong answering that (${message}). Try rephrasing, or /help.`;
30
+ }
31
+
32
+ /**
33
+ * `createTurnSession({ memoryDir, graph, lexicon, sessionId, vocabHint,
34
+ * buildExtraOptions, captureExtraState })` — a live turn-dispatch session
35
+ * over the real engine (`runTurn`), threading focus/last/planState/
36
+ * researchState across calls the way every browser chat dock does.
37
+ *
38
+ * `vocabHint` is the session-lifetime hint every caller computes once at
39
+ * creation (a fixed string, or `vocabExampleHint(vocabSeeded)`'s result) —
40
+ * never recomputed per turn.
41
+ *
42
+ * `buildExtraOptions(state, callArgs)` (optional) returns extra `runTurn`
43
+ * options to merge OVER the defaults below — anything a specific page needs
44
+ * (`uiContext: "browser"`, `synthesisBudget`, `gameConfig`, `researchConfig`,
45
+ * a per-character `actingSubject`/`sessionId`, or a `planState` read from an
46
+ * external holder instead of this closure's own). `state` is
47
+ * `{ focus, last, planState, researchState }` as this turn is about to run;
48
+ * `callArgs` is whatever extra argument `turn(line, callArgs)` was given
49
+ * (plan-browser-entry.mjs's own per-call `{ maxDepth }` override, for one).
50
+ *
51
+ * `captureExtraState(result, state, callArgs)` (optional, may be async) runs
52
+ * once after a SUCCESSFUL turn, with `state` already folded forward
53
+ * (focus/last/planState/researchState updated) — the caller's own chance to
54
+ * do further bookkeeping (sync an external plan holder, reload memory to
55
+ * grow a visited-rooms set, bump a turn tally). Never runs on a throw: none
56
+ * of the nine originals touch their own state when `runTurn` itself failed.
57
+ *
58
+ * Returns `{ memoryDir, sessionId, graph, turn, focus, last, planState,
59
+ * setPlanState, researchState, setResearchState }`. `graph` is the one this
60
+ * session was built over, exposed so a caller can put a question to the same
61
+ * graph its turns run against (engine-surface.mjs's `graphAsk`) without
62
+ * holding a second reference to it. `turn(line, callArgs)`
63
+ * resolves to `{ answer, end, record, plan, research }` — the union of every
64
+ * field any of the nine originals reads; a caller ignores what it doesn't
65
+ * need. `research` is passed through undefined/null/a queue snapshot exactly
66
+ * as `runTurn` returned it (a caller may distinguish "not a research turn"
67
+ * from "a research turn that just ended").
68
+ *
69
+ * `runTurn` (default: the real engine) is injectable, purely so a test can
70
+ * force the catch path deterministically without needing an input that
71
+ * happens to make the real, deliberately crash-resistant engine throw.
72
+ */
73
+ export function createTurnSession({
74
+ memoryDir, graph, lexicon, sessionId, vocabHint = "",
75
+ buildExtraOptions = () => ({}),
76
+ captureExtraState = async () => {},
77
+ runTurn = defaultRunTurn,
78
+ } = {}) {
79
+ let focus = null;
80
+ let last = null;
81
+ let planState = null;
82
+ let researchState = null;
83
+
84
+ async function turn(line, callArgs) {
85
+ const before = { focus, last, planState, researchState };
86
+ const extra = buildExtraOptions(before, callArgs) || {};
87
+ let result;
88
+ try {
89
+ result = await runTurn(line, {
90
+ config: null, source: null, graph, focus, last, memoryDir, sessionId,
91
+ env: {}, lexicon, vocabHint, planState, researchState,
92
+ ...extra,
93
+ });
94
+ } catch (e) {
95
+ const message = e instanceof Error ? e.message : String(e);
96
+ return { answer: turnErrorFallback(message), end: false, record: null, plan: null, research: null };
97
+ }
98
+ focus = result.focus;
99
+ last = result.last;
100
+ if ("planState" in result) planState = result.planState;
101
+ if ("researchState" in result) researchState = result.researchState;
102
+ await captureExtraState(result, { focus, last, planState, researchState }, callArgs);
103
+ return {
104
+ answer: result.answer,
105
+ end: Boolean(result.end),
106
+ record: result.record ?? null,
107
+ plan: result.plan ?? null,
108
+ research: result.research,
109
+ };
110
+ }
111
+
112
+ return {
113
+ memoryDir,
114
+ sessionId,
115
+ graph,
116
+ get focus() { return focus; },
117
+ get last() { return last; },
118
+ get planState() { return planState; },
119
+ setPlanState(next) { planState = next; },
120
+ get researchState() { return researchState; },
121
+ setResearchState(next) { researchState = next; },
122
+ turn,
123
+ };
124
+ }
@@ -20,12 +20,19 @@
20
20
  // restated here, so a verb the parser learns is a verb the documentation gains.
21
21
 
22
22
  import { RELATIONS, WHERE_MARKERS } from "../domain/ask-vocab.mjs";
23
+ import { EXPRESSION_PALETTE } from "../domain/sprite-expressions.mjs";
23
24
 
24
25
  /** The relation kinds tmct_ask traverses, each with the opening verbs a question can
25
26
  * use for it. Read from the parser's RELATIONS table — never a hand-kept copy. */
26
27
  export const askLexicon = () =>
27
28
  Object.entries(RELATIONS).map(([kind, { verbs }]) => ({ kind, verbs: verbs.slice(0, 4) }));
28
29
 
30
+ /** The expression words tmct_sprite's schema offers. Read from the face-fragment
31
+ * palette sprite-expressions.mjs already ships — the same derivation askLexicon
32
+ * does for the ask verbs, so an expression the palette gains is an expression
33
+ * the schema gains. */
34
+ export const spriteExpressionEnum = () => Object.keys(EXPRESSION_PALETTE).sort();
35
+
29
36
  const symbolArg = (description) => ({
30
37
  type: "object",
31
38
  required: ["symbol"],
@@ -193,6 +200,29 @@ export const TOOL_DEFINITIONS = Object.freeze([
193
200
  },
194
201
  example: { term: "sofa" },
195
202
  },
203
+ {
204
+ name: "tmct_sprite",
205
+ tier: "cold",
206
+ summary:
207
+ "The sprite markup for a class, resolved up the taught rdfs:subClassOf chain — with the expression and size asked for, and the ancestor chain the resolver walked to find it.",
208
+ inputSchema: {
209
+ type: "object",
210
+ required: ["class"],
211
+ properties: {
212
+ class: { type: "string", description: "The class to draw, e.g. spider. Resolved through the memory graph's own rdfs:subClassOf taxonomy." },
213
+ expression: {
214
+ type: "string",
215
+ enum: spriteExpressionEnum(),
216
+ description: "The face the sprite wears, as an mgx:feels value. A class whose template takes no expression parameter misses rather than dropping it.",
217
+ },
218
+ size: {
219
+ type: "string",
220
+ description: "A taught size property word (mgx:hasProperty), resolved to a numeric render SCALE — the size of the thing drawn, not a choice of template tier.",
221
+ },
222
+ },
223
+ },
224
+ example: { class: "spider", expression: "happy", size: "large" },
225
+ },
196
226
  {
197
227
  name: "tmct_architecture",
198
228
  tier: "cold",
@@ -1,9 +1,10 @@
1
1
  // The tool-name → handler registry dispatchTool maps over. One module per tool, so a
2
2
  // tool's behaviour has exactly one home and this file only wires names to it.
3
3
  //
4
- // A handler takes (args, ctx) and returns the caller-facing string. ctx carries the
5
- // already-loaded { graph, svc, config, repoRoot }. A handler marked `ownsGraphLoad`
6
- // gets { config, source, tel } instead and loads whatever it needs itself.
4
+ // A handler takes (args, ctx) and returns the caller-facing string, or kit.mjs's
5
+ // toolResult({ content, data }) when it already holds the structured form of what that
6
+ // string says. ctx carries the already-loaded { graph, svc, config, repoRoot }. A handler
7
+ // marked `ownsGraphLoad` gets { config, source, tel } instead and loads what it needs.
7
8
 
8
9
  import { tmct_context } from "./tmct-context.mjs";
9
10
  import { tmct_context_more } from "./tmct-context-more.mjs";
@@ -15,6 +16,7 @@ import { tmct_search } from "./tmct-search.mjs";
15
16
  import { tmct_members } from "./tmct-members.mjs";
16
17
  import { tmct_subclasses } from "./tmct-subclasses.mjs";
17
18
  import { tmct_related } from "./tmct-related.mjs";
19
+ import { tmct_sprite } from "./tmct-sprite.mjs";
18
20
  import { tmct_architecture } from "./tmct-architecture.mjs";
19
21
  import { tmct_exports } from "./tmct-exports.mjs";
20
22
  import { tmct_untested } from "./tmct-untested.mjs";
@@ -42,6 +44,7 @@ export const HANDLERS = Object.freeze({
42
44
  tmct_members,
43
45
  tmct_subclasses,
44
46
  tmct_related,
47
+ tmct_sprite,
45
48
  tmct_architecture,
46
49
  tmct_exports,
47
50
  tmct_untested,
@@ -1,11 +1,28 @@
1
- // What every per-tool handler module shares: the argument contract, the resolve-or-miss
2
- // contract, and the one shape most cold tools have (take a symbol, resolve it, render it).
1
+ // What every per-tool handler module shares: the argument contract, the result contract,
2
+ // the resolve-or-miss contract, and the one shape most cold tools have (take a symbol,
3
+ // resolve it, render it).
3
4
 
4
5
  import { ToolError } from "../../adapters/config.mjs";
5
6
  import { resolveSymbol } from "../../domain/codegraph.mjs";
6
7
 
7
8
  export const SNIPPET_MAX_LINES = 200;
8
9
 
10
+ /** A handler's answer when it already holds the structured form of what its prose says.
11
+ * `content` is the prose. `data` is that structure, handed to dispatchToolStructured
12
+ * callers as-is so a page renders it instead of parsing the sentence back apart.
13
+ * `text` is what the string-only callers (dispatchTool, the CLI `cli <tool>` route)
14
+ * receive; it defaults to `content`, and a tool sets it only where its flat string
15
+ * already carried the structure in-band. A handler with nothing structured to add
16
+ * keeps returning a bare string, and `data` is absent for it. */
17
+ export function toolResult({ content, data, text = null }) {
18
+ return { content, data, text: text ?? content };
19
+ }
20
+
21
+ /** A handler may return either shape, so both dispatch paths have to tell them apart. */
22
+ export const isToolResult = (value) =>
23
+ !!value && typeof value === "object"
24
+ && typeof value.content === "string" && typeof value.text === "string";
25
+
9
26
  /** A required string argument, trimmed. Missing or blank raises "<key> is required". */
10
27
  export function requiredArg(args, key) {
11
28
  const value = String(args?.[key] || "").trim();