@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 +2 -1
- package/src/adapters/memory/core.mjs +1 -0
- package/src/domain/router/drive.mjs +43 -17
- package/src/domain/router/planner.mjs +54 -4
- package/src/domain/router/resolver.mjs +88 -14
- package/src/domain/sprite-map.mjs +131 -0
- package/src/services/adventure.mjs +7 -0
- package/src/services/chat.mjs +25 -0
- package/src/services/spider-fly-turn.mjs +352 -0
- package/src/services/spider-fly-viz.mjs +492 -0
- package/src/services/spider-fly.mjs +491 -0
- package/src/services/viz-ticker.mjs +119 -0
- package/src/surfaces/web/memory-ask-browser.bundle.js +123 -85
- package/src/surfaces/web/spider-fly-browser-entry.mjs +152 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@polycode-projects/the-mechanical-code-talker",
|
|
3
|
-
"version": "2.7.
|
|
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
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
// (
|
|
19
|
-
//
|
|
20
|
-
//
|
|
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 {
|
|
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
|
|
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
|
-
|
|
61
|
-
|
|
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
|
-
|
|
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? }
|
|
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 =
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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
|
-
|
|
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,
|
|
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) {
|
package/src/services/chat.mjs
CHANGED
|
@@ -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
|