@polycode-projects/the-mechanical-code-talker 2.7.3 → 2.7.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@polycode-projects/the-mechanical-code-talker",
3
- "version": "2.7.3",
3
+ "version": "2.7.5",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "The Mechanical Code Talker (tmct) — a tolerant, offline, $0 chat surface that guides you toward precision queries about a software repository. ELIZA/PARRY-style but domain-obsessed with code. No model calls; no codebase index of its own.",
@@ -135,6 +135,7 @@
135
135
  "roll": "node scripts/roll.mjs",
136
136
  "build:ask-bundle": "node scripts/build-ask-bundle.mjs",
137
137
  "build:chat-bundle": "node scripts/build-chat-bundle.mjs",
138
+ "build:spider-fly-bundle": "node scripts/build-spider-fly-bundle.mjs",
138
139
  "build:chat-seed": "node scripts/build-chat-seed.mjs",
139
140
  "build:demo-graph": "node scripts/build-demo-graph.mjs",
140
141
  "build:demo-pack": "node scripts/build-demo-pack.mjs",
@@ -712,6 +712,7 @@ function sourceIdFor(desc) {
712
712
  case "teach": return { id: desc.sessionId ? `${TEACH_SOURCE_ID}:${desc.sessionId}` : TEACH_SOURCE_ID, type: "teach" };
713
713
  case "provider": return { id: `src:provider:${desc.name}`, type: "provider" };
714
714
  case "corpus": return { id: `src:corpus:${desc.name}`, type: "corpus" };
715
+ case "corpusWeak": return { id: `src:corpus-weak:${desc.name}`, type: "corpusWeak" };
715
716
  // One Source per pack article (the @revid stays in the article segment),
716
717
  // so two facts from the same article corroborate nothing extra.
717
718
  case "reference": return { id: `src:reference:${desc.pack}:${desc.article}`, type: "reference" };
@@ -6,20 +6,29 @@
6
6
  // build a { dispatch, resolve, graph } context against the repo's actual code
7
7
  // graph, then run a request through resolver -> planner -> goal-reasoner.
8
8
  //
9
- // Single-shot -> the resolver. A compound "... then ..."/"if ..."/"of the ...,
10
- // which are ..." request -> the planner, HTN-decomposed into an ordered call
11
- // sequence with a POP causal-link proof chain, then folded into ONE composed
12
- // answer via the same set-algebra the HTN method names (relative-filter ->
13
- // intersect; conditional -> fallback/guard). A refused WORLD goal ("make every
14
- // disk rest on peg-c") is tried against the taught capability records next
15
- // (runTaughtPlan selected by backward chaining, grounded by pure simulation,
16
- // never dispatched). A request none of those ground escalates to the
17
- // closed-world goal-reasoner a maintenance-invariant deduction
18
- // (coverage-gap / cochange-risk), never a keyword guess. Anything no stage
19
- // grounds is an honest refuse, the same "grounded or an honest miss" contract
20
- // as every other tmct answer path.
9
+ // Single-shot -> the resolver. A single-shot bound argument that TIES between
10
+ // two same-tier candidates (a source module and its own test module, the
11
+ // graph's own `tests` edge never a raw score tie alone) never picks one
12
+ // arbitrarily: the resolver dispatches a read per tied candidate and refuses
13
+ // with `candidateResults` carrying both, the enumerate-or-refuse discipline
14
+ // the chat surface's own ambiguous-entity refusal already uses. A compound
15
+ // "... then ..."/"if ..."/"of the ..., which are ..." request -> the planner,
16
+ // HTN-decomposed into an ordered call sequence with a POP causal-link proof
17
+ // chain, then folded into ONE composed answer via the same set-algebra the HTN
18
+ // method names (relative-filter -> intersect; conditional -> fallback/guard).
19
+ // A "<primary>, and if <it came up empty>, <fallback> instead" request
20
+ // decomposes to the RECOVER method instead: the primary dispatches, its own
21
+ // structured result is OBSERVED, and the fallback dispatches only when that
22
+ // observation is empty (`recovered:true`) — never both, and never neither. A
23
+ // refused WORLD goal ("make every disk rest on peg-c") is tried against the
24
+ // taught capability records next (runTaughtPlan — selected by backward
25
+ // chaining, grounded by pure simulation, never dispatched). A request none of
26
+ // those ground escalates to the closed-world goal-reasoner — a maintenance-
27
+ // invariant deduction (coverage-gap / cochange-risk), never a keyword guess.
28
+ // Anything no stage grounds is an honest refuse, the same "grounded or an
29
+ // honest miss" contract as every other tmct answer path.
21
30
 
22
- import { resolveOne, backwardChainWorld } from "./resolver.mjs";
31
+ import { resolveOne, backwardChainWorld, resolveMemoryTerm } from "./resolver.mjs";
23
32
  import { plan, isMultiStep, decompose, MAX_STEPS } from "./planner.mjs";
24
33
  import { goalReason } from "./goal-reasoner.mjs";
25
34
  import { capabilities } from "./registry.mjs";
@@ -136,7 +145,12 @@ export async function runResolverPlan(request, tools, ctx) {
136
145
 
137
146
  const r = await resolveOne(request, tools, ctx, { execute: true });
138
147
  if (r.refused) {
139
- return { calls: [], refused: true, terminated: true, proof: [], driver: ROUTER_DRIVER, why: r.reason };
148
+ return {
149
+ calls: [], refused: true, terminated: true, proof: [], driver: ROUTER_DRIVER, why: r.reason,
150
+ // the tied-candidate composer's answer: one dispatched read per tied
151
+ // candidate, riding the refusal rather than an arbitrary pick.
152
+ ...(r.candidateCalls ? { candidateResults: r.candidateCalls } : {}),
153
+ };
140
154
  }
141
155
  return {
142
156
  calls: [r.selected], refused: false, terminated: true, proof: r.proof,
@@ -232,10 +246,17 @@ export async function runTaughtPlan(request, tools, ctx) {
232
246
  * agentbench's driver-resolver.mjs + driver-goal.mjs composition, with no
233
247
  * agentbench/ dependency (agentbench/ is dev-only, never shipped). Returns a
234
248
  * loopResult:
235
- * `{ calls, refused, terminated, proof, why, driver, composed?, observed? }`. */
249
+ * `{ calls, refused, terminated, proof, why, driver, composed?, observed?, candidateResults? }`.
250
+ *
251
+ * A C1 refusal that already carries `candidateResults` (the tied-candidate
252
+ * composer's enumerate-or-refuse answer) is TERMINAL — it stands as-is rather
253
+ * than escalating further, the same way a grounded C1 answer stands. Escalating
254
+ * it would silently trade a complete "both tied readings, dispatched" answer
255
+ * for whatever the taught/goal lanes make of the same refusal (typically a
256
+ * plainer refuse with no candidates at all). */
236
257
  export async function runCapabilityPlan(request, tools, ctx) {
237
258
  const c1 = await runResolverPlan(request, tools, ctx);
238
- if (!c1.refused) return c1;
259
+ if (!c1.refused || c1.candidateResults) return c1;
239
260
  const taught = await runTaughtPlan(request, tools, ctx);
240
261
  if (taught) return taught.refused ? { ...taught, c1Why: c1.why } : taught;
241
262
  const c2 = await goalReason(request, tools, ctx, { driver: GOAL_DRIVER });
@@ -266,7 +287,11 @@ export async function runCapabilityPlan(request, tools, ctx) {
266
287
  * an already-registered name is skipped) and runTaughtPlan simulates over the
267
288
  * same store, re-reading it per request via ctx.readTaughtStore. The new
268
289
  * registrations' unregister disposers ride the ctx as `ctx.disposers`; the
269
- * caller runs them when the ctx is done. */
290
+ * caller runs them when the ctx is done. The same `memoryDir` also opens
291
+ * `ctx.resolveMemoryTerm` — resolveOne's binding oracle for a memoryTerm slot
292
+ * (tmct_related's `term`), re-reading the store's fact rows per request
293
+ * through resolveMemoryTerm (resolver.mjs), the memory-graph sibling of
294
+ * `resolve` above. */
270
295
  export async function buildCapabilityPlanCtx({
271
296
  config, source, tel = null, graph = null, memoryDir = null,
272
297
  dispatchTool, isToolError = () => false, selectTool = null,
@@ -294,6 +319,7 @@ export async function buildCapabilityPlanCtx({
294
319
  const memory = await loadMemory(memoryDir);
295
320
  return { factRows: readFactRows(memory), ruleRows: readRuleRows(memory) };
296
321
  };
322
+ ctx.resolveMemoryTerm = async (term) => resolveMemoryTerm(readFactRows(await loadMemory(memoryDir)), term);
297
323
  ctx.disposers = registerTaughtActions(readRuleRows(await loadMemory(memoryDir)));
298
324
  }
299
325
  return ctx;
@@ -12,6 +12,13 @@ export const MAX_STEPS = 8;
12
12
 
13
13
  const PRONOUN_RE = /\b(?:it|its|them|those|these|that|their)\b/i;
14
14
 
15
+ // The RECOVER method's check clause must itself name an EMPTY outcome (none/
16
+ // nothing/no/not any/empty) — a closed, curated cue list, not a general "any if
17
+ // clause is a guard" reading. This is what keeps METHOD 1b narrow: "X, and if Y,
18
+ // Z instead" without an emptiness cue in Y falls through to plain sequencing
19
+ // unchanged, exactly as it did before this method existed.
20
+ const RECOVER_EMPTY_CUE_RE = /\bnone\b|\bnothing\b|\bno\b|\bnot\s+any\b|\bempty\b/i;
21
+
15
22
  /** HTN decomposition — turn a request into an ORDERED list of leaf sub-goals.
16
23
  * Returns { method, segments:[{ text, role, thread }] }:
17
24
  * - role "check" — a conditional antecedent (a test whose call still emits)
@@ -34,6 +41,25 @@ export function decompose(request) {
34
41
  };
35
42
  }
36
43
 
44
+ // METHOD 1b — the RECOVER recipe: "<primary>, and if <the primary came up
45
+ // empty>, <fallback> [instead]". Unlike METHOD 1 (which always dispatches
46
+ // both sides and folds the answer), the check clause here names no separate
47
+ // call at all — it is read as an emptiness GUARD on the primary's own
48
+ // result, observed at execution rather than dispatched. Requires an
49
+ // emptiness cue in the check clause, so an ordinary "X, and if Y, Z instead"
50
+ // whose Y doesn't name an empty outcome keeps falling through to plain
51
+ // sequencing (METHOD 4), unchanged from before this method existed.
52
+ const recover = raw.match(/^(.+?),\s*(?:and\s+)?if\s+(.+?),\s*(.+?)(?:\s+instead)?$/i);
53
+ if (recover && RECOVER_EMPTY_CUE_RE.test(recover[2])) {
54
+ return {
55
+ method: "recover",
56
+ segments: [
57
+ { text: recover[1].trim(), role: "action", thread: false },
58
+ { text: recover[3].trim(), role: "action", thread: PRONOUN_RE.test(recover[3]) },
59
+ ],
60
+ };
61
+ }
62
+
37
63
  // METHOD 2 — the RELATIVE-FILTER recipe: "of the <set> <rel> X, which are <Y>".
38
64
  // Decomposes to [produce the <set> (the <rel> over X), filter it by <Y>].
39
65
  const rel = raw.match(/^of\s+the\s+(.+?),\s*which\s+(?:are\s+)?(.+?)$/i);
@@ -101,11 +127,15 @@ function rewriteCheck(text, lastEntity) {
101
127
  const refuse = (why, driver) => ({ calls: [], refused: true, terminated: true, proof: [], driver, why });
102
128
 
103
129
  /** Plan + execute a multi-step request. Returns a loopResult
104
- * { calls, refused, terminated, proof, why, driver, observed }
130
+ * { calls, refused, terminated, proof, why, driver, observed, recovered? }
105
131
  * with a POP causal-link proof chain. Each step is monitored; a failed sub-goal stops the
106
- * plan honestly. Bounded by MAX_STEPS.
132
+ * plan honestly. Bounded by MAX_STEPS. A `recover` method OBSERVES its primary step's
133
+ * own structured result before deciding the fallback: `recovered:true` marks a plan
134
+ * whose fallback fired because the primary came up empty; a non-empty primary stops the
135
+ * plan after just that one call (the fallback is never dispatched) and carries no
136
+ * `recovered` key at all.
107
137
  *
108
- * ctx: { dispatch(name,input)->{ok,text,resolved?}, resolve(term)->resolveObject } */
138
+ * ctx: { dispatch(name,input)->{ok,text,resolved?,result?}, resolve(term)->resolveObject } */
109
139
  export async function plan(request, declaredNames, ctx, { driver = "resolver-0.8.0" } = {}) {
110
140
  const { method, segments } = decompose(request);
111
141
  if (segments.length > MAX_STEPS) {
@@ -117,6 +147,7 @@ export async function plan(request, declaredNames, ctx, { driver = "resolver-0.8
117
147
  const why = [`HTN method: ${method} — ${segments.length} sub-goal(s)`];
118
148
  let lastEntity = null; // the most-recent bound entity label (for anaphora threading)
119
149
  let steps = 0;
150
+ let recovered = false;
120
151
 
121
152
  for (let i = 0; i < segments.length; i += 1) {
122
153
  if (steps >= MAX_STEPS) return refuse("step budget exhausted mid-plan — escalate", driver);
@@ -127,7 +158,10 @@ export async function plan(request, declaredNames, ctx, { driver = "resolver-0.8
127
158
 
128
159
  const r = await resolveOne(text, declaredNames, ctx, { execute: true });
129
160
  if (r.refused) {
130
- return refuse(`sub-goal ${i + 1} ("${text}") did not resolve: ${r.reason}`, driver);
161
+ return {
162
+ ...refuse(`sub-goal ${i + 1} ("${text}") did not resolve: ${r.reason}`, driver),
163
+ ...(r.candidateCalls ? { candidateResults: r.candidateCalls } : {}),
164
+ };
131
165
  }
132
166
 
133
167
  calls.push(r.selected);
@@ -140,6 +174,21 @@ export async function plan(request, declaredNames, ctx, { driver = "resolver-0.8
140
174
 
141
175
  if (r.resolved?.label) lastEntity = r.resolved.label;
142
176
  why.push(...(r.why || []).map((w) => `[${i + 1}] ${w}`));
177
+
178
+ // RECOVER's guard: OBSERVE the primary's own structured result (a fresh,
179
+ // read-only re-dispatch — the same idiom composeResult uses to fold a
180
+ // threaded plan) before deciding the fallback. A non-empty primary already
181
+ // answered the request, so the loop stops here — the fallback is never
182
+ // dispatched, closing the bug this method exists to fix (the primary used
183
+ // to double-emit because the guard was never actually observed). An empty
184
+ // primary marks the plan `recovered` and lets the fallback segment run.
185
+ if (method === "recover" && i === 0) {
186
+ const res = await ctx.dispatch(r.selected.name, r.selected.input || {});
187
+ const primaryEmpty = res.ok && Array.isArray(res.result) && res.result.length === 0;
188
+ why.push(`[guard] observed ${r.selected.name} => ${primaryEmpty ? "empty — recovering with the fallback" : "non-empty — the primary already answers this; no fallback dispatched"}`);
189
+ if (!primaryEmpty) break;
190
+ recovered = true;
191
+ }
143
192
  }
144
193
 
145
194
  return {
@@ -149,6 +198,7 @@ export async function plan(request, declaredNames, ctx, { driver = "resolver-0.8
149
198
  proof,
150
199
  driver,
151
200
  why,
201
+ ...(recovered ? { recovered: true } : {}),
152
202
  observed: `plan(${method}): ${calls.map((c) => c.name).join(" -> ")}`,
153
203
  };
154
204
  }
@@ -16,6 +16,8 @@
16
16
 
17
17
  import { parseQuery } from "../ask.mjs";
18
18
  import { SUPERLATIVE_EXTREMES } from "../ask-vocab.mjs";
19
+ import { buildSkosConceptView } from "../skos-view.mjs";
20
+ import { edgesOfKind } from "../codegraph.mjs";
19
21
  import {
20
22
  capabilities, capabilityByName, preconditionsOf, effectsOf, PRECOND,
21
23
  } from "./registry.mjs";
@@ -56,10 +58,9 @@ export const UNMAPPED_KINDS = Object.freeze({
56
58
 
57
59
  // ---- capabilities the NL surface cannot reach today (named, not accidental) ---
58
60
  // A declared capability with no NL/command/frame path is a routing gap and must be tagged
59
- // here with the reason.
60
- export const NOT_NL_REACHABLE = Object.freeze({
61
- tmct_related: "the SKOS synonym/related surface is served by the chat lane's own recogniser over the memory graph; a router frame for it needs memory-term binding, which resolveObject (code-graph-only) does not prove yet",
62
- });
61
+ // here with the reason. Empty today: the synonym/related FRAME below reaches tmct_related
62
+ // via resolveMemoryTerm, the memory-graph sibling of resolveObject's code-graph binding.
63
+ export const NOT_NL_REACHABLE = Object.freeze({});
63
64
 
64
65
  // ---- imperative intent FRAMES (fills what the relational grammar and command register
65
66
  // both miss). regex -> { topic, arg | noArg }. Ordered: first match wins.
@@ -81,6 +82,12 @@ export const FRAMES = Object.freeze([
81
82
  { re: /\bmembers?\b|\bmethods?\s+of\b|\battributes?\s+of\b/i, topic: "members", arg: "class" },
82
83
  { re: /\bhistory\b|who\s+changed\b|commits?\s+(?:that\s+)?touch/i, topic: "history", arg: "symbol" },
83
84
  { re: /\bsignature\b/i, topic: "signature", arg: "symbol" },
85
+ // tmct_related: the SKOS synonym/related-concept surface over the memory graph
86
+ // — "another word for X" / "a synonym for X" / "synonyms of X" / "what's
87
+ // related to X". `arg: "term"` is the one memory-graph-bound slot in this
88
+ // table (see resolveMemoryTerm below); every other frame's arg binds against
89
+ // the code graph via ctx.resolve.
90
+ { re: /\bsynonyms?\b|\banother\s+word\s+for\b|\brelated\s+(?:words?|concepts?|to)\b/i, topic: "related", arg: "term" },
84
91
  { re: /\bdescribe\b|\bexplain\b|what\s+is\b|tell\s+me\s+about\b|definition\s+of\b/i, topic: "description", arg: "symbol" },
85
92
  { re: /\bsearch\b|\bfind\b|look\s+for\b/i, topic: "matches", arg: "query" },
86
93
  ]);
@@ -122,6 +129,7 @@ const STOP = new Set([
122
129
  "module", "modules", "class", "classes", "function", "functions", "symbol", "symbols",
123
130
  "untested", "blast", "radius", "change", "changes", "changing", "reach", "reaches", "affect", "affects",
124
131
  "explain", "edge", "edges", "graph", "outgoing", "site", "sites", "invoke", "invokes", "run", "runs", "execute", "executes",
132
+ "word", "words", "another", "synonym", "synonyms", "related", "relate", "relates", "like", "concept", "concepts",
125
133
  ]);
126
134
 
127
135
  /** Pull one entity token from a request (imperative-frame slot-filling). Prefer a
@@ -178,7 +186,14 @@ export function mapFrame(request) {
178
186
  if (!cap) continue;
179
187
  if (f.noArg) return { name: cap.name, noArg: true, topic: f.topic, source: "frame", why: [`imperative frame => goal (knows ${f.topic})`, `backward-chain => ${cap.name}`] };
180
188
  const term = f.arg === "query" ? searchQuery(request) : extractEntity(request);
181
- return { name: cap.name, arg: f.arg, term, topic: f.topic, source: "frame", why: [`imperative frame => goal (knows ${f.topic} ?${f.arg})`, `backward-chain => ${cap.name}`] };
189
+ // memoryTerm: this slot binds against the memory graph's SKOS concept view
190
+ // (resolveMemoryTerm below), not the code graph resolveObject resolves
191
+ // every other frame's arg against — "term" is the one param kind this is
192
+ // true for (tmct_related's own memory-facts-gated param).
193
+ return {
194
+ name: cap.name, arg: f.arg, term, topic: f.topic, source: "frame", memoryTerm: f.arg === "term",
195
+ why: [`imperative frame => goal (knows ${f.topic} ?${f.arg})`, `backward-chain => ${cap.name}`],
196
+ };
182
197
  }
183
198
  return null;
184
199
  }
@@ -198,16 +213,37 @@ export function commandCapability(request, declaredNames, selectTool) {
198
213
  return { name: sel.name, input, source: "command", why: [`command register: "${String(request).trim().split(/\s+/)[0]}" => ${sel.name}`] };
199
214
  }
200
215
 
216
+ /** Resolve a term against the memory graph's SKOS concept view
217
+ * (skos-view.mjs's buildSkosConceptView) — the memory-graph sibling of
218
+ * resolveObject, for a param whose precondition is memory-facts rather than
219
+ * a code-graph `resolves`. Same `{ match, ambiguous }` shape resolveObject
220
+ * returns, so resolveOne's generic binding step treats both the same way:
221
+ * `match.label` is the RAW queried term (not the concept's canonicalised
222
+ * prefLabel), because tmct_related's own lookup (relatedForTerm) re-derives
223
+ * the concept from whatever term it's given — the bound call should carry
224
+ * what the user actually asked about. No code-graph fallback: a term the
225
+ * store holds no synonym/related facts for is an honest miss, never a guess.
226
+ * `rows` is a loadMemory+readFactRows payload — the same trust-bearing rows
227
+ * skosRelatedAnswer (chat.mjs) and tmct_related (the tool handler) read. */
228
+ export function resolveMemoryTerm(rows, term) {
229
+ const t = String(term || "").trim();
230
+ if (!t) return { match: null };
231
+ const view = buildSkosConceptView(rows);
232
+ if (!view.conceptIdForTerm(t)) return { match: null };
233
+ return { match: { label: t, class: "skos:Concept" }, ambiguous: false, tier: "memory-concept" };
234
+ }
235
+
201
236
  // ---- the full single-call resolver (async — binds + grounds) -----------------
202
237
 
203
238
  /** Build the glass-box proof chain for a grounded single call: its preconditions then the
204
- * epistemic add-effect. Dispatch has succeeded, so `resolves` steps are ok. */
239
+ * epistemic add-effect. Dispatch has succeeded, so `resolves`/`memoryFacts` steps are ok. */
205
240
  function proofFor(name, input) {
206
241
  const steps = [];
207
242
  for (const pre of preconditionsOf(name)) {
208
243
  if (pre.pred === PRECOND.graphLoaded) steps.push({ step: "precondition", pred: pre.pred, ok: true });
209
244
  else if (pre.pred === PRECOND.resolves) steps.push({ step: "precondition", pred: pre.pred, param: pre.param, value: input[pre.param] ?? null, ok: true });
210
245
  else if (pre.pred === PRECOND.anyPresent) steps.push({ step: "precondition", pred: pre.pred, params: pre.params, ok: pre.params.some((k) => input[k]) });
246
+ else if (pre.pred === PRECOND.memoryFacts) steps.push({ step: "precondition", pred: pre.pred, ok: true });
211
247
  }
212
248
  for (const eff of effectsOf(name).add) steps.push({ step: "effect", pred: eff.pred, topic: eff.topic, of: eff.of });
213
249
  return steps;
@@ -238,11 +274,29 @@ async function dispatchEachCandidate(pool, capName, arg, ctx, execute) {
238
274
  return results;
239
275
  }
240
276
 
277
+ /** A tie the raw tier-3 SCORE never flags: a bare basename that binds to a source
278
+ * module also names its own test module, because the graph's own `tests` edge
279
+ * connects the two directly (not a filename guess — a real edge dispatchTool
280
+ * already reads). resolveObject's scoring ranks the source module far ahead on
281
+ * string similarity ("b" is an exact stem match; "b.test" is not), so this is
282
+ * never `ambiguous:true` on its own, yet "b" genuinely names either reading. Two
283
+ * individuals with no such edge are near-miss neighbours, not a genuine tie. */
284
+ function testModuleTie(graph, matchInd, candidateInd) {
285
+ if (!graph || !matchInd || !candidateInd) return false;
286
+ return edgesOfKind(graph, "tests").some((e) =>
287
+ (e.subject === matchInd.id && e.object === candidateInd.id)
288
+ || (e.subject === candidateInd.id && e.object === matchInd.id));
289
+ }
290
+
241
291
  /** Select a capability for a request and BIND its arguments — the full resolver.
242
292
  * Order: command register -> NL parse -> imperative frame. Delegates entity binding to
243
293
  * ctx.resolve and, unless `execute:false`, grounds it via ctx.dispatch. Returns
244
294
  * { selected:{name,input}, proof, why, resolved, observed? } — a grounded call
245
- * { selected:null, refused:true, reason, candidateResults? } — an honest refusal
295
+ * { selected:null, refused:true, reason, candidateResults?, candidateCalls? } — an
296
+ * honest refusal. candidateResults carries the FULL dispatched {candidate,result}
297
+ * pair per tied reading; candidateCalls is its {name,input} call-only twin, the
298
+ * shape a caller composes into a loopResult's own top-level `candidateResults`
299
+ * (the tied-candidate composer's enumerate-or-refuse answer).
246
300
  * Never emits an ungrounded / ambiguous / undeclared call. */
247
301
  export async function resolveOne(request, declaredNames, ctx, { execute = true } = {}) {
248
302
  const declared = new Set(declaredNames);
@@ -272,22 +326,42 @@ export async function resolveOne(request, declaredNames, ctx, { execute = true }
272
326
  if (!declared.has(pick.name)) return REFUSE(`selected ${pick.name} but it is not in the declared toolset`);
273
327
 
274
328
  // A command pick already carries a bound input; an NL/frame pick carries a raw term
275
- // we bind via resolveObject.
329
+ // we bind via resolveObject (code graph) or, for a memoryTerm slot, resolveMemoryTerm
330
+ // (the memory graph's SKOS concept view) — the two binding oracles never mix on one pick.
276
331
  let input = pick.input ? { ...pick.input } : {};
277
332
  let resolved = null;
278
333
  if (!pick.input && !pick.noArg) {
279
334
  const term = String(pick.term || "").trim();
280
335
  if (!term) return REFUSE(`the ${pick.topic} intent named no entity to bind`);
281
- const r = ctx.resolve ? ctx.resolve(term) : { match: { label: term }, ambiguous: false };
282
- if (!r || !r.match) return REFUSE(`"${term}" does not resolve to any graph entity (honest miss)`);
283
- if (r.ambiguous) {
284
- const pool = [r.match, ...(r.candidates || [])].slice(0, 4);
336
+ const r = pick.memoryTerm
337
+ ? (ctx.resolveMemoryTerm ? await ctx.resolveMemoryTerm(term) : { match: null })
338
+ : (ctx.resolve ? ctx.resolve(term) : { match: { label: term }, ambiguous: false });
339
+ if (!r || !r.match) {
340
+ return REFUSE(pick.memoryTerm
341
+ ? `"${term}" has no synonym/related facts in the memory graph (honest miss)`
342
+ : `"${term}" does not resolve to any graph entity (honest miss)`);
343
+ }
344
+ // A tied read: either resolveObject's own score-tie (r.ambiguous), or a same-tier
345
+ // candidate the graph's own `tests` edge ties to the match (a source module and
346
+ // its test module — a grain neither side's raw score alone reveals as tied).
347
+ // resolveMemoryTerm's SKOS concept view has no code-graph tests-edge notion, so the
348
+ // sibling tie-check only applies on the code-graph resolution path.
349
+ const sibling = (!pick.memoryTerm && !r.ambiguous)
350
+ ? (r.candidates || []).find((c) => testModuleTie(ctx.graph, r.match, c))
351
+ : null;
352
+ if (r.ambiguous || sibling) {
353
+ const pool = r.ambiguous ? [r.match, ...(r.candidates || [])].slice(0, 4) : [r.match, sibling];
285
354
  const candidateResults = await dispatchEachCandidate(pool, pick.name, pick.arg, ctx, execute);
286
- return REFUSE(`"${term}" is ambiguous (${pool.map((m) => m.label).join(", ")}) — narrow it`, candidateResults ? { candidateResults } : undefined);
355
+ const extra = candidateResults
356
+ ? { candidateResults, candidateCalls: pool.map((c) => ({ name: pick.name, input: { [pick.arg]: c.label } })) }
357
+ : undefined;
358
+ return REFUSE(`"${term}" is ambiguous (${pool.map((m) => m.label).join(", ")}) — narrow it`, extra);
287
359
  }
288
360
  resolved = r.match;
289
361
  input = { [pick.arg]: r.match.label };
290
- why = [...why, `resolveObject: "${term}" => ${r.match.label} (${r.match.class || "?"}, tier ${r.tier})`];
362
+ why = [...why, pick.memoryTerm
363
+ ? `resolveMemoryTerm: "${term}" mints a memory-graph SKOS concept (tier ${r.tier})`
364
+ : `resolveObject: "${term}" => ${r.match.label} (${r.match.class || "?"}, tier ${r.tier})`];
291
365
  }
292
366
 
293
367
  const call = { name: pick.name, input };
@@ -0,0 +1,131 @@
1
+ // sprite-map.mjs — the ontology-to-sprite mapping (PLAN_SPIDER_FLY.md §7): a
2
+ // flat registry of small, original inline-SVG icons keyed on class name, and
3
+ // the ancestor-walk resolver that maps ANY class down to whichever registered
4
+ // ancestor is nearest — so a taxonomy can teach `poodle IsA dog` and
5
+ // `sheepdog IsA dog` once, register a sprite only for `poodle` and `dog`, and
6
+ // have `sheepdog` fall back to the generic dog sprite automatically.
7
+ //
8
+ // resolveSpriteForClass is styled after src/domain/ask.mjs's private
9
+ // ancestorsOf (BFS, nearest-ancestor-first, a flat queue with `shift()`) but
10
+ // walks generic rdfs:subClassOf fact-store edges (subject IsA object) rather
11
+ // than ask.mjs's code-graph `inherits` edges — the two data shapes are
12
+ // different graphs, so the walk is re-derived here rather than imported.
13
+ //
14
+ // SPRITE_REGISTRY follows the flat closed-vocabulary registry idiom this
15
+ // project already uses elsewhere (SOURCE_PRIOR in domain/memory/trust.mjs,
16
+ // EDGE_KIND_TO_TMCT in adapters/repository-interface.mjs): an
17
+ // Object.freeze({...}) string-to-value map with a doc comment, keyed on the
18
+ // same normFactTerm-normalized spelling the fact store already uses, so a
19
+ // lookup never has to re-normalise its own keys before comparing.
20
+ //
21
+ // normFactTerm is imported from domain/hash.mjs directly, not
22
+ // adapters/memory/core.mjs's re-export of it — this module lives in
23
+ // src/domain/, and domain stays pure of src/adapters/ (test/estate/
24
+ // import-layers.test.mjs enforces the direction).
25
+
26
+ import { normFactTerm } from "./hash.mjs";
27
+
28
+ const SUBJECT_TO_SUPERCLASS_PREDICATE = "rdfs:subClassOf";
29
+
30
+ // Every value is a complete, self-contained <svg> string using
31
+ // `fill="currentColor"`/`stroke="currentColor"` so the renderer colors an
32
+ // icon with plain CSS (the spider's green accent, the fly's amber one — §9)
33
+ // rather than baking a color into the markup. Small, geometric, hand-drawn
34
+ // shapes only — no external asset files, no licence to track (§8's whole
35
+ // point of dropping RPG-JS in favor of this).
36
+ const SPIDER_SVG =
37
+ '<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="10" r="3.4" fill="currentColor"/>'
38
+ + '<circle cx="12" cy="15.6" r="4.6" fill="currentColor"/>'
39
+ + '<g stroke="currentColor" stroke-width="1.1" fill="none" stroke-linecap="round">'
40
+ + '<path d="M9 8 L2 4"/><path d="M9 10 L1.5 9"/><path d="M9 12.5 L2 14"/><path d="M9 15.5 L3 19"/>'
41
+ + '<path d="M15 8 L22 4"/><path d="M15 10 L22.5 9"/><path d="M15 12.5 L22 14"/><path d="M15 15.5 L21 19"/>'
42
+ + "</g></svg>";
43
+
44
+ const FLY_SVG =
45
+ '<svg viewBox="0 0 24 24" aria-hidden="true"><ellipse cx="12" cy="13.2" rx="3.2" ry="5" fill="currentColor"/>'
46
+ + '<circle cx="12" cy="7" r="2.6" fill="currentColor"/>'
47
+ + '<g fill="currentColor" opacity="0.55">'
48
+ + '<path d="M9 10 C3 6 2 4 3 2 C6 2 9 6 10.5 10 Z"/>'
49
+ + '<path d="M15 10 C21 6 22 4 21 2 C18 2 15 6 13.5 10 Z"/>'
50
+ + "</g></svg>";
51
+
52
+ const EGG_SVG =
53
+ '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 2 C7 2 4 10 4 15.5 C4 20 7.6 22 12 22 '
54
+ + 'C16.4 22 20 20 20 15.5 C20 10 17 2 12 2 Z" fill="currentColor"/></svg>';
55
+
56
+ const POODLE_SVG =
57
+ '<svg viewBox="0 0 24 24" aria-hidden="true"><ellipse cx="13" cy="15" rx="6.4" ry="4.6" fill="currentColor"/>'
58
+ + '<circle cx="7" cy="8.5" r="4" fill="currentColor"/>'
59
+ + '<g fill="currentColor"><circle cx="4" cy="6" r="1.6"/><circle cx="7" cy="4.2" r="1.7"/><circle cx="10" cy="6" r="1.6"/></g>'
60
+ + '<rect x="18.5" y="14" width="2.2" height="6" rx="1.1" fill="currentColor"/>'
61
+ + '<circle cx="19.6" cy="13" r="1.5" fill="currentColor"/>'
62
+ + '<g stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><line x1="9" y1="19.5" x2="9" y2="22"/><line x1="17" y1="19.5" x2="17" y2="22"/></g>'
63
+ + "</svg>";
64
+
65
+ const DOG_SVG =
66
+ '<svg viewBox="0 0 24 24" aria-hidden="true"><ellipse cx="13" cy="15" rx="6.4" ry="4.2" fill="currentColor"/>'
67
+ + '<circle cx="7" cy="9" r="3.6" fill="currentColor"/>'
68
+ + '<path d="M4.5 6.5 L2 3 L6 5 Z" fill="currentColor"/>'
69
+ + '<path d="M19 13 C22 12 22.5 15 20 16 Z" fill="currentColor"/>'
70
+ + '<g stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><line x1="9" y1="19" x2="9" y2="22"/><line x1="17" y1="19" x2="17" y2="22"/></g>'
71
+ + "</svg>";
72
+
73
+ // The generic fallback — deliberately the plainest shape in the set (an
74
+ // unadorned four-legged blob) so it reads as "some animal" rather than
75
+ // suggesting a species the taxonomy never named.
76
+ const ANIMAL_SVG =
77
+ '<svg viewBox="0 0 24 24" aria-hidden="true"><ellipse cx="12" cy="13" rx="7" ry="4.4" fill="currentColor"/>'
78
+ + '<circle cx="12" cy="7" r="3.4" fill="currentColor"/>'
79
+ + '<g stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><line x1="7" y1="17" x2="7" y2="21"/><line x1="17" y1="17" x2="17" y2="21"/></g>'
80
+ + "</svg>";
81
+
82
+ /** The sprite registry: class name (normFactTerm-normalized) -> inline SVG
83
+ * markup. `animal` is this world's declared root fallback (PLAN_SPIDER_FLY.md
84
+ * §7 names "animal"/"object"/"plant" as the family of possible roots — a
85
+ * spider-and-fly board only ever needs "animal") — resolveSpriteForClass
86
+ * falls back to it once the ancestor walk exhausts with no closer hit. */
87
+ export const SPRITE_REGISTRY = Object.freeze({
88
+ spider: SPIDER_SVG,
89
+ fly: FLY_SVG,
90
+ egg: EGG_SVG,
91
+ poodle: POODLE_SVG,
92
+ dog: DOG_SVG,
93
+ animal: ANIMAL_SVG,
94
+ });
95
+
96
+ /** Every direct rdfs:subClassOf superclass of `term`, from a flat fact-row
97
+ * array (`{ subject, predicate, object }`, generic — the same shape both
98
+ * the fact store and a plain taxonomy fixture already use). */
99
+ function directSuperclassesOf(term, factRows) {
100
+ const out = [];
101
+ for (const row of factRows || []) {
102
+ if (row?.predicate !== SUBJECT_TO_SUPERCLASS_PREDICATE) continue;
103
+ if (normFactTerm(row.subject) !== term) continue;
104
+ out.push(normFactTerm(row.object));
105
+ }
106
+ return out;
107
+ }
108
+
109
+ /**
110
+ * Resolve a class name to a sprite: `className` itself if the registry
111
+ * carries it directly, otherwise the nearest rdfs:subClassOf ancestor
112
+ * (breadth-first, nearest first — styled after ask.mjs's own ancestorsOf)
113
+ * that the registry carries, otherwise `rootFallback` ("animal" by default —
114
+ * override it for a registry whose own declared root is "object" or "plant").
115
+ * Pure; `factRows` is read only, never mutated.
116
+ */
117
+ export function resolveSpriteForClass(className, factRows, spriteRegistry, { rootFallback = "animal" } = {}) {
118
+ const start = normFactTerm(className);
119
+ const seen = new Set();
120
+ const queue = [start];
121
+ while (queue.length) {
122
+ const term = queue.shift();
123
+ if (seen.has(term)) continue;
124
+ seen.add(term);
125
+ if (Object.prototype.hasOwnProperty.call(spriteRegistry, term)) return spriteRegistry[term];
126
+ for (const parent of directSuperclassesOf(term, factRows)) {
127
+ if (!seen.has(parent)) queue.push(parent);
128
+ }
129
+ }
130
+ return spriteRegistry[rootFallback];
131
+ }
@@ -799,6 +799,13 @@ export async function adventureTurn(line, { planHolder, memoryDir, sessionId = "
799
799
  note: "ADVENTURE — an opening arrived mid-number-game; the slot holds one thing at a time",
800
800
  };
801
801
  }
802
+ if (slot?.spiderFly) {
803
+ return {
804
+ text: 'the spider-and-fly game is running — say "stop watching" to end it, then start the adventure.',
805
+ lane: "game-inform",
806
+ note: "ADVENTURE — an opening arrived mid-spider-fly-game; the slot holds one thing at a time",
807
+ };
808
+ }
802
809
  const planActive = slot && !slot.done
803
810
  && ((Array.isArray(slot.goals) && slot.goals.length) || (Array.isArray(slot.actions) && slot.actions.length));
804
811
  if (planActive) {
@@ -53,6 +53,7 @@ import { getChildPackProvider } from "../adapters/corpus/child-pack.mjs";
53
53
  import { dialogueActForLane } from "../domain/dialogue-acts.mjs";
54
54
  import { relatedForTerm } from "../domain/skos-view.mjs";
55
55
  import { adventureTurn } from "./adventure.mjs";
56
+ import { spiderFlyTurn } from "./spider-fly-turn.mjs";
56
57
 
57
58
  // Composition: the chat surface supplies the domain parser's default lemma/POS
58
59
  // adapter (the browser bundle's ask-nlp stub carries no factory, so this is a
@@ -12246,6 +12247,9 @@ function guessNumberTurn(line, { planHolder, env }) {
12246
12247
  if (state?.adventure) {
12247
12248
  return { text: 'we\'re mid-adventure — say "stop playing" to end it before a number game.', lane: "game-inform", note: "GAME — an opening arrived mid-adventure; the slot holds one thing at a time" };
12248
12249
  }
12250
+ if (state?.spiderFly) {
12251
+ return { text: 'the spider-and-fly game is running — say "stop watching" to end it before a number game.', lane: "game-inform", note: "GAME — an opening arrived mid-spider-fly-game; the slot holds one thing at a time" };
12252
+ }
12249
12253
  const planActive = state && !state.done
12250
12254
  && ((Array.isArray(state.goals) && state.goals.length) || (Array.isArray(state.actions) && state.actions.length));
12251
12255
  if (planActive) {
@@ -12621,6 +12625,27 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
12621
12625
  }
12622
12626
  }
12623
12627
 
12628
+ // SPIDER-AND-FLY — the opener ("watch the spider and the fly"), the stop
12629
+ // command, the addressed spatial teach-frame, the bare tick command, and
12630
+ // (once a game is live) the game's own turns. Ordered AFTER the adventure
12631
+ // lane for the same reason the adventure lane follows guess-the-number: an
12632
+ // opening line would otherwise read as a declarative or an orientation ask.
12633
+ {
12634
+ const sfTurn = await spiderFlyTurn(workingLine, {
12635
+ planHolder, memoryDir, env, cache: factRowsCache, isPlanFrameLine,
12636
+ });
12637
+ if (sfTurn) {
12638
+ note(trace, `lane: ${sfTurn.note}`);
12639
+ if (sfTurn.goal) note(trace, `goal: ${sfTurn.goal}`);
12640
+ const result = plainTurn(workingLine, sfTurn.text, { via: "game", miss: !!sfTurn.miss, focus });
12641
+ if (sfTurn.goal) result.goal = sfTurn.goal;
12642
+ result.lane = sfTurn.lane;
12643
+ const rec = withLast(result, sfTurn.goal ?? "watch the spider-and-fly game");
12644
+ rec.planState = planHolder.state;
12645
+ return rec;
12646
+ }
12647
+ }
12648
+
12624
12649
  // Conversational layer next (greetings, thanks, help, bye, why/say-more) — these
12625
12650
  // resolve no entity and carry their own preserved `last`. Bypasses withLast (a
12626
12651
  // conversational turn is never finish()'d / never becomes a new `last`), so the