@polycode-projects/the-mechanical-code-talker 0.7.0 → 0.8.0
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/ROADMAP.md +113 -11
- package/bin/tmct.mjs +45 -0
- package/package.json +3 -1
- package/src/ask-vocab.mjs +1 -1
- package/src/ask.mjs +94 -2
- package/src/chat.mjs +86 -19
- package/src/concept.mjs +5 -0
- package/src/conformance.mjs +1 -1
- package/src/corpus/templates.mjs +1 -1
- package/src/finish.mjs +1 -1
- package/src/hash.mjs +1 -1
- package/src/interpret/normalize.mjs +28 -1
- package/src/interpret/strategies/keywords.mjs +2 -2
- package/src/providers/bootstrap.mjs +1 -1
- package/src/providers/fixture.mjs +1 -1
- package/src/providers/graph-service.mjs +1 -1
- package/src/repository-interface.mjs +1 -1
- package/src/router/guardrail.mjs +120 -0
- package/src/router/planner.mjs +168 -0
- package/src/router/registry.mjs +271 -0
- package/src/router/resolver.mjs +293 -0
- package/src/server-http.mjs +296 -0
- package/src/syllogise.mjs +0 -0
- package/src/tui/app.mjs +63 -14
package/src/corpus/templates.mjs
CHANGED
|
@@ -19,7 +19,7 @@ const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
|
19
19
|
export const TEMPLATES_FILE = join(PKG_ROOT, "data", "templates", "responses.jsonl");
|
|
20
20
|
export const PHRASEBOOK_FILE = join(PKG_ROOT, "data", "phrasebook", "software-phrases.txt");
|
|
21
21
|
|
|
22
|
-
// Registers (Phase 6, PLAN_FORMULAIC_COMPETENCE.md): `terse|friendly` are the
|
|
22
|
+
// Registers (Phase 6, archive/PLAN_FORMULAIC_COMPETENCE.md): `terse|friendly` are the
|
|
23
23
|
// conversational bands; `technical` is the C1 / technical-paper band whose
|
|
24
24
|
// templates render item-5 mechanical conclusions (count / comparison /
|
|
25
25
|
// superlative + the provenance we already compute) as advanced prose. A
|
package/src/finish.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// finish.mjs — Phase 7 response finishing: the segmentation IR seam.
|
|
2
|
-
// (PLAN_RESPONSE_FINISHING.md, "The segmentation IR (lever 1)".)
|
|
2
|
+
// (archive/PLAN_RESPONSE_FINISHING.md, "The segmentation IR (lever 1)".)
|
|
3
3
|
//
|
|
4
4
|
// The governing principle is fact-invariance BY CONSTRUCTION. An answer is a
|
|
5
5
|
// list of typed spans, [{ type, text }, …], carried alongside the flat string
|
package/src/hash.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// hash.mjs — the single home for tmct's content-address hash.
|
|
2
2
|
//
|
|
3
|
-
// FNV-1a 32-bit is deliberately home-grown (see PLAN_DEPENDENCY_STRATEGY.md): it
|
|
3
|
+
// FNV-1a 32-bit is deliberately home-grown (see archive/PLAN_DEPENDENCY_STRATEGY.md): it
|
|
4
4
|
// must be synchronous, browser-safe, dependency-free, and — critically —
|
|
5
5
|
// CROSS-VERSION STABLE, because fact ids are content-addressed by it and a fact's
|
|
6
6
|
// id is its identity across the whole memory graph. Every library candidate fails
|
|
@@ -118,6 +118,33 @@ export const PHRASING_FRAMES = Object.freeze([
|
|
|
118
118
|
{ re: /^what\s+(?:defined|declared)\s+(?:the\s+)?(?:function\s+|method\s+|class\s+|module\s+|variable\s+|constant\s+)?(.+?)\??$/i, to: (m) => `where is ${m[1]} defined` },
|
|
119
119
|
// "where's X defined" (the "where's" contraction is not in the contraction table)
|
|
120
120
|
{ re: /^where'?s\s+(?:the\s+)?(.+?)\s+(defined|declared|located|implemented)\??$/i, to: (m) => `where is ${m[1]} ${m[2]}` },
|
|
121
|
+
|
|
122
|
+
// PREDICATIVE QUALIFIER → the ATTRIBUTIVE form the grammar already answers. The
|
|
123
|
+
// adjective-qualifier post-filters (ask-vocab.mjs QUALIFIERS: tested/untested,
|
|
124
|
+
// public/private, exported, static/abstract/constant, …) parse in the ATTRIBUTIVE
|
|
125
|
+
// slot — "untested modules", "public methods" — but a developer just as naturally
|
|
126
|
+
// asks the PREDICATIVE "which modules are untested" / "what functions are tested",
|
|
127
|
+
// which hit the grammar wall (and, worse, the wall's own hint SUGGESTED "which
|
|
128
|
+
// functions are tested" — a shape it could not then answer). Rewriting the
|
|
129
|
+
// predicative "<which|what> <kind> are <QUALIFIER>" to "<QUALIFIER> <kind>" routes
|
|
130
|
+
// it onto the working attributive filter. Closed to the known qualifier adjectives
|
|
131
|
+
// (not a general "… are X" catch), and the QUALIFIER must sit immediately after
|
|
132
|
+
// are/is, so "which modules are NOT tested" never matches here — that keeps its own
|
|
133
|
+
// set-complement handler (matchNegationSet, downstream in ask.mjs's parseNegation).
|
|
134
|
+
{
|
|
135
|
+
re: /^(?:which|what)\s+(?:the\s+|all\s+)?([a-z][a-z-]*?)\s+(?:are|is)\s+(public|private|protected|static|abstract|constant|exported|re-?exported|tested|covered|untested|uncovered)\??$/i,
|
|
136
|
+
to: (m) => `${m[2].toLowerCase()} ${m[1].toLowerCase()}`,
|
|
137
|
+
},
|
|
138
|
+
|
|
139
|
+
// CO-CHANGE → the "co-changes with" canonical the RELATIONS table answers. The
|
|
140
|
+
// cochange verb synonyms (ask-vocab.mjs) include "co-changes with" / "moves
|
|
141
|
+
// together with" / "tends to change together with", but NOT the plainest form a
|
|
142
|
+
// developer types — the one the README itself prints and the relation renders as:
|
|
143
|
+
// "what does X change together with" / "what changes together with X". Both hit a
|
|
144
|
+
// dead-end ("couldn't resolve one of the terms" / the grammar wall); rewriting them
|
|
145
|
+
// onto "what co-changes with X" routes them to the working change-coupling query.
|
|
146
|
+
{ re: /^what\s+does\s+(.+?)\s+changes?\s+together\s+with\??$/i, to: (m) => `what co-changes with ${m[1]}` },
|
|
147
|
+
{ re: /^what\s+changes?\s+together\s+with\s+(.+?)\??$/i, to: (m) => `what co-changes with ${m[1]}` },
|
|
121
148
|
]);
|
|
122
149
|
|
|
123
150
|
/** Apply the phrasing frames (members-of-class + where-defined) — first match wins
|
|
@@ -132,7 +159,7 @@ export function applyPhrasingFrames(text) {
|
|
|
132
159
|
return text;
|
|
133
160
|
}
|
|
134
161
|
|
|
135
|
-
// ---- §B1 negation — the SET-COMPLEMENT frame (Cycle 5, PLAN_CYCLE_4.md). Recognizes
|
|
162
|
+
// ---- §B1 negation — the SET-COMPLEMENT frame (Cycle 5, archive/PLAN_CYCLE_4.md). Recognizes
|
|
136
163
|
// a BARE set-negation query — "which X do not <verb> Y", "X that don't <verb> Y",
|
|
137
164
|
// "modules not importing Y", "which X are not <qualifier>" — and returns a descriptor
|
|
138
165
|
// {entWord, predicate} that ask.mjs's compositional grammar turns into a bounded
|
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
import { STOPWORDS } from "../normalize.mjs";
|
|
13
13
|
import { VOCAB_WORDS, eligibleForCanon, fuzzyVocabWord } from "../fuzzy.mjs";
|
|
14
14
|
|
|
15
|
-
// Reversible-passive detection (Cycle 6, PLAN_CYCLE_4.md): the passive auxiliaries that,
|
|
15
|
+
// Reversible-passive detection (Cycle 6, archive/PLAN_CYCLE_4.md): the passive auxiliaries that,
|
|
16
16
|
// together with an agent-marking "by", flip the active reading, and the wh-words that
|
|
17
17
|
// mark a QUESTIONED agent ("by which classes" / stranded "who is X tested by"). Bare
|
|
18
18
|
// "do/does/did" are deliberately EXCLUDED — "which X do not <verb> Y" is a NEGATION, not
|
|
@@ -176,7 +176,7 @@ export function parseKeywordSpot(text, nlp = null) {
|
|
|
176
176
|
if (objText) return { shape: "when", entityType: null, modifier: "direct", kind: "touches", object: objText };
|
|
177
177
|
}
|
|
178
178
|
|
|
179
|
-
// reversible passive (Cycle 6, PLAN_CYCLE_4.md): "PATIENT is VERBed BY AGENT" — an
|
|
179
|
+
// reversible passive (Cycle 6, archive/PLAN_CYCLE_4.md): "PATIENT is VERBed BY AGENT" — an
|
|
180
180
|
// agent-marking "by" plus a passive auxiliary flips the active reading, so the AGENT
|
|
181
181
|
// (after "by") is the edge SUBJECT and the PATIENT the edge OBJECT. Object-first
|
|
182
182
|
// phrasing is otherwise read subject-first and the edge traversed backwards. Fires
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// The BOOTSTRAP reference provider — the empty/degenerate graph a fresh repo
|
|
2
|
-
// "contains" before anything is indexed. PLAN_REPOSITORY_INTERFACE.md deliverable
|
|
2
|
+
// "contains" before anything is indexed. archive/PLAN_REPOSITORY_INTERFACE.md deliverable
|
|
3
3
|
// 2: "bootstrap returns honest empties".
|
|
4
4
|
//
|
|
5
5
|
// It implements every Repository-Interface service over the empty bootstrap
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// The FIXTURE reference provider — a small, real, self-contained code graph that
|
|
2
|
-
// implements every Repository-Interface service. PLAN_REPOSITORY_INTERFACE.md
|
|
2
|
+
// implements every Repository-Interface service. archive/PLAN_REPOSITORY_INTERFACE.md
|
|
3
3
|
// deliverable 2: "the executable specification an external producer reads first".
|
|
4
4
|
//
|
|
5
5
|
// It is a degenerate provider in the sense that its graph is tiny and its source
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// The reference Repository-Interface service over a parsed code graph.
|
|
2
|
-
// PLAN_REPOSITORY_INTERFACE.md — "the executable specification".
|
|
2
|
+
// archive/PLAN_REPOSITORY_INTERFACE.md — "the executable specification".
|
|
3
3
|
//
|
|
4
4
|
// createGraphService(graph) returns a typed service object implementing EVERY
|
|
5
5
|
// service in src/repository-interface.mjs over the `{ individuals, byId,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// The Repository Interface — tmct's OWNED, versioned contract between "interpret
|
|
2
2
|
// the query" (tmct, the brittle side) and "ask the graph for truth" (a provider,
|
|
3
|
-
// the stable side). PLAN_REPOSITORY_INTERFACE.md.
|
|
3
|
+
// the stable side). archive/PLAN_REPOSITORY_INTERFACE.md.
|
|
4
4
|
//
|
|
5
5
|
// tmct defines and versions this shape; a provider (seonix, a fixture, a browser
|
|
6
6
|
// page) IMPLEMENTS it over its native graph. Both sides already agree on the
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// src/router/guardrail.mjs — Stage 4 of the capability router
|
|
2
|
+
// (PLAN_CAPABILITY_ROUTER.md): THE GUARDRAIL. Validate an EXTERNALLY-proposed
|
|
3
|
+
// `tool_use` (e.g. an LLM's chosen call in the hybrid fast-path) against the
|
|
4
|
+
// registry's declared preconditions, and DEFAULT-DENY anything outside the
|
|
5
|
+
// declared, registered envelope. This is the precondition-checking half of the
|
|
6
|
+
// STRIPS model: a call fires only when its preconditions are provably satisfied.
|
|
7
|
+
//
|
|
8
|
+
// WHAT IT PROVES — and what it deliberately does NOT.
|
|
9
|
+
// The guardrail proves RESOLVABILITY: the tool exists in the registry, it was
|
|
10
|
+
// declared, every arg-key is one the operator accepts, every required arg is
|
|
11
|
+
// present, and every `resolves(param, as)` precondition binds to a real graph
|
|
12
|
+
// entity (delegated to resolveObject — the same oracle Stage 1 uses).
|
|
13
|
+
//
|
|
14
|
+
// It does NOT prove ANTECEDENT-CORRECTNESS. A cross-turn mis-binding — "it" ->
|
|
15
|
+
// the wrong Commit, say — produces a call whose symbol STILL resolves to a real
|
|
16
|
+
// entity, so it PASSES the guardrail. That is by design: binding-confidence
|
|
17
|
+
// across turns is the CHAT LEVER's job (pronoun/focus binding), not the
|
|
18
|
+
// guardrail's. A gate that leaned on Stage 4 for antecedent correctness would be
|
|
19
|
+
// trusting the wrong layer. The guardrail's contract is narrow and honest:
|
|
20
|
+
// "this symbol denotes SOMETHING real and the call is well-formed", never "this
|
|
21
|
+
// is the RIGHT something".
|
|
22
|
+
//
|
|
23
|
+
// DEFAULT-DENY: a tool name that is not a registered capability is denied outright
|
|
24
|
+
// (identical to a hallucinated/invented tool). The intentionally-unregistered
|
|
25
|
+
// unbounded tools (tmct_snippet/tmct_context*) are therefore denied here too — the
|
|
26
|
+
// registry is the whole trust boundary.
|
|
27
|
+
//
|
|
28
|
+
// Pure over its inputs + ctx.resolve (the binding oracle). No network, no Date.now.
|
|
29
|
+
|
|
30
|
+
import { capabilityByName, preconditionsOf, PRECOND } from "./registry.mjs";
|
|
31
|
+
import { hallucinationsIn } from "../../agentbench/grade.mjs";
|
|
32
|
+
|
|
33
|
+
/** Validate a proposed tool_use. Returns a glass-box verdict:
|
|
34
|
+
* { ok, tool, denied:[{reason,detail}], steps:[{pred,ok,...}], provenance }
|
|
35
|
+
* - ok=false with a `default-deny`/`undeclared`/`unknown-arg`/`missing-arg`
|
|
36
|
+
* denial is a STRUCTURAL rejection (no graph needed).
|
|
37
|
+
* - ok=false with an `unresolved` step is a BINDING rejection (a `resolves`
|
|
38
|
+
* precondition whose term matched no entity, or matched ambiguously).
|
|
39
|
+
* - ok=true means the call is RESOLVABLE + well-formed (NOT proven antecedent-
|
|
40
|
+
* correct — see the file header).
|
|
41
|
+
* `declaredNames` may be null to skip the declared-set check (validate against
|
|
42
|
+
* the registry alone); pass it to also enforce the case/session toolset.
|
|
43
|
+
* `ctx.resolve(term)` is the resolveObject oracle; omit it to skip binding proof
|
|
44
|
+
* (structural-only validation). */
|
|
45
|
+
export function guard(toolUse, declaredNames = null, ctx = {}) {
|
|
46
|
+
const name = toolUse?.name;
|
|
47
|
+
const input = toolUse && typeof toolUse.input === "object" && toolUse.input ? toolUse.input : {};
|
|
48
|
+
const denied = [];
|
|
49
|
+
const steps = [];
|
|
50
|
+
|
|
51
|
+
// 1. DEFAULT-DENY — unknown/unregistered tool is an automatic reject. Reuse the
|
|
52
|
+
// grader's hallucination check as the single source of truth for structural
|
|
53
|
+
// well-formedness (unknown-tool / undeclared / unknown-arg / missing-arg).
|
|
54
|
+
const declaredList = declaredNames ? [...declaredNames] : null;
|
|
55
|
+
const cap = capabilityByName(name);
|
|
56
|
+
if (!cap) {
|
|
57
|
+
denied.push({ reason: "default-deny", detail: `"${name ?? "(none)"}" is not a registered capability` });
|
|
58
|
+
return { ok: false, tool: name ?? null, denied, steps, provenance: "registry default-deny" };
|
|
59
|
+
}
|
|
60
|
+
// structural well-formedness against the registry (and the declared set when
|
|
61
|
+
// given — an undeclared-but-registered tool is still a policy denial).
|
|
62
|
+
const structural = hallucinationsIn({ name, input }, declaredList ?? [name]);
|
|
63
|
+
for (const p of structural) {
|
|
64
|
+
// when no declared set is supplied, an "undeclared" finding is not a real
|
|
65
|
+
// denial (we synthesised [name] as the set) — filter it out.
|
|
66
|
+
if (!declaredList && p.reason === "undeclared") continue;
|
|
67
|
+
denied.push(p);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// 2. PRECONDITION CHECK — the STRIPS safety gate, step by step (the proof).
|
|
71
|
+
for (const pre of preconditionsOf(name)) {
|
|
72
|
+
if (pre.pred === PRECOND.graphLoaded) {
|
|
73
|
+
// graph presence is the harness's responsibility; if a resolver is wired we
|
|
74
|
+
// treat graph-loaded as satisfied (resolveObject would throw without one).
|
|
75
|
+
steps.push({ step: "precondition", pred: pre.pred, ok: true });
|
|
76
|
+
} else if (pre.pred === PRECOND.anyPresent) {
|
|
77
|
+
const ok = pre.params.some((k) => input[k] !== undefined && input[k] !== null && String(input[k]).trim() !== "");
|
|
78
|
+
steps.push({ step: "precondition", pred: pre.pred, params: pre.params, ok });
|
|
79
|
+
if (!ok) denied.push({ reason: "missing-arg", detail: `${name} needs one of ${pre.params.join("|")}` });
|
|
80
|
+
} else if (pre.pred === PRECOND.resolves) {
|
|
81
|
+
const term = input[pre.param];
|
|
82
|
+
const present = term !== undefined && term !== null && String(term).trim() !== "";
|
|
83
|
+
if (!present) {
|
|
84
|
+
// a missing required arg is already flagged structurally; record the step.
|
|
85
|
+
steps.push({ step: "precondition", pred: pre.pred, param: pre.param, value: null, ok: false });
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
// DELEGATE to resolveObject (the binding oracle). No oracle wired → we can
|
|
89
|
+
// only assert the arg is PRESENT, not that it binds (structural mode).
|
|
90
|
+
if (!ctx.resolve) {
|
|
91
|
+
steps.push({ step: "precondition", pred: pre.pred, param: pre.param, value: term, ok: true, note: "structural-only (no resolver wired)" });
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
const r = ctx.resolve(String(term));
|
|
95
|
+
const resolvedOk = Boolean(r && r.match && !r.ambiguous);
|
|
96
|
+
steps.push({
|
|
97
|
+
step: "precondition", pred: pre.pred, param: pre.param, value: term,
|
|
98
|
+
ok: resolvedOk,
|
|
99
|
+
...(r && r.match ? { boundTo: r.match.label, boundClass: r.match.class ?? null, tier: r.tier ?? null } : {}),
|
|
100
|
+
...(r && r.ambiguous ? { ambiguous: true } : {}),
|
|
101
|
+
});
|
|
102
|
+
if (!resolvedOk) {
|
|
103
|
+
denied.push({
|
|
104
|
+
reason: "unresolved",
|
|
105
|
+
detail: r && r.ambiguous
|
|
106
|
+
? `${name}.${pre.param}="${term}" is ambiguous (narrow it)`
|
|
107
|
+
: `${name}.${pre.param}="${term}" resolves to no graph entity`,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const ok = denied.length === 0;
|
|
114
|
+
return { ok, tool: name, denied, steps, provenance: ok ? "resolvable (NOT proven antecedent-correct)" : "denied" };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Convenience boolean: does a proposed tool_use PASS the guardrail? */
|
|
118
|
+
export function admits(toolUse, declaredNames = null, ctx = {}) {
|
|
119
|
+
return guard(toolUse, declaredNames, ctx).ok;
|
|
120
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
// src/router/planner.mjs — Stage 3 of the capability router
|
|
2
|
+
// (PLAN_CAPABILITY_ROUTER.md): THE PLANNER. Compose a bounded, ordered plan of
|
|
3
|
+
// tool calls for a multi-step request, over the SAME operators Stage 1 resolves
|
|
4
|
+
// single-shot. Pure-JS POP/HTN + a Steel & Ho monitor-and-replan loop under a
|
|
5
|
+
// HARD budget — sound/complete INSIDE the declared operator model, honest-refuse
|
|
6
|
+
// (escalate) for novelty outside it. Deterministic, no-LLM, glass-box.
|
|
7
|
+
//
|
|
8
|
+
// THE MODEL, mapped to the literature:
|
|
9
|
+
// - HTN decomposition (NONLIN/SHOP2): a compound request is decomposed into an
|
|
10
|
+
// ORDERED list of sub-goals by declared METHODS — the sequencing connectives
|
|
11
|
+
// ("... then ...", "... and then ...") and the two closed recipes we author:
|
|
12
|
+
// the CONDITIONAL method ("if <check>, <action> [instead]") and the
|
|
13
|
+
// RELATIVE-FILTER method ("of the <set> <rel> X, which are <Y>"). Each leaf
|
|
14
|
+
// sub-goal is resolved by Stage 1 (resolveOne) — the primitive operator.
|
|
15
|
+
// - POP causal links (partial-order planning): each step's proof records the
|
|
16
|
+
// PRODUCER -> CONDITION -> CONSUMER link. An independent step's producer is
|
|
17
|
+
// the grounded graph (graph-loaded); a THREADED step (one whose entity came
|
|
18
|
+
// from a prior step via anaphora — "its subclasses", "describe it") records
|
|
19
|
+
// the prior STEP as its producer. That link IS the proof chain (grade.mjs's
|
|
20
|
+
// connectedness check reads it), never a flat ok-list. Least commitment: we
|
|
21
|
+
// only order what the connectives actually order.
|
|
22
|
+
// - Steel & Ho monitor-and-replan: after each call we read the tool_result;
|
|
23
|
+
// a failed sub-goal (an unresolvable entity / an operator that errors) forces
|
|
24
|
+
// an honest STOP (refuse/escalate) rather than pressing on with a broken
|
|
25
|
+
// chain. Bounded depth + a hard step counter GUARANTEE termination — no
|
|
26
|
+
// unbounded search can ever wedge the caller (the harness also caps us).
|
|
27
|
+
//
|
|
28
|
+
// THE OPEN-WORLD BOUNDARY, named honestly: novelty the declared methods + operators
|
|
29
|
+
// do not cover (a sub-goal that resolves to nothing, a connective we do not model)
|
|
30
|
+
// is REFUSED/ESCALATED, not guessed. Sound/complete is claimed only INSIDE the
|
|
31
|
+
// declared world.
|
|
32
|
+
|
|
33
|
+
import { resolveOne, extractEntity } from "./resolver.mjs";
|
|
34
|
+
|
|
35
|
+
// Hard budget — the planner may emit at most this many steps; a request that
|
|
36
|
+
// decomposes to more is REFUSED (escalate) rather than searched. Guarantees
|
|
37
|
+
// termination independent of the harness backstop.
|
|
38
|
+
export const MAX_STEPS = 8;
|
|
39
|
+
|
|
40
|
+
const PRONOUN_RE = /\b(?:it|its|them|those|these|that|their)\b/i;
|
|
41
|
+
|
|
42
|
+
/** HTN decomposition — turn a request into an ORDERED list of leaf sub-goals.
|
|
43
|
+
* Returns { method, segments:[{ text, role, thread }] }:
|
|
44
|
+
* - role "check" — a conditional antecedent (a test whose call still emits)
|
|
45
|
+
* - role "action" — a plain sub-goal
|
|
46
|
+
* - thread:true — the segment carries an anaphor to bind from a prior step
|
|
47
|
+
* A single segment (no connective) means "not multi-step" (the driver hands
|
|
48
|
+
* those to resolveOne directly). Pure. */
|
|
49
|
+
export function decompose(request) {
|
|
50
|
+
const raw = String(request || "").trim();
|
|
51
|
+
|
|
52
|
+
// METHOD 1 — the CONDITIONAL recipe: "if <check>, <action> [instead]".
|
|
53
|
+
const cond = raw.match(/^if\s+(.+?),\s*(.+?)(?:\s+instead)?$/i);
|
|
54
|
+
if (cond) {
|
|
55
|
+
return {
|
|
56
|
+
method: "conditional",
|
|
57
|
+
segments: [
|
|
58
|
+
{ text: cond[1].trim(), role: "check", thread: PRONOUN_RE.test(cond[1]) },
|
|
59
|
+
{ text: cond[2].trim(), role: "action", thread: PRONOUN_RE.test(cond[2]) },
|
|
60
|
+
],
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// METHOD 2 — the RELATIVE-FILTER recipe: "of the <set> <rel> X, which are <Y>".
|
|
65
|
+
// Decomposes to [produce the <set> (the <rel> over X), filter it by <Y>].
|
|
66
|
+
const rel = raw.match(/^of\s+the\s+(.+?),\s*which\s+(?:are\s+)?(.+?)$/i);
|
|
67
|
+
if (rel) {
|
|
68
|
+
return {
|
|
69
|
+
method: "relative-filter",
|
|
70
|
+
segments: [
|
|
71
|
+
{ text: rel[1].trim(), role: "action", thread: false },
|
|
72
|
+
{ text: `which are ${rel[2].trim()}`, role: "action", thread: true },
|
|
73
|
+
],
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// METHOD 3 — SEQUENCING: split on the ordered connectives. Least commitment:
|
|
78
|
+
// we only split where a connective actually is.
|
|
79
|
+
const parts = raw.split(/\s*(?:,\s*then\s+|,\s+and\s+then\s+|\s+and\s+then\s+|\s+then\s+|,\s+|\s+and\s+)\s*/i)
|
|
80
|
+
.map((s) => s.replace(/^(?:then\s+|and\s+then\s+|and\s+|also\s+|check\s+|next\s+)/i, "").trim())
|
|
81
|
+
.filter(Boolean);
|
|
82
|
+
const segments = parts.map((text) => ({ text, role: "action", thread: PRONOUN_RE.test(text) }));
|
|
83
|
+
return { method: segments.length > 1 ? "sequence" : "single", segments };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** True iff the request is multi-step (the planner owns it); else the driver
|
|
87
|
+
* routes it to the single-shot resolver. */
|
|
88
|
+
export function isMultiStep(request) {
|
|
89
|
+
return decompose(request).segments.length > 1;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Substitute a bound anaphor: replace a bare pronoun with the prior step's
|
|
93
|
+
* entity label so the leaf resolver can bind it ("its subclasses" + Widget ->
|
|
94
|
+
* "Widget subclasses"; "describe it" + fnAlpha -> "describe fnAlpha"). */
|
|
95
|
+
function bindAnaphor(text, lastEntity) {
|
|
96
|
+
if (!lastEntity) return text;
|
|
97
|
+
return text.replace(PRONOUN_RE, lastEntity);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** A conditional ANTECEDENT that tests a SPECIFIC entity's coverage ("X has no
|
|
101
|
+
* tests", "fnAlpha is untested") is the CHECK operator tmct_tests_for over that
|
|
102
|
+
* entity — NOT the no-arg tmct_untested (which lists the whole codebase). Rewrite
|
|
103
|
+
* it to the terse "tests <entity>" command form so the leaf resolver binds the
|
|
104
|
+
* entity. A check with no coverage predicate is left untouched. */
|
|
105
|
+
function rewriteCheck(text, lastEntity) {
|
|
106
|
+
if (!/\b(?:untested|tested|no\s+tests?|has\s+no\s+tests?|tests?|coverage|covered)\b/i.test(text)) return text;
|
|
107
|
+
const entity = extractEntity(text) || lastEntity;
|
|
108
|
+
return entity ? `tests ${entity}` : text;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const refuse = (why, driver) => ({ calls: [], refused: true, terminated: true, proof: [], driver, why });
|
|
112
|
+
|
|
113
|
+
/** Plan + execute a multi-step request. Returns a loopResult
|
|
114
|
+
* { calls, refused, terminated, proof, why, driver, observed }
|
|
115
|
+
* with a POP causal-link proof chain. Steel & Ho: each step is monitored; a
|
|
116
|
+
* failed sub-goal STOPS the plan honestly (refuse/escalate). Bounded by
|
|
117
|
+
* MAX_STEPS + a hard step counter. `driver` labels the row.
|
|
118
|
+
*
|
|
119
|
+
* ctx: { dispatch(name,input)->{ok,text,resolved?}, resolve(term)->resolveObject } */
|
|
120
|
+
export async function plan(request, declaredNames, ctx, { driver = "resolver-0.8.0" } = {}) {
|
|
121
|
+
const { method, segments } = decompose(request);
|
|
122
|
+
if (segments.length > MAX_STEPS) {
|
|
123
|
+
return refuse(`plan would need ${segments.length} steps (> budget ${MAX_STEPS}) — escalate`, driver);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const calls = [];
|
|
127
|
+
const proof = [];
|
|
128
|
+
const why = [`HTN method: ${method} — ${segments.length} sub-goal(s)`];
|
|
129
|
+
let lastEntity = null; // the most-recent bound entity label (for anaphora threading)
|
|
130
|
+
let steps = 0;
|
|
131
|
+
|
|
132
|
+
for (let i = 0; i < segments.length; i += 1) {
|
|
133
|
+
if (steps >= MAX_STEPS) return refuse("step budget exhausted mid-plan — escalate", driver);
|
|
134
|
+
steps += 1;
|
|
135
|
+
const seg = segments[i];
|
|
136
|
+
let text = seg.thread ? bindAnaphor(seg.text, lastEntity) : seg.text;
|
|
137
|
+
if (seg.role === "check") text = rewriteCheck(text, lastEntity);
|
|
138
|
+
|
|
139
|
+
const r = await resolveOne(text, declaredNames, ctx, { execute: true });
|
|
140
|
+
if (r.refused) {
|
|
141
|
+
// Steel & Ho: an unresolvable sub-goal breaks the causal chain — STOP
|
|
142
|
+
// honestly (escalate), never emit a partial/guessed plan.
|
|
143
|
+
return refuse(`sub-goal ${i + 1} ("${text}") did not resolve: ${r.reason}`, driver);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
calls.push(r.selected);
|
|
147
|
+
// POP causal link: the producer is the prior step when this step THREADED an
|
|
148
|
+
// anaphor from it; otherwise the grounded graph. Its condition is the arg the
|
|
149
|
+
// step needed. This is the "why step i" edge, not a flat ok.
|
|
150
|
+
const producer = seg.thread && i > 0 ? `step-${i}` : "graph";
|
|
151
|
+
const boundLabel = r.resolved?.label ?? Object.values(r.selected.input || {})[0] ?? null;
|
|
152
|
+
proof.push({ step: "causal-link", producer, condition: boundLabel, consumer: `step-${i + 1}:${r.selected.name}`, role: seg.role, ok: true });
|
|
153
|
+
for (const s of r.proof) proof.push({ ...s, ofStep: i + 1 });
|
|
154
|
+
|
|
155
|
+
if (r.resolved?.label) lastEntity = r.resolved.label;
|
|
156
|
+
why.push(...(r.why || []).map((w) => `[${i + 1}] ${w}`));
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return {
|
|
160
|
+
calls,
|
|
161
|
+
refused: false,
|
|
162
|
+
terminated: true,
|
|
163
|
+
proof,
|
|
164
|
+
driver,
|
|
165
|
+
why,
|
|
166
|
+
observed: `plan(${method}): ${calls.map((c) => c.name).join(" -> ")}`,
|
|
167
|
+
};
|
|
168
|
+
}
|