@polycode-projects/the-mechanical-code-talker 2.7.4 → 2.7.7

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.4",
3
+ "version": "2.7.7",
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.",
@@ -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 };
@@ -474,7 +474,12 @@ async function runWorldCommand(cmd, { world, memoryDir, env, graph, cache }) {
474
474
 
475
475
  if (cmd.verb === "examine" || cmd.verb === "talk") {
476
476
  const object = cmd.object;
477
- if (visibleRoomOf(object, { rows, state }) !== here) {
477
+ // A carried object has no room to be "visible in" (visibleRoomOf returns
478
+ // null for anything held by the player) — examine still applies to it,
479
+ // the same way "what am I carrying" already reads inventory contents.
480
+ // talk has no carried exception: NPCs are never portable.
481
+ const carried = cmd.verb === "examine" && carriedByPlayer(state, object);
482
+ if (!carried && visibleRoomOf(object, { rows, state }) !== here) {
478
483
  return answer(
479
484
  `I don't see a ${object} here.`,
480
485
  noteFor(`${cmd.verb} — ${object} isn't visible in the ${here}; declined, hidden things stay hidden`),
@@ -9675,6 +9675,8 @@ ${bodyText}` : graphText, tier });
9675
9675
  return { id: `src:provider:${desc.name}`, type: "provider" };
9676
9676
  case "corpus":
9677
9677
  return { id: `src:corpus:${desc.name}`, type: "corpus" };
9678
+ case "corpusWeak":
9679
+ return { id: `src:corpus-weak:${desc.name}`, type: "corpusWeak" };
9678
9680
  // One Source per pack article (the @revid stays in the article segment),
9679
9681
  // so two facts from the same article corroborate nothing extra.
9680
9682
  case "reference":
@@ -10580,6 +10582,94 @@ CREATE INDEX IF NOT EXISTS edges_by_prop ON edges(prop);
10580
10582
  }
10581
10583
  });
10582
10584
 
10585
+ // src/domain/skos-view.mjs
10586
+ function buildSkosConceptView(rows, { conceptBase = "concept:", relationMap = DEFAULT_RELATION_MAP } = {}) {
10587
+ const synonymPreds = new Set(relationMap.synonym || []);
10588
+ const relatedPreds = new Set(relationMap.related || []);
10589
+ const parent = /* @__PURE__ */ new Map();
10590
+ const ensure = (t) => {
10591
+ if (!parent.has(t)) parent.set(t, t);
10592
+ };
10593
+ const find = (x) => {
10594
+ let r = x;
10595
+ while (parent.get(r) !== r) r = parent.get(r);
10596
+ while (parent.get(x) !== r) {
10597
+ const next = parent.get(x);
10598
+ parent.set(x, r);
10599
+ x = next;
10600
+ }
10601
+ return r;
10602
+ };
10603
+ const union = (a, b) => {
10604
+ const ra = find(a), rb = find(b);
10605
+ if (ra === rb) return;
10606
+ if (ra < rb) parent.set(rb, ra);
10607
+ else parent.set(ra, rb);
10608
+ };
10609
+ const relatedRaw = [];
10610
+ for (const r of rows) {
10611
+ const p = r.predicate;
10612
+ if (!synonymPreds.has(p) && !relatedPreds.has(p)) continue;
10613
+ const s = normFactTerm(r.subject), o = normFactTerm(r.object);
10614
+ if (!s || !o) continue;
10615
+ ensure(s);
10616
+ ensure(o);
10617
+ if (synonymPreds.has(p)) union(s, o);
10618
+ else relatedRaw.push({ s, o });
10619
+ }
10620
+ const iriFor = (rep) => conceptBase + rep.replace(/ /g, "_");
10621
+ const componentTerms = /* @__PURE__ */ new Map();
10622
+ for (const t of parent.keys()) {
10623
+ const rep = find(t);
10624
+ if (!componentTerms.has(rep)) componentTerms.set(rep, /* @__PURE__ */ new Set());
10625
+ componentTerms.get(rep).add(t);
10626
+ }
10627
+ const concepts = [];
10628
+ for (const [rep, terms] of componentTerms) {
10629
+ const sorted = [...terms].sort();
10630
+ concepts.push({ id: iriFor(rep), prefLabel: rep, altLabels: sorted.filter((t) => t !== rep) });
10631
+ }
10632
+ concepts.sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
10633
+ const seen = /* @__PURE__ */ new Set();
10634
+ const related = [];
10635
+ for (const { s, o } of relatedRaw) {
10636
+ const cs = iriFor(find(s)), co = iriFor(find(o));
10637
+ if (cs === co) continue;
10638
+ const key = cs < co ? `${cs}\0${co}` : `${co}\0${cs}`;
10639
+ if (seen.has(key)) continue;
10640
+ seen.add(key);
10641
+ related.push({ subject: cs, object: co });
10642
+ }
10643
+ related.sort((a, b) => `${a.subject}${a.object}` < `${b.subject}${b.object}` ? -1 : 1);
10644
+ const conceptIdForTerm = (term) => {
10645
+ const t = normFactTerm(term);
10646
+ return parent.has(t) ? iriFor(find(t)) : null;
10647
+ };
10648
+ return { concepts, related, conceptIdForTerm, namespace: SKOS_NS };
10649
+ }
10650
+ function relatedForTerm(rows, term, options = {}) {
10651
+ const view = buildSkosConceptView(rows, options);
10652
+ const conceptId = view.conceptIdForTerm(term);
10653
+ if (!conceptId) return null;
10654
+ const byId = new Map(view.concepts.map((c) => [c.id, c]));
10655
+ const concept = byId.get(conceptId);
10656
+ const queried = normFactTerm(term);
10657
+ const synonyms = [concept.prefLabel, ...concept.altLabels].filter((label) => label !== queried);
10658
+ const related = view.related.filter((r) => r.subject === conceptId || r.object === conceptId).map((r) => byId.get(r.subject === conceptId ? r.object : r.subject)).filter(Boolean);
10659
+ return { conceptId, prefLabel: concept.prefLabel, altLabels: concept.altLabels, synonyms, related };
10660
+ }
10661
+ var SKOS_NS, DEFAULT_RELATION_MAP;
10662
+ var init_skos_view = __esm({
10663
+ "src/domain/skos-view.mjs"() {
10664
+ init_hash();
10665
+ SKOS_NS = "http://www.w3.org/2004/02/skos/core#";
10666
+ DEFAULT_RELATION_MAP = {
10667
+ synonym: ["mgx:synonym"],
10668
+ related: ["mgx:relatedTo", "mgx:similarTo"]
10669
+ };
10670
+ }
10671
+ });
10672
+
10583
10673
  // adapter-stub-ask-nlp.mjs:../adapters/ask-nlp.mjs
10584
10674
  var nlpAdapter;
10585
10675
  var init_ask_nlp = __esm({
@@ -22968,91 +23058,7 @@ ${hint}` : ""}${cand}`;
22968
23058
 
22969
23059
  // src/tools/handlers/tmct-related.mjs
22970
23060
  init_config();
22971
-
22972
- // src/domain/skos-view.mjs
22973
- init_hash();
22974
- var SKOS_NS = "http://www.w3.org/2004/02/skos/core#";
22975
- var DEFAULT_RELATION_MAP = {
22976
- synonym: ["mgx:synonym"],
22977
- related: ["mgx:relatedTo", "mgx:similarTo"]
22978
- };
22979
- function buildSkosConceptView(rows, { conceptBase = "concept:", relationMap = DEFAULT_RELATION_MAP } = {}) {
22980
- const synonymPreds = new Set(relationMap.synonym || []);
22981
- const relatedPreds = new Set(relationMap.related || []);
22982
- const parent = /* @__PURE__ */ new Map();
22983
- const ensure = (t) => {
22984
- if (!parent.has(t)) parent.set(t, t);
22985
- };
22986
- const find = (x) => {
22987
- let r = x;
22988
- while (parent.get(r) !== r) r = parent.get(r);
22989
- while (parent.get(x) !== r) {
22990
- const next = parent.get(x);
22991
- parent.set(x, r);
22992
- x = next;
22993
- }
22994
- return r;
22995
- };
22996
- const union = (a, b) => {
22997
- const ra = find(a), rb = find(b);
22998
- if (ra === rb) return;
22999
- if (ra < rb) parent.set(rb, ra);
23000
- else parent.set(ra, rb);
23001
- };
23002
- const relatedRaw = [];
23003
- for (const r of rows) {
23004
- const p = r.predicate;
23005
- if (!synonymPreds.has(p) && !relatedPreds.has(p)) continue;
23006
- const s = normFactTerm(r.subject), o = normFactTerm(r.object);
23007
- if (!s || !o) continue;
23008
- ensure(s);
23009
- ensure(o);
23010
- if (synonymPreds.has(p)) union(s, o);
23011
- else relatedRaw.push({ s, o });
23012
- }
23013
- const iriFor = (rep) => conceptBase + rep.replace(/ /g, "_");
23014
- const componentTerms = /* @__PURE__ */ new Map();
23015
- for (const t of parent.keys()) {
23016
- const rep = find(t);
23017
- if (!componentTerms.has(rep)) componentTerms.set(rep, /* @__PURE__ */ new Set());
23018
- componentTerms.get(rep).add(t);
23019
- }
23020
- const concepts = [];
23021
- for (const [rep, terms] of componentTerms) {
23022
- const sorted = [...terms].sort();
23023
- concepts.push({ id: iriFor(rep), prefLabel: rep, altLabels: sorted.filter((t) => t !== rep) });
23024
- }
23025
- concepts.sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
23026
- const seen = /* @__PURE__ */ new Set();
23027
- const related = [];
23028
- for (const { s, o } of relatedRaw) {
23029
- const cs = iriFor(find(s)), co = iriFor(find(o));
23030
- if (cs === co) continue;
23031
- const key = cs < co ? `${cs}\0${co}` : `${co}\0${cs}`;
23032
- if (seen.has(key)) continue;
23033
- seen.add(key);
23034
- related.push({ subject: cs, object: co });
23035
- }
23036
- related.sort((a, b) => `${a.subject}${a.object}` < `${b.subject}${b.object}` ? -1 : 1);
23037
- const conceptIdForTerm = (term) => {
23038
- const t = normFactTerm(term);
23039
- return parent.has(t) ? iriFor(find(t)) : null;
23040
- };
23041
- return { concepts, related, conceptIdForTerm, namespace: SKOS_NS };
23042
- }
23043
- function relatedForTerm(rows, term, options = {}) {
23044
- const view = buildSkosConceptView(rows, options);
23045
- const conceptId = view.conceptIdForTerm(term);
23046
- if (!conceptId) return null;
23047
- const byId = new Map(view.concepts.map((c) => [c.id, c]));
23048
- const concept = byId.get(conceptId);
23049
- const queried = normFactTerm(term);
23050
- const synonyms = [concept.prefLabel, ...concept.altLabels].filter((label) => label !== queried);
23051
- const related = view.related.filter((r) => r.subject === conceptId || r.object === conceptId).map((r) => byId.get(r.subject === conceptId ? r.object : r.subject)).filter(Boolean);
23052
- return { conceptId, prefLabel: concept.prefLabel, altLabels: concept.altLabels, synonyms, related };
23053
- }
23054
-
23055
- // src/tools/handlers/tmct-related.mjs
23061
+ init_skos_view();
23056
23062
  init_memory_fallthrough();
23057
23063
  async function tmct_related(args, { config }) {
23058
23064
  const term = requiredArg(args, "term");
@@ -23396,6 +23402,9 @@ ${JSON.stringify(envelope, null, 2)}`;
23396
23402
  "game-inform": "inform"
23397
23403
  });
23398
23404
 
23405
+ // src/services/chat.mjs
23406
+ init_skos_view();
23407
+
23399
23408
  // src/domain/worlds-pack.mjs
23400
23409
  var WORLD_NAME_RE = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
23401
23410
  var WORLD_RULE_KINDS = Object.freeze([