@polycode-projects/the-mechanical-code-talker 4.1.0 → 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.
@@ -48,6 +48,8 @@ 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
50
  import { createTurnSession } from "./turn-session.mjs";
51
+ import { publishTmctSurface } from "./tmct-surface.mjs";
52
+ import { graphAsk, enginePlan } from "./engine-surface.mjs";
51
53
 
52
54
  /** A live, shared mud world several characters can each act in. `worldPayload`
53
55
  * is `{ name, facts, rules, opening }` — the same shape adventure-browser-
@@ -60,7 +62,7 @@ import { createTurnSession } from "./turn-session.mjs";
60
62
  * are minted, fewer leaves the ones nobody is playing out of the world
61
63
  * altogether.
62
64
  *
63
- * Returns `{ memoryDir, windows, snapshot }`. `windows` is a plain object
65
+ * Returns `{ memoryDir, graph, windows, snapshot }`. `windows` is a plain object
64
66
  * keyed by character id, each value `{ character, turn, autoplayTick,
65
67
  * visitedRoomIds, turnsTaken, isOutOfPlay, outOfPlayReason }`. `snapshot()` is
66
68
  * the one OMNISCIENT read this module exposes — the central world map's own
@@ -270,7 +272,7 @@ export async function createMudSession(worldPayload, { characters = [], epoch =
270
272
  return claims.length;
271
273
  }
272
274
 
273
- return { memoryDir, windows, snapshot, applyEdit, wave, claimCharacters };
275
+ return { memoryDir, graph, windows, snapshot, applyEdit, wave, claimCharacters };
274
276
  }
275
277
 
276
278
  /** `count` entries drawn at random from `roster`, in random order, without
@@ -351,22 +353,41 @@ export function worldFactsForCast(facts, characters) {
351
353
  return rows.filter((f) => !uncast.has(f.subject)).concat(mintedCharacterFacts(rows, characters));
352
354
  }
353
355
 
354
- // Re-exported so mud-viz.mjs's own inlined script never duplicates sprite
355
- // resolution or the digest/affordance/knowledge readers its room view and
356
- // chat pills already need the same reach-through-the-global posture
357
- // adventure-browser-entry.mjs's own globalThis.tmctAdventure takes.
358
- globalThis.tmctMud = {
359
- createMudSession, pickMudRoster, expandMudRoster, mintedCharacterFacts, worldFactsForCast,
360
- resolveSpriteForClass, SPRITE_REGISTRY, classAncestorChain, resolveSpriteAsset,
361
- foldWorldState, worldActionRows, worldDigestRows, roomAffordances,
362
- personKnowledgeLines, personKnownFoodLines,
363
- diggableDirections, castInRoom, displayNameOf, isOutOfPlay, outOfPlayReasonOf, outOfPlayPhrase,
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,
369
- // The edit mode's own reach-throughs: the SKOS neighbourhood and the is-a
370
- // chain its cursor-suggestion pills read, neither of which is splice-safe.
371
- relatedForTerm,
372
- };
356
+ // Several characters, one world, so a line has to say who is speaking:
357
+ // `tmct.turn(line, { as: "mole-1" })` routes to that character's own window,
358
+ // which owns its private focus/last and its own fog of war. Everything else a
359
+ // window does a scripted autoplayTick, its visited rooms, whether it is
360
+ // still in play — stays on `tmct.session.windows[id]`, because those are
361
+ // per-character state rather than questions anyone could ask in words.
362
+ //
363
+ // `tmct.page` keeps the sprite resolution and the digest/affordance/knowledge
364
+ // readers the room view and chat pills render from, the roster helpers that
365
+ // decide which animals this visit is played with, the two predicate lists the
366
+ // P2P layer checks against, and the SKOS neighbourhood behind the edit mode's
367
+ // cursor pills none of which is `.toString()`-splice-safe.
368
+ publishTmctSurface({
369
+ open: createMudSession,
370
+ turn: (line, options, session) => {
371
+ const character = options?.as;
372
+ const characterWindow = character ? session.windows[character] : null;
373
+ if (!characterWindow) {
374
+ return {
375
+ answer: `say which character is speaking — tmct.turn(line, { as: "${Object.keys(session.windows)[0] || "mole-1"}" })`,
376
+ end: false, record: null, plan: null, research: null,
377
+ };
378
+ }
379
+ return characterWindow.turn(line);
380
+ },
381
+ ask: graphAsk,
382
+ plan: enginePlan,
383
+ page: {
384
+ pickMudRoster, expandMudRoster, mintedCharacterFacts, worldFactsForCast,
385
+ resolveSpriteForClass, SPRITE_REGISTRY, classAncestorChain, resolveSpriteAsset,
386
+ foldWorldState, worldActionRows, worldDigestRows, roomAffordances,
387
+ personKnowledgeLines, personKnownFoodLines,
388
+ diggableDirections, castInRoom, displayNameOf, isOutOfPlay, outOfPlayReasonOf, outOfPlayPhrase,
389
+ roomKindOf,
390
+ isMudStatePredicate, P2P_PREDICATES,
391
+ relatedForTerm,
392
+ },
393
+ });
@@ -34,7 +34,7 @@ import { planToPddl } from "../../services/plan-pddl.mjs";
34
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
- // tmctChat.registerWinkModel — see wink-model.mjs's own header. The hanoi
37
+ // tmct.page.registerWinkModel — see wink-model.mjs's own header. The hanoi
38
38
  // lesson's own "moving a disk onto a target makes the disk rest on the
39
39
  // target" sentence needs a REAL lemmatiser (verbLemma reduces "moving" to
40
40
  // "move" to match the taught "move onto" action family) — without it, that
@@ -44,10 +44,12 @@ import { createTurnSession } from "./turn-session.mjs";
44
44
  // model because their own gameplay never asks a taught rule to reduce a
45
45
  // verb; the hanoi lesson is the first live session here that does.
46
46
  import { registerWinkModel } from "../../adapters/wink-model.mjs";
47
+ import { publishTmctSurface } from "./tmct-surface.mjs";
48
+ import { graphAsk, enginePlan } from "./engine-surface.mjs";
47
49
 
48
50
  /** A live in-memory towers-of-hanoi session this page's live controls AND
49
- * chat dock can both drive. Returns `{ memoryDir, sessionId, diskCount,
50
- * maxDepth, plan, turn }`. `plan` is the puzzle's freshly solved plan (the
51
+ * chat dock can both drive. Returns `{ memoryDir, sessionId, graph,
52
+ * diskCount, maxDepth, plan, turn }`. `plan` is the puzzle's freshly solved plan (the
51
53
  * same shape chat.mjs's planLaneAnswer returns, enriched with
52
54
  * `becauseText` — see `turn()` below), or null when `maxDepth` was too low
53
55
  * to find one (an honest miss, not an error: `turn()`'s own answer text
@@ -84,13 +86,18 @@ export async function createPlanSession({ diskCount = 3, maxDepth = DEFAULT_GAME
84
86
  if (r.plan) plan = r.plan;
85
87
  }
86
88
 
87
- return { memoryDir, sessionId, diskCount, maxDepth, plan, turn: session.turn };
89
+ return { memoryDir, sessionId, graph, diskCount, maxDepth, plan, turn: session.turn };
88
90
  }
89
91
 
90
- // Re-exported so the page's own rendering script (plan-viz.mjs's inlined
91
- // script) never has to duplicate board layout or PDDL/OWL-RDF formatting
92
- // the same posture adventure-browser-entry.mjs/spider-fly-browser-entry.mjs
93
- // take re-exporting their own engines' pure helpers.
94
- globalThis.tmctPlan = {
95
- createPlanSession, computeBlocksLayout, planToPageData, renderInputsFromPlan, planToPddl, registerWinkModel,
96
- };
92
+ // `tmct.page` keeps the board layout and the PDDL/OWL-RDF formatting the
93
+ // page's own script draws with, plus the wink seam the hanoi lesson needs
94
+ // registered before its first teach. Note that `tmct.plan(...)` here is the
95
+ // CAPABILITY planner, not the puzzle solver: a typed "solve it" is a
96
+ // conversational turn like any other, so the page reaches the hanoi plan
97
+ // through `tmct.turn("solve it", { maxDepth })` and reads `.plan` off it.
98
+ publishTmctSurface({
99
+ open: createPlanSession,
100
+ ask: graphAsk,
101
+ plan: enginePlan,
102
+ page: { computeBlocksLayout, planToPageData, renderInputsFromPlan, planToPddl, registerWinkModel },
103
+ });
@@ -39,6 +39,8 @@ import { groundTextToFacts } from "./ingest-browser-entry.mjs";
39
39
  import { openPersistedStore } from "./idb-persist.mjs";
40
40
  import { digestTermFromPayloadBrowser } from "./digest-client.mjs";
41
41
  import { createTurnSession } from "./turn-session.mjs";
42
+ import { publishTmctSurface } from "./tmct-surface.mjs";
43
+ import { enginePlan } from "./engine-surface.mjs";
42
44
  import { exportFactsJsonl } from "./memory-stats.mjs";
43
45
 
44
46
  // The Fact individual's first-write-wins timestamp, read straight off the
@@ -247,6 +249,7 @@ export function createResearchSession({ seedPayload = null, vocabSeeded = false,
247
249
 
248
250
  return {
249
251
  memoryDir,
252
+ graph,
250
253
  chatSessionId,
251
254
  ingestSessionId,
252
255
  sessionIds,
@@ -327,8 +330,22 @@ export function createResearchSession({ seedPayload = null, vocabSeeded = false,
327
330
  };
328
331
  }
329
332
 
330
- globalThis.tmctResearch = {
331
- createResearchSession, researchSnapshot, exportFactsJsonl,
332
- registerWinkModel, registerReferencePackProvider, registerLiveReferenceProvider, registerResearchProvider,
333
- normFactTerm, vocabExampleHint, openPersistedStore,
334
- };
333
+ // This page's ask is already source-scoped: `tmct.ask(q, { sources })` keeps
334
+ // the checked source keys and runs the same factAnswer/factReadBack cascade
335
+ // the ledger dock runs, so a visitor can see the answer change as they tick
336
+ // sources off. `tmct.page` keeps the provider and wink seams the page
337
+ // registers, the run snapshot its timeline reads, its persisted store and its
338
+ // JSONL export.
339
+ publishTmctSurface({
340
+ open: createResearchSession,
341
+ ask: async (request, options, session) => {
342
+ const { text, miss } = await session.ask(request, options);
343
+ return { answer: text, data: null, miss };
344
+ },
345
+ plan: enginePlan,
346
+ page: {
347
+ researchSnapshot, exportFactsJsonl,
348
+ registerWinkModel, registerReferencePackProvider, registerLiveReferenceProvider, registerResearchProvider,
349
+ normFactTerm, vocabExampleHint, openPersistedStore,
350
+ },
351
+ });
@@ -44,6 +44,8 @@
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
46
  import { createTurnSession } from "./turn-session.mjs";
47
+ import { publishTmctSurface } from "./tmct-surface.mjs";
48
+ import { graphAsk, enginePlan } from "./engine-surface.mjs";
47
49
  import { registerWinkModel } from "../../adapters/wink-model.mjs";
48
50
  import {
49
51
  createInMemoryStore, normFactTerm, appendFacts, loadMemory, readFactRows,
@@ -137,6 +139,16 @@ export async function createSpiderFlySession({ flyCount = 1 } = {}) {
137
139
  initial: { turn: 0, agents: initialAgents, activeWebs: [] },
138
140
  taxonomyRows,
139
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
+
140
152
  /** Run one real engine turn directly. Returns spider-fly.mjs's own
141
153
  * { turn, agents, ecology } shape unmodified. */
142
154
  async tick() {
@@ -189,16 +201,23 @@ export async function createSpiderFlySession({ flyCount = 1 } = {}) {
189
201
  };
190
202
  }
191
203
 
192
- // cellId/parseCellId/DIRECTION_DELTA/visibleCells/DEFAULT_VISION_RADIUS are
193
- // re-exported so the page's own rendering script (spider-fly-viz.mjs) never
194
- // has to duplicate grid geometry or the vision-radius default: reconstructing
195
- // a spider's remaining silk-thread path from its returned direction list, and
196
- // computing the POV overlay's visible-cell mask, both need them.
197
- // pillsForSpiderFly/oneStepDirectionBetween are re-exported so the same page
198
- // can build its own dynamic deception-pill container without duplicating
199
- // spider-fly-turn.mjs's own pill logic.
200
- globalThis.tmctSpiderFly = {
201
- createSpiderFlySession, normFactTerm, resolveSpriteForClass, SPRITE_REGISTRY, resolveSpriteAsset,
202
- cellId, parseCellId, DIRECTION_DELTA, visibleCells, DEFAULT_VISION_RADIUS,
203
- pillsForSpiderFly, oneStepDirectionBetween, DEFAULT_GAME_CONFIG, registerWinkModel,
204
- };
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
+ });
@@ -25,9 +25,11 @@ import { loadLexicon } from "../../domain/grammar/lexicon.mjs";
25
25
  import { SPRITE_FACTS_PROVENANCE } from "../../domain/sprite-facts.mjs";
26
26
  import { registerWinkModel } from "../../adapters/wink-model.mjs";
27
27
  import { createTurnSession } from "./turn-session.mjs";
28
+ import { publishTmctSurface } from "./tmct-surface.mjs";
29
+ import { graphAsk, enginePlan } from "./engine-surface.mjs";
28
30
 
29
31
  /** A live in-memory chat session seeded with the embedded sprite-facts rows.
30
- * Returns { memoryDir, sessionId, factCount, turn }. */
32
+ * Returns { memoryDir, sessionId, graph, factCount, turn }. */
31
33
  export async function createSpriteCatalogSession({ factRows = [] } = {}) {
32
34
  const memoryDir = createInMemoryStore();
33
35
  await appendFacts(memoryDir, factRows.map((f) => ({
@@ -43,13 +45,20 @@ export async function createSpriteCatalogSession({ factRows = [] } = {}) {
43
45
  return {
44
46
  memoryDir,
45
47
  sessionId,
48
+ graph,
46
49
  factCount: factRows.length,
47
50
  turn: session.turn,
48
51
  };
49
52
  }
50
53
 
51
- // registerWinkModel is re-exported so the page's own inline script can hand in
52
- // the self-hosted wink pair (./vendor/wink.js) exactly the way chat.html/
53
- // ledger.html/plan.html register theirs — the bundle itself never imports
54
- // wink-nlp (wink-model.mjs's own header explains why).
55
- globalThis.tmctSprites = { createSpriteCatalogSession, registerWinkModel, normFactTerm, extractSceneItems };
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
+ }
@@ -55,8 +55,11 @@ function turnErrorFallback(message) {
55
55
  * grow a visited-rooms set, bump a turn tally). Never runs on a throw: none
56
56
  * of the nine originals touch their own state when `runTurn` itself failed.
57
57
  *
58
- * Returns `{ memoryDir, sessionId, turn, focus, last, planState,
59
- * setPlanState, researchState, setResearchState }`. `turn(line, callArgs)`
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)`
60
63
  * resolves to `{ answer, end, record, plan, research }` — the union of every
61
64
  * field any of the nine originals reads; a caller ignores what it doesn't
62
65
  * need. `research` is passed through undefined/null/a queue snapshot exactly
@@ -109,6 +112,7 @@ export function createTurnSession({
109
112
  return {
110
113
  memoryDir,
111
114
  sessionId,
115
+ graph,
112
116
  get focus() { return focus; },
113
117
  get last() { return last; },
114
118
  get planState() { return planState; },
@@ -50,7 +50,7 @@ export const TOOLS = HOT_TOOLS.map(({ name, agentDescription, inputSchema }) =>
50
50
  inputSchema,
51
51
  }));
52
52
 
53
- async function runHandler(name, args, { config, source = defaultSource, tel = null, ingest = null, memoryBackend = null } = {}) {
53
+ async function runHandler(name, args, { config, source = defaultSource, tel = null, ingest = null, memoryBackend = null, graph: suppliedGraph = null } = {}) {
54
54
  // Reject an unknown tool BEFORE touching the graph — an unknown name never
55
55
  // triggers a load. hasOwn, so an inherited name ("constructor", "toString")
56
56
  // is unknown rather than a callable found on the prototype chain.
@@ -64,13 +64,20 @@ async function runHandler(name, args, { config, source = defaultSource, tel = nu
64
64
  // conversational memory store (tmct_export) prefers it over re-deriving a
65
65
  // backend from config when one is supplied; every other caller leaves it null
66
66
  // and gets today's re-derive-from-config behaviour unchanged.
67
- if (handle.ownsGraphLoad) return handle(args, { config, source, tel, ingest, memoryBackend });
68
- const graph = await loadGraph(config, source);
67
+ if (handle.ownsGraphLoad) return handle(args, { config, source, tel, ingest, memoryBackend, graph: suppliedGraph });
68
+ // `graph` is the third seam of the same kind: a caller that ALREADY holds a
69
+ // parsed graph hands it over instead of making the tool layer load one. A
70
+ // browser session is the case that needs it — its graph is built in memory
71
+ // (a seed payload, or a live board projected through worldRelationGraphPayload)
72
+ // and there is no config or file behind it to load from.
73
+ const graph = suppliedGraph || await loadGraph(config, source);
69
74
  // repo root = the dir containing .tmct/ (graphFile = <repo>/.tmct/graph.json). Passed to
70
75
  // createGraphService so svc.snippet()/svc.context() are usable directly, and on to the
71
- // handlers that do their own safe source reads.
72
- const repoRoot = dirname(dirname(config.graphFile));
73
- const svc = createGraphService(graph, { sourceAccess: true, repoRoot, readFile, tel, ask });
76
+ // handlers that do their own safe source reads. A supplied graph has no repo on
77
+ // disk behind it, so those reads are off rather than pointed at a path that
78
+ // isn't there.
79
+ const repoRoot = config?.graphFile ? dirname(dirname(config.graphFile)) : null;
80
+ const svc = createGraphService(graph, { sourceAccess: Boolean(repoRoot), repoRoot, readFile, tel, ask });
74
81
  return handle(args, { graph, svc, config, repoRoot, memoryBackend });
75
82
  }
76
83