@polycode-projects/the-mechanical-code-talker 0.9.5 → 0.9.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/ROADMAP.md +41 -12
- package/bin/tmct.mjs +11 -2
- package/package.json +1 -1
- package/src/chat.mjs +371 -46
- package/src/router/goal-reasoner.mjs +62 -4
package/ROADMAP.md
CHANGED
|
@@ -12,10 +12,28 @@ mapped into them (item numbers retained for traceability); the seven sketches
|
|
|
12
12
|
formerly held in `code-talker-ideas.txt` are folded into items 8–11 below and
|
|
13
13
|
the file has been deleted.
|
|
14
14
|
|
|
15
|
-
## Where we are now (2026-07-
|
|
15
|
+
## Where we are now (2026-07-08)
|
|
16
16
|
|
|
17
|
-
`npm test` green (**
|
|
18
|
-
`HANDOVER.md` for the exact release
|
|
17
|
+
`npm test` green (**1055**). **v0.9.6, pushed** (0.8.2 → 0.9.6 across this session — see
|
|
18
|
+
`HANDOVER.md` for the exact release chain).
|
|
19
|
+
|
|
20
|
+
### Doing right now (5 background clusters, all worktree-isolated, merged sequentially as each lands)
|
|
21
|
+
|
|
22
|
+
- **Cluster A** — Bug 6 (scoped-listing false-empty) + Bug 7 (modal-in-fuzzy-cascade) + the
|
|
23
|
+
chat-feel residual pair (items 1-2 of the trio's siblings) + `edgesOfKind` memoization +
|
|
24
|
+
ontology numeric-vocabulary declaration.
|
|
25
|
+
- **Cluster B** — Bug 8 (goal-reasoner confident-wrong on off-domain requests) + refreshing
|
|
26
|
+
`PLAN_CAPABILITY_ROUTER.md`'s stale stage-5 status text.
|
|
27
|
+
- **Cluster C** — `PLAN_CHAT_FEEL.md` item 6, the pronoun/temporal/discourse-count trio
|
|
28
|
+
(measured red sets from an earlier advisor tick; re-measured against the current graded pool
|
|
29
|
+
before any fix).
|
|
30
|
+
- **Cluster D** — `PLAN_CODE.md` Track 1 (GOAL_RULE/PHRASING_FRAMES synthesis) — operator
|
|
31
|
+
sign-off given this session; Tracks 2-4 remain unsigned-off and untouched.
|
|
32
|
+
- **Cluster E** — `PLAN_INFERENCE_TESTING.md` Stage 1 (`cax-sco` rule) + Stage 2 (proof-chain
|
|
33
|
+
receipts) in `src/syllogise.mjs`, targeting the measured chat-A2 50% ceiling.
|
|
34
|
+
|
|
35
|
+
Also in flight: playtest sprint rounds 4-8 (3/3 rounds so far shipped a real fix), the strategy
|
|
36
|
+
advisor (restarted, 5-min re-arm).
|
|
19
37
|
|
|
20
38
|
### Now: shipped this session
|
|
21
39
|
|
|
@@ -26,7 +44,7 @@ the file has been deleted.
|
|
|
26
44
|
- **A new feature: predicate-based "find" queries.** "find me the payment class" now works:
|
|
27
45
|
type-filtered, fuzzy property-surface matching, with a narrow-then-broaden inheritance-aware
|
|
28
46
|
cascade and a boolean-fold generalization for compositional predicate queries. Design:
|
|
29
|
-
`PLAN_PREDICATE_QUERIES.md`.
|
|
47
|
+
`archive/PLAN_PREDICATE_QUERIES.md`.
|
|
30
48
|
- **Two research tracks landed as code.** The ontology plan's two inert synonym resources
|
|
31
49
|
(ConceptNet synonym/similar-to rows, phrasebook synonym families) are now wired into query-time
|
|
32
50
|
matching, and the disjointness premise set plus numeric vocabulary grew
|
|
@@ -41,18 +59,25 @@ the file has been deleted.
|
|
|
41
59
|
end to end, and surfaced a real goal-reasoner honesty gap (Bug 8, below).
|
|
42
60
|
- **A session-loop fix.** A throwing turn no longer aborts a piped/non-interactive session.
|
|
43
61
|
- **4 new plan docs.** `PLAN_ontology-hierarchies.md`, `PLAN_INFERENCE_TESTING.md` (revised so
|
|
44
|
-
infbench generation is mechanical, not hand-authored), `PLAN_PREDICATE_QUERIES.md`, and
|
|
62
|
+
infbench generation is mechanical, not hand-authored), `archive/PLAN_PREDICATE_QUERIES.md`, and
|
|
45
63
|
`PLAN_CODE.md` (new, program synthesis over tmct's closed DSLs, gated on explicit operator
|
|
46
|
-
sign-off per track,
|
|
64
|
+
sign-off per track — Track 1 signed off, see "Doing" above).
|
|
65
|
+
- **`PLAN_TMCT_ECOSYSTEM_INTEGRATION.md`**, landed — a code-grounded tmct/bedrock-meter/marginalia
|
|
66
|
+
integration plan. Corrects a standing wrong assumption (the `/v1/messages` shim was believed
|
|
67
|
+
unmerged; it's been on `main` since 0.8.0) and confirms seonix has already fully migrated onto
|
|
68
|
+
tmct as a real dependency.
|
|
69
|
+
- **A `/narrate` debug mode.** Opt-in (`/narrate on`, `--narrate`, `TMCT_NARRATE=1`) per-turn
|
|
70
|
+
decision narrative — which lane/pattern matched, results + sources, a deterministic goal
|
|
71
|
+
deduction — built for playtest-sprint debugging; zero cost when off (byte-identical regression
|
|
72
|
+
guard).
|
|
47
73
|
|
|
48
74
|
### Next: the open follow-ups
|
|
49
75
|
|
|
50
76
|
In priority order (full detail and measured targets in `HANDOVER.md`):
|
|
51
77
|
|
|
52
|
-
1. **Bug 8.**
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
zero-hallucination capability. Needs a real semantic gate.
|
|
78
|
+
1. **Bug 8 — FIXED.** A global-mode domain gate now requires the request itself (not just the
|
|
79
|
+
caller's declared toolset) to parse as a query about the deduced goal's domain, reusing
|
|
80
|
+
`ask.mjs`'s own grammar. Zero-hallucination baseline unchanged; independently re-verified.
|
|
56
81
|
2. **Bug 6.** Scoped listing false-empty: "list modules in `<pkg>`" returns empty though the
|
|
57
82
|
unscoped lister shows matches. Found dogfooding a 191k-entity monorepo graph.
|
|
58
83
|
3. **Bug 7.** A modal auxiliary ("should") survives the fuzzy-correction cascade and misreads as
|
|
@@ -62,8 +87,12 @@ In priority order (full detail and measured targets in `HANDOVER.md`):
|
|
|
62
87
|
5. **`PLAN_CODE.md`'s sign-off decision.** Track 1 (rule/frame synthesis) is the lowest-risk
|
|
63
88
|
candidate; decide with the operator whether to greenlight it.
|
|
64
89
|
6. Smaller chat-feel residuals from the 0.8.2 confirmation playtest, the Track-1 trio (pronoun,
|
|
65
|
-
temporal, discourse-count, measured red sets), `edgesOfKind` memoization for monorepo-scale
|
|
66
|
-
latency
|
|
90
|
+
temporal, discourse-count, measured red sets), and `edgesOfKind` memoization for monorepo-scale
|
|
91
|
+
latency. (The version bump plus push is done — see "Now" above.)
|
|
92
|
+
7. **`SKILL_PLAYTEST_SPRINT.md`, in progress.** A capped, delegated, chained playtest loop (each
|
|
93
|
+
round a background chat session against `examples/mini-webapp`, appraised and fixed+shipped
|
|
94
|
+
live). Rounds 1-3 shipped 3 real fixes (0.9.3-0.9.5); cap raised from 3 to 8 rounds mid-run;
|
|
95
|
+
continuing.
|
|
67
96
|
|
|
68
97
|
### Later: deferred by design, staged inside each plan
|
|
69
98
|
|
package/bin/tmct.mjs
CHANGED
|
@@ -35,6 +35,10 @@ Usage:
|
|
|
35
35
|
tmct interactive chat (the headline surface)
|
|
36
36
|
tmct chat [--repo <abs>] chat over a specific repo's graph
|
|
37
37
|
[--ephemeral] read the graph but write nothing back (demo/read-only)
|
|
38
|
+
[--narrate] start with narrate mode on — a verbose, developer-facing
|
|
39
|
+
trace of decision points/matched pattern/results/goal per
|
|
40
|
+
turn, appended under a "--- narrate ---" marker (also
|
|
41
|
+
TMCT_NARRATE=1; toggle mid-session with /narrate on|off)
|
|
38
42
|
[--plain] force the plain readline shell (the default when
|
|
39
43
|
stdin/stdout is not a terminal)
|
|
40
44
|
tmct memory [--repo <abs>] what tmct remembers: facts, utterances, sessions,
|
|
@@ -277,16 +281,21 @@ async function main() {
|
|
|
277
281
|
// code graph, no .tmct/memory dropped under it. A demo you can run repeatedly
|
|
278
282
|
// on a checked-in example without ever dirtying it.
|
|
279
283
|
const ephemeral = rest.includes("--ephemeral");
|
|
284
|
+
// `--narrate` (or TMCT_NARRATE=1, read directly by createSession from
|
|
285
|
+
// process.env — no extra wiring needed for the env-var form): start the
|
|
286
|
+
// session with the verbose developer/debug narrate mode already on. Default
|
|
287
|
+
// OFF; `/narrate on`/`/narrate off` also toggles it mid-session.
|
|
288
|
+
const narrate = rest.includes("--narrate");
|
|
280
289
|
// The shell gate: a real terminal gets the full-screen Ink TUI; `--plain` or a
|
|
281
290
|
// non-TTY stream (pipes, scripts, the test suite) gets the readline shell. Both
|
|
282
291
|
// drive the same createSession sink — only the drawing differs.
|
|
283
292
|
const plain = rest.includes("--plain") || !process.stdin.isTTY || !process.stdout.isTTY;
|
|
284
293
|
if (plain) {
|
|
285
294
|
const { runChat } = await import("../src/chat.mjs");
|
|
286
|
-
await runChat({ repoPath, ephemeral });
|
|
295
|
+
await runChat({ repoPath, ephemeral, narrate });
|
|
287
296
|
} else {
|
|
288
297
|
const { runTui } = await import("../src/tui/app.mjs");
|
|
289
|
-
await runTui({ repoPath, ephemeral });
|
|
298
|
+
await runTui({ repoPath, ephemeral, narrate });
|
|
290
299
|
}
|
|
291
300
|
return;
|
|
292
301
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@polycode-projects/the-mechanical-code-talker",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.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.",
|
package/src/chat.mjs
CHANGED
|
@@ -79,6 +79,144 @@ const ASK_ENVELOPE_DELIM = "\n\n---tmct_ask---\n";
|
|
|
79
79
|
const CONTEXT_WORDS = new Set(["it", "this", "that", "here"]);
|
|
80
80
|
const isPronoun = (s) => CONTEXT_WORDS.has(String(s || "").trim().toLowerCase());
|
|
81
81
|
|
|
82
|
+
// ---- narrate mode (opt-in, developer/debug-facing) -------------------------
|
|
83
|
+
// "in the tmct interface let's be a lot more verbose, you and I are the only
|
|
84
|
+
// users" — a narrative of decision points, the matched pattern, the results +
|
|
85
|
+
// sources, and a deduced per-turn goal, appended to the answer. OFF by default
|
|
86
|
+
// (a `/narrate on`/`/narrate off` toggle, or a `--narrate`/TMCT_NARRATE=1
|
|
87
|
+
// session start): the DEFAULT (narrate:false) path must stay byte-identical
|
|
88
|
+
// to before this feature existed, so every site below is a cheap `trace?.push`
|
|
89
|
+
// no-op when tracing is off — runTurn only allocates the `trace` array at all
|
|
90
|
+
// when narrate is true. Design: a single mutable `trace` array threaded
|
|
91
|
+
// through runTurn -> runAsk/runCommand/conversationalTurn (via `ctx.trace`);
|
|
92
|
+
// each stage pushes plain, already-formatted lines tagged with their own
|
|
93
|
+
// category prefix ("goal:", "lane:", "pattern:", "result:", "source:",
|
|
94
|
+
// "intermediate:") — the trace array IS the narrative, in decision order; no
|
|
95
|
+
// separate structured side-channel to keep in sync. renderNarration (below,
|
|
96
|
+
// next to runTurn) buckets by that prefix into the sections the operator
|
|
97
|
+
// asked for and appends the block under NARRATE_MARKER, AFTER finish() and
|
|
98
|
+
// OUTSIDE of `last.answer` — so a narrated turn's repeat-detection / why-
|
|
99
|
+
// re-render logic (which compares `last.answer` bytes) is unaffected by
|
|
100
|
+
// whether narrate happens to be on.
|
|
101
|
+
export const NARRATE_MARKER = "--- narrate ---";
|
|
102
|
+
|
|
103
|
+
/** Push one narrative line, only when tracing is on (`trace` is the mutable
|
|
104
|
+
* array runTurn allocates for a narrate:true turn, else null/undefined). */
|
|
105
|
+
function note(trace, text) { if (trace) trace.push(text); }
|
|
106
|
+
|
|
107
|
+
/** relation `kind` (ask-vocab.mjs RELATIONS) -> a short, deterministic
|
|
108
|
+
* statement of what a person asking that KIND of question is probably after.
|
|
109
|
+
* Deliberately a small, honest bucket lookup over the query SHAPE the engine
|
|
110
|
+
* already computed — tmct is no-LLM, so goal deduction is table-driven, never
|
|
111
|
+
* free-text generation. A kind/shape this table doesn't recognise falls
|
|
112
|
+
* through to a generic line in deduceGoalFromParsed, never a fabricated guess. */
|
|
113
|
+
const GOAL_BY_KIND = {
|
|
114
|
+
imports: "understand a dependency/import relationship",
|
|
115
|
+
uses: "understand a dependency/usage relationship (imports and/or calls)",
|
|
116
|
+
calls: "understand a call relationship",
|
|
117
|
+
callsSymbol: "understand a call relationship",
|
|
118
|
+
defines: "locate what a module/class defines",
|
|
119
|
+
contains: "understand class membership (methods/attributes)",
|
|
120
|
+
tests: "assess test coverage",
|
|
121
|
+
inherits: "understand a class hierarchy/inheritance relationship",
|
|
122
|
+
touches: "understand commit/change history",
|
|
123
|
+
touchesSymbol: "understand commit/change history",
|
|
124
|
+
cochange: "understand change-coupling between modules",
|
|
125
|
+
reexports: "understand a module's public exports/API surface",
|
|
126
|
+
};
|
|
127
|
+
const goalNoun = (entityType) => (entityType ? `${String(entityType).toLowerCase()}(s)` : "entities");
|
|
128
|
+
|
|
129
|
+
/** Deduce a one-line goal statement from the ask engine's parsed AST — either
|
|
130
|
+
* the plain-clause form ({shape,kind,entityType,object[,subject]}) or the
|
|
131
|
+
* compositional form ({node:...}, ask.mjs's §compositional grammar). Returns
|
|
132
|
+
* null when there's nothing to bucket on (no parse stood at all); the caller
|
|
133
|
+
* supplies its own honest "didn't resolve" wording in that case. */
|
|
134
|
+
function deduceGoalFromParsed(parsed) {
|
|
135
|
+
if (!parsed) return null;
|
|
136
|
+
const { node, shape, kind } = parsed;
|
|
137
|
+
if (node === "find") return `locate a specific named entity ("${parsed.term}")`;
|
|
138
|
+
if (node === "count") return `get a count of ${goalNoun(parsed.entityType)}`;
|
|
139
|
+
if (node === "list") return `list/enumerate ${goalNoun(parsed.entityType)} matching a condition`;
|
|
140
|
+
if (node === "superlative") return `rank/compare ${goalNoun(parsed.entityType)} by ${parsed.metricNoun || parsed.metric || "a metric"}`;
|
|
141
|
+
if (node === "anaphora") return "follow up on the previous answer's result set (discourse anaphora)";
|
|
142
|
+
if (node === "membership") return `understand "${parsed.term || "an entity"}"'s membership/relationship`;
|
|
143
|
+
if (node === "clause") return deduceGoalFromParsed(parsed.clause);
|
|
144
|
+
if (node === "miss") return null;
|
|
145
|
+
if (node === "boolean" || node === "qualifier" || node === "reverseSet" || node === "forwardSet" || node === "allOfClass" || node === "temporal") {
|
|
146
|
+
const k = kind || parsed.inner?.kind;
|
|
147
|
+
return k && GOAL_BY_KIND[k] ? GOAL_BY_KIND[k] : `filter/traverse ${goalNoun(parsed.entityType)} by a relationship`;
|
|
148
|
+
}
|
|
149
|
+
// plain (non-compositional) clause
|
|
150
|
+
if (shape === "meta") return `understand a vocabulary/definition term ("${parsed.object}")`;
|
|
151
|
+
if (shape === "where") return `locate where something is defined ("${parsed.object}")`;
|
|
152
|
+
if (shape === "when") return "understand when something last changed (history)";
|
|
153
|
+
if (shape === "mentions") return `find where something is mentioned in prose ("${parsed.object}")`;
|
|
154
|
+
if (shape === "ask") return (kind && GOAL_BY_KIND[kind]) || "check a specific subject/object relationship";
|
|
155
|
+
if ((shape === "reverse" || shape === "forward") && kind) return GOAL_BY_KIND[kind] || `understand a "${kind}" relationship`;
|
|
156
|
+
return "understand a graph relationship";
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Split the collected trace into buckets by its own leading category tag, so
|
|
160
|
+
* renderNarration can group like with like while the trace array itself stays
|
|
161
|
+
* a flat, chronological narrative — no structured side-channel to keep in
|
|
162
|
+
* sync with the notes pushed at each call site. */
|
|
163
|
+
function bucketTrace(trace) {
|
|
164
|
+
const buckets = { goal: [], lane: [], pattern: [], result: [], source: [], intermediate: [] };
|
|
165
|
+
const other = [];
|
|
166
|
+
for (const line of trace) {
|
|
167
|
+
const m = /^([a-z]+):\s/.exec(String(line));
|
|
168
|
+
if (m && buckets[m[1]]) buckets[m[1]].push(line); else other.push(line);
|
|
169
|
+
}
|
|
170
|
+
return { ...buckets, other };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Render the collected trace into the human-readable block appended to a
|
|
174
|
+
* narrated turn's answer (see runTurn's withLast — this runs AFTER finish()
|
|
175
|
+
* and never touches `last.answer`). `fallbackGoal` covers turn types that
|
|
176
|
+
* push no "goal:" note of their own (a bare slash-command, a count, an
|
|
177
|
+
* assert) with a generic via-derived line — every narrated turn gets a goal
|
|
178
|
+
* line, never a silent gap. */
|
|
179
|
+
function renderNarration(trace, { record, detail, fallbackGoal }) {
|
|
180
|
+
const b = bucketTrace(trace);
|
|
181
|
+
const lines = [NARRATE_MARKER];
|
|
182
|
+
lines.push(...(b.goal.length ? b.goal : [`goal: ${fallbackGoal}`]));
|
|
183
|
+
lines.push(`decision: via=${record.via || "?"}${record.command ? ` command=/${record.command}` : ""}${record.miss ? " (miss)" : ""}`);
|
|
184
|
+
lines.push(...b.lane, ...b.pattern);
|
|
185
|
+
if (detail?.traversal) lines.push(`result: traversal — ${detail.traversal}`);
|
|
186
|
+
if (Array.isArray(detail?.matches) && detail.matches.length) {
|
|
187
|
+
const shown = detail.matches.slice(0, 5).map((m) => `${m.label}${m.type ? ` [${m.type}]` : ""}`);
|
|
188
|
+
lines.push(`result: ${detail.matches.length} match(es) — ${shown.join(", ")}${detail.matches.length > shown.length ? ", …" : ""}`);
|
|
189
|
+
}
|
|
190
|
+
if (Array.isArray(record.resolvedIds) && record.resolvedIds.length) {
|
|
191
|
+
lines.push(`result: resolved entity id(s) — ${record.resolvedIds.join(", ")}`);
|
|
192
|
+
}
|
|
193
|
+
if (Array.isArray(record.answeredIds) && record.answeredIds.length && record.answeredIds.length !== (detail?.matches?.length || 0)) {
|
|
194
|
+
lines.push(`result: answered entity id(s) — ${record.answeredIds.join(", ")}`);
|
|
195
|
+
}
|
|
196
|
+
lines.push(...b.result, ...b.source, ...b.intermediate, ...b.other);
|
|
197
|
+
return lines.join("\n");
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Append the narrate-mode block to a turn's OUTWARD-FACING answer/logLines —
|
|
201
|
+
* used at every runTurn return site, AFTER finish() (or, for a conversational
|
|
202
|
+
* turn, after its own render). Deliberately never touches `last.answer` /
|
|
203
|
+
* `last.detail` (the caller builds `last` from the PRE-narration `result`) so
|
|
204
|
+
* a narrated turn's own repeat-detection and why/say-more re-render (both of
|
|
205
|
+
* which compare `last.answer` bytes — see ORIENTATION_REPEAT_ONELINER and
|
|
206
|
+
* renderVerbose) see the exact same text a narrate:false run would have
|
|
207
|
+
* produced; narrate is purely additive to what's PRINTED, never to what's
|
|
208
|
+
* REMEMBERED. No-op (returns `result` unchanged, by reference) when `trace`
|
|
209
|
+
* is null (narrate off) or empty (nothing was traced). */
|
|
210
|
+
function withNarration(result, trace, fallbackGoal) {
|
|
211
|
+
if (!trace || !trace.length) return result;
|
|
212
|
+
const narrative = renderNarration(trace, { record: result.record, detail: result.detail, fallbackGoal });
|
|
213
|
+
const answer = `${result.answer}\n\n${narrative}`;
|
|
214
|
+
const logLines = Array.isArray(result.logLines)
|
|
215
|
+
? result.logLines.map((l) => (l === result.answer ? answer : l))
|
|
216
|
+
: result.logLines;
|
|
217
|
+
return { ...result, answer, logLines };
|
|
218
|
+
}
|
|
219
|
+
|
|
82
220
|
/** Slash-command → (dispatchTool name, arg key). Arg keys are the EXACT ones the
|
|
83
221
|
* server.mjs dispatchTool switch reads (members/subclasses take `class`;
|
|
84
222
|
* impact/exports take `module`; architecture takes `package`; search takes
|
|
@@ -517,26 +655,48 @@ function conversationalTurn(line, ctx) {
|
|
|
517
655
|
...(end ? { end: true } : {}),
|
|
518
656
|
};
|
|
519
657
|
};
|
|
520
|
-
if (BYE.has(q))
|
|
658
|
+
if (BYE.has(q)) {
|
|
659
|
+
note(ctx.trace, "goal: casual/social — ending the session (no graph intent)");
|
|
660
|
+
note(ctx.trace, "lane: conversational — farewell (BYE closed set)");
|
|
661
|
+
return mk(t(T_FAREWELL), { end: true });
|
|
662
|
+
}
|
|
521
663
|
if (WHY.has(q)) {
|
|
664
|
+
note(ctx.trace, "goal: elaborate on the previous answer (why/say-more)");
|
|
665
|
+
note(ctx.trace, "lane: conversational — why/say-more (WHY closed set)");
|
|
522
666
|
const v = renderVerbose(ctx.last);
|
|
523
667
|
// The empty-state hint is template wording (via:"template", the data row wins;
|
|
524
668
|
// renderVerbose's own string is the degraded fallback for direct library callers).
|
|
525
669
|
// A real expansion re-renders the LAST ANSWER — its wording is the prior answer's,
|
|
526
670
|
// not a template's, so it carries via:"conversational".
|
|
527
|
-
if (v.empty)
|
|
671
|
+
if (v.empty) {
|
|
672
|
+
note(ctx.trace, "intermediate: no previous answer held on ctx.last — nothing to expand");
|
|
673
|
+
return mk(tRender(ctx.templates, T_WHY_EMPTY) ?? v.text, { miss: true });
|
|
674
|
+
}
|
|
675
|
+
note(ctx.trace, `result: re-rendering the previous answer to "${ctx.last?.query ?? "?"}" verbosely`);
|
|
528
676
|
return mk(v.text, { via: "conversational" });
|
|
529
677
|
}
|
|
530
678
|
if (GREET.has(q)) {
|
|
679
|
+
note(ctx.trace, "goal: casual/social — greeting, no graph intent");
|
|
680
|
+
note(ctx.trace, "lane: conversational — greeting (GREET closed set)");
|
|
531
681
|
// #3 empty/degenerate-graph greeting: a plain "hi"/"hello" over a graph with 0
|
|
532
682
|
// modules orients toward --repo/tmct init instead of over-promising "ask me
|
|
533
683
|
// about this codebase". Phrase-specific variants (good morning, hello there)
|
|
534
684
|
// keep their wording; only the default greeting swaps.
|
|
535
685
|
const id = (!T_GREETING_BY_PHRASE[q] && noCodeGraph(ctx.graph)) ? T_GREETING_EMPTY : (T_GREETING_BY_PHRASE[q] || T_GREETING);
|
|
686
|
+
note(ctx.trace, `pattern: template "${id}" (data/templates/responses.jsonl)`);
|
|
536
687
|
return mk(t(id));
|
|
537
688
|
}
|
|
538
|
-
if (THANKS.has(q))
|
|
539
|
-
|
|
689
|
+
if (THANKS.has(q)) {
|
|
690
|
+
note(ctx.trace, "goal: casual/social — acknowledgement, no graph intent");
|
|
691
|
+
note(ctx.trace, "lane: conversational — thanks/acknowledgement (THANKS closed set)");
|
|
692
|
+
note(ctx.trace, `pattern: template "${T_THANKS}" (data/templates/responses.jsonl)`);
|
|
693
|
+
return mk(t(T_THANKS));
|
|
694
|
+
}
|
|
695
|
+
if (q === "help" || q === "?" || HELP_PHRASES.some((re) => re.test(raw))) {
|
|
696
|
+
note(ctx.trace, "goal: get oriented — what can tmct answer, how do I start");
|
|
697
|
+
note(ctx.trace, "lane: conversational — help/orientation (HELP_PHRASES / bare help / ?)");
|
|
698
|
+
return mk(orientationAnswer(ctx.templates, ctx.graph));
|
|
699
|
+
}
|
|
540
700
|
return null;
|
|
541
701
|
}
|
|
542
702
|
|
|
@@ -1154,6 +1314,7 @@ export async function helpText() {
|
|
|
1154
1314
|
["/stats", "a one-screen overview: entity counts, relationship counts, packages"],
|
|
1155
1315
|
["/memory [verbose]", "what tmct remembers: facts, utterances, sessions, folded blocks"],
|
|
1156
1316
|
["/focus <symbol>", "set the current focus (reused by 'it'/'this' and no-arg entity commands)"],
|
|
1317
|
+
["/narrate on|off", "verbose developer/debug mode: decision points, matched pattern, results+sources, goal per turn"],
|
|
1157
1318
|
["/help", "this list"],
|
|
1158
1319
|
["/exit", "leave the session (also Ctrl+C / Ctrl+D)"],
|
|
1159
1320
|
];
|
|
@@ -2169,7 +2330,7 @@ async function conceptForceAnswer(query, envelope, { graph, config, source, memo
|
|
|
2169
2330
|
* otherwise the unchanged dispatchTool path (which also yields the no-graph error).
|
|
2170
2331
|
* A hit updates the focus to the resolved object. Grammar miss / ToolError → a
|
|
2171
2332
|
* normal answer, never a crash. */
|
|
2172
|
-
async function runAsk(query, { config, source, graph, focus, last, templates, memoryDir, sessionId = "", lexicon = null, env }) {
|
|
2333
|
+
async function runAsk(query, { config, source, graph, focus, last, templates, memoryDir, sessionId = "", lexicon = null, env, trace }) {
|
|
2173
2334
|
const ts = new Date().toISOString();
|
|
2174
2335
|
// DISCOURSE ANAPHORA (CHATBENCH_006 levers 1+2): a follow-up like "which of those
|
|
2175
2336
|
// are tested" / "how many of those" / "count them" filters or counts the PREVIOUS
|
|
@@ -2189,7 +2350,10 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
|
|
|
2189
2350
|
// W2: the explicit recall forms are answered from memory's folded blocks, never
|
|
2190
2351
|
// the graph. Gated on memoryDir — a bare runTurn (no session shell) stays pure.
|
|
2191
2352
|
if (memoryDir && RECALL_ASK_RE.test(String(query).trim())) {
|
|
2353
|
+
note(trace, "goal: recall what was discussed earlier (explicit recall phrasing)");
|
|
2354
|
+
note(trace, "lane: RECALL_ASK_RE matched — answered from folded-session memory, never the graph");
|
|
2192
2355
|
const summary = await recallSummary(memoryDir);
|
|
2356
|
+
note(trace, summary ? "source: memory/fold.mjs recallSummary" : "intermediate: no folded session blocks yet — nothing to recall");
|
|
2193
2357
|
return plainTurn(query, summary ?? "nothing to recall yet — no earlier session has been folded into memory.", {
|
|
2194
2358
|
via: "recall", miss: !summary, focus,
|
|
2195
2359
|
});
|
|
@@ -2214,7 +2378,31 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
|
|
|
2214
2378
|
if (envJson) { try { envelope = JSON.parse(envJson); } catch { envelope = null; } }
|
|
2215
2379
|
} catch (e) {
|
|
2216
2380
|
answer = String(e?.message || e);
|
|
2217
|
-
|
|
2381
|
+
note(trace, `intermediate: the ask engine threw — ${answer}`);
|
|
2382
|
+
}
|
|
2383
|
+
// NARRATE: the direct parse/traversal receipt, straight off ask()'s own envelope
|
|
2384
|
+
// (§6.2) — this alone covers most of "the version of prompt that matched" and
|
|
2385
|
+
// "intermediate information" with ZERO extra instrumentation of ask.mjs: `parsed`
|
|
2386
|
+
// is the compiled AST (shape/kind/entityType or a compositional {node:...}),
|
|
2387
|
+
// `relaxed` is the FULL relaxation-cascade trace (what noise/unmatched tokens the
|
|
2388
|
+
// engine stripped/corrected before it found an answerable parse — exactly the
|
|
2389
|
+
// "almost resolved but failed" near-miss detail a playtest debugging session
|
|
2390
|
+
// wants), and `matchedVia` names the confidence tier (prose/fuzzy) a resolution
|
|
2391
|
+
// fell through to.
|
|
2392
|
+
if (envelope?.parsed) {
|
|
2393
|
+
const p = envelope.parsed;
|
|
2394
|
+
const shape = p.node ? `node=${p.node}` : `shape=${p.shape}`;
|
|
2395
|
+
note(trace, `pattern: parsed AST — ${shape}${p.kind ? ` kind=${p.kind}` : ""}${p.entityType ? ` entityType=${p.entityType}` : ""}${p.object != null ? ` object="${p.object}"` : ""}${p.term != null ? ` term="${p.term}"` : ""}`);
|
|
2396
|
+
} else {
|
|
2397
|
+
note(trace, "pattern: no parse stood (direct grammar miss — every registered strategy declined)");
|
|
2398
|
+
}
|
|
2399
|
+
if (envelope?.relaxed) {
|
|
2400
|
+
const r = envelope.relaxed;
|
|
2401
|
+
note(trace, `intermediate: the direct parse missed — the relaxation cascade rescued it: "${r.from}" -> "${r.to}"${r.dropped?.length ? ` (dropped: ${r.dropped.join(", ")})` : ""}`);
|
|
2402
|
+
if (r.steps?.length) note(trace, `intermediate: relaxation steps — ${r.steps.join(" | ")}`);
|
|
2403
|
+
}
|
|
2404
|
+
if (envelope?.matchedVia) note(trace, `source: term resolved via the "${envelope.matchedVia}" confidence tier (not a literal identifier match)`);
|
|
2405
|
+
if (envelope?.ambiguous) note(trace, "intermediate: the resolved term was AMBIGUOUS — multiple candidates matched, see the answer's disambiguation prompt");
|
|
2218
2406
|
// A grammar miss has parsed:null → stays []. An empty-RESULT query (object resolved,
|
|
2219
2407
|
// no edges) still records the resolved subject — that IS the asksAbout signal, and
|
|
2220
2408
|
// it becomes the new focus so a follow-up "what calls it" can reuse it.
|
|
@@ -2233,6 +2421,9 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
|
|
|
2233
2421
|
// Class-gate the focus update: a Commit/Session/schema object never displaces a
|
|
2234
2422
|
// standing code-entity focus (see nextFocus).
|
|
2235
2423
|
newFocus = nextFocus(graph, focus, ent);
|
|
2424
|
+
note(trace, `result: resolved object "${obj}" -> ${ent.label} (${ent.id}) — becomes the new focus`);
|
|
2425
|
+
} else if (!isPronoun(obj)) {
|
|
2426
|
+
note(trace, `intermediate: object "${obj}" did NOT resolve to a graph entity — this is why an otherwise-parsed query still misses`);
|
|
2236
2427
|
}
|
|
2237
2428
|
}
|
|
2238
2429
|
const answeredIds = (envelope?.matches || []).map((m) => m?.id).filter(Boolean);
|
|
@@ -2242,6 +2433,17 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
|
|
|
2242
2433
|
let via = "composed";
|
|
2243
2434
|
let recordMiss = miss;
|
|
2244
2435
|
let factPending = null; // a truncated fact listing's held remainder (for "more" paging)
|
|
2436
|
+
// GOAL DEDUCTION: from the parsed AST when one stood (deterministic, table-driven —
|
|
2437
|
+
// see deduceGoalFromParsed); a total grammar miss (no parse at all) gets the honest
|
|
2438
|
+
// "didn't resolve" goal line verbatim, matching the operator's own wording for that
|
|
2439
|
+
// case. Pushed once, EARLY (before the miss cascade below may go on to answer via a
|
|
2440
|
+
// completely different lane — an intent lane's own goal note, when it pushes one,
|
|
2441
|
+
// stays the more specific of the two since bucketTrace keeps every "goal:" line and
|
|
2442
|
+
// renderNarration shows them all, most-specific-last-written).
|
|
2443
|
+
{
|
|
2444
|
+
const deduced = deduceGoalFromParsed(envelope?.parsed);
|
|
2445
|
+
note(trace, `goal: ${deduced ?? "unclear — the phrasing didn't resolve to a known query shape"}`);
|
|
2446
|
+
}
|
|
2245
2447
|
// MISS handling. The intent lanes + short-miss are RECOGNIZER-gated on the query
|
|
2246
2448
|
// text AND only consulted on a would-miss, so a real graph query — a hit, an honest
|
|
2247
2449
|
// empty with a receipt, a fuzzy repair — is never hijacked. Order: (1) META/SELF
|
|
@@ -2254,7 +2456,10 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
|
|
|
2254
2456
|
// fact-dump readers so "what do you know" gets a summary, not raw facts.
|
|
2255
2457
|
if (miss) {
|
|
2256
2458
|
const meta = await metaLane(query, { graph, memoryDir });
|
|
2257
|
-
if (meta) {
|
|
2459
|
+
if (meta) {
|
|
2460
|
+
answer = meta.text; via = meta.via; recordMiss = false; handled = true;
|
|
2461
|
+
note(trace, `lane: (1) META/SELF — bare self/session question recognized, answered via="${meta.via}"`);
|
|
2462
|
+
}
|
|
2258
2463
|
}
|
|
2259
2464
|
if (!handled && miss && isConversational(query)) {
|
|
2260
2465
|
// A conversational miss (a greeting, "what can you do", a very short non-code
|
|
@@ -2264,8 +2469,11 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
|
|
|
2264
2469
|
// identical orientation-class turn used to repeat the full blurb verbatim —
|
|
2265
2470
|
// collapse to a one-liner on that repeat, mirroring WALL_REPEAT_ONELINER.
|
|
2266
2471
|
const orientation = orientationAnswer(templates, graph);
|
|
2267
|
-
|
|
2472
|
+
const repeat = last?.answer === orientation;
|
|
2473
|
+
answer = repeat ? ORIENTATION_REPEAT_ONELINER : orientation;
|
|
2268
2474
|
via = "template"; handled = true;
|
|
2475
|
+
note(trace, `lane: (2) conversational orientation — isConversational() matched a would-miss; ${repeat ? "REPEAT collapsed to one-liner" : "full orientation card"}`);
|
|
2476
|
+
note(trace, "goal: casual/social or too-short-to-be-structural — no graph intent");
|
|
2269
2477
|
} else if (!handled && memoryDir) {
|
|
2270
2478
|
// W4: vocabulary/definition questions consult the MEMORY graph's Facts alongside
|
|
2271
2479
|
// the schema-docs surface — a remembered fact answers a miss OR extends a (non-
|
|
@@ -2280,6 +2488,8 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
|
|
|
2280
2488
|
via = "fact";
|
|
2281
2489
|
recordMiss = false;
|
|
2282
2490
|
if (fact.pending) factPending = fact.pending; // a truncated fact list → paginable remainder
|
|
2491
|
+
note(trace, `lane: (3) memory facts — factAnswer/factReadBack matched (memoryDir=${memoryDir})`);
|
|
2492
|
+
note(trace, "source: .tmct/memory Facts (see /memory for provenance per line)");
|
|
2283
2493
|
} else if (miss) {
|
|
2284
2494
|
// W2: after the honest miss is composed, consult the folded-session memory. A
|
|
2285
2495
|
// relevant enough block ANSWERS — recalled Q/A framed + cited first, with the
|
|
@@ -2305,6 +2515,8 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
|
|
|
2305
2515
|
answer = `${recalled}\n\n${trailing}`;
|
|
2306
2516
|
via = "recall";
|
|
2307
2517
|
recordMiss = false; // memory answered it, cited — no longer a blank
|
|
2518
|
+
note(trace, "lane: (3) memory recall — recallFromBlocks matched a folded-session Q/A above the relevance floor");
|
|
2519
|
+
note(trace, "source: .tmct/memory folded session blocks (fold.mjs)");
|
|
2308
2520
|
}
|
|
2309
2521
|
}
|
|
2310
2522
|
}
|
|
@@ -2316,7 +2528,11 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
|
|
|
2316
2528
|
// conversational lanes (via:"meta"/"template"), which answer a different question.
|
|
2317
2529
|
if (via === "composed" || via === "fact") {
|
|
2318
2530
|
const def = await curatedDefinitionAnswer(query, envelope, { memoryDir, lexicon });
|
|
2319
|
-
if (def) {
|
|
2531
|
+
if (def) {
|
|
2532
|
+
answer = def.text; via = "corpus/seon"; recordMiss = false;
|
|
2533
|
+
note(trace, "lane: CURATED SEON DEFINITION — curatedDefinitionAnswer matched a lexicon term");
|
|
2534
|
+
note(trace, "source: corpus/seon (curated prose definition, licensed per data/corpus/seon)");
|
|
2535
|
+
}
|
|
2320
2536
|
}
|
|
2321
2537
|
// THE CONCEPT FORCE (concept.mjs) — a vague "what is a X" / "tell me about X" that
|
|
2322
2538
|
// names a KNOWN code concept WITH real instances composes the three-band answer
|
|
@@ -2336,6 +2552,8 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
|
|
|
2336
2552
|
conceptInstances = concept.instances;
|
|
2337
2553
|
conceptAllIds = concept.allIds;
|
|
2338
2554
|
conceptPending = concept.pending;
|
|
2555
|
+
note(trace, `lane: THE CONCEPT FORCE — a known code concept with ${concept.instances?.length ?? 0} real instance(s) composed the 3-band answer`);
|
|
2556
|
+
note(trace, "source: concept.mjs composeConcept — graph instances + corpus/seon definition");
|
|
2339
2557
|
} else {
|
|
2340
2558
|
// THE RELATION CONCEPT FORCE — the noun force declined, so try the edge-kind
|
|
2341
2559
|
// touch ("what about imports", "what are the calls", "tell me about contains").
|
|
@@ -2349,6 +2567,8 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
|
|
|
2349
2567
|
if (relation) {
|
|
2350
2568
|
answer = relation.text; via = "corpus/seon"; recordMiss = false;
|
|
2351
2569
|
conceptPending = relation.pending;
|
|
2570
|
+
note(trace, "lane: THE RELATION CONCEPT FORCE — the touched word named a known, edge-bearing relation kind");
|
|
2571
|
+
note(trace, "source: relationForceAnswer over the loaded graph's own edges (not corpus)");
|
|
2352
2572
|
}
|
|
2353
2573
|
}
|
|
2354
2574
|
}
|
|
@@ -2363,13 +2583,19 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
|
|
|
2363
2583
|
if (syn) {
|
|
2364
2584
|
answer = syn.text; via = "fact"; recordMiss = false;
|
|
2365
2585
|
if (syn.pending) factPending = syn.pending;
|
|
2586
|
+
note(trace, "lane: (3b) ONTOLOGY SYNONYM EXPANSION — a last-resort synonym of the term had direct facts");
|
|
2587
|
+
note(trace, "source: .tmct/memory Facts, reached via a known synonym (cited in the answer itself)");
|
|
2366
2588
|
}
|
|
2367
2589
|
}
|
|
2368
2590
|
// (4) #2 TEACH lane — a teach-shaped would-miss nothing above answered: route to
|
|
2369
2591
|
// memory, or say what CAN be remembered (LOUD), never the wall / a silent drop.
|
|
2370
2592
|
if (miss && recordMiss && via === "composed") {
|
|
2371
2593
|
const taught = await teachLane(query, { memoryDir, sessionId, lexicon });
|
|
2372
|
-
if (taught) {
|
|
2594
|
+
if (taught) {
|
|
2595
|
+
answer = taught.text; via = taught.via; recordMiss = taught.miss;
|
|
2596
|
+
note(trace, `lane: (4) TEACH — TEACH_RE/OWNS_TEACH_RE/BARE_DECLARATIVE_RE matched, ${taught.miss ? "but the payload could not be stored" : "reified into .tmct/memory"}`);
|
|
2597
|
+
note(trace, "goal: teach/remember a new fact");
|
|
2598
|
+
}
|
|
2373
2599
|
}
|
|
2374
2600
|
// (4b) #4 AUTHOR lane (0.8.2 WS4) — "who is <Name>", "what did <Name> touch",
|
|
2375
2601
|
// "who authored <sha>": the Commit author ATTRIBUTE answered as a person, off
|
|
@@ -2378,7 +2604,12 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
|
|
|
2378
2604
|
// honest miss below (never a guess).
|
|
2379
2605
|
if (miss && recordMiss && via === "composed") {
|
|
2380
2606
|
const authored = authorLane(query, { graph });
|
|
2381
|
-
if (authored) {
|
|
2607
|
+
if (authored) {
|
|
2608
|
+
answer = authored.text; via = authored.via; recordMiss = false;
|
|
2609
|
+
note(trace, "lane: (4b) AUTHOR — a who-is/what-did-<Name>-touch/who-authored-<sha> pattern matched a commit author");
|
|
2610
|
+
note(trace, "source: codegraph.mjs authorIndex (derived from Commit individuals)");
|
|
2611
|
+
note(trace, "goal: identify a person and/or what they touched (authorship/history)");
|
|
2612
|
+
}
|
|
2382
2613
|
}
|
|
2383
2614
|
// (4b2) #5(f) PRESUPPOSITION HONEST-NUDGE (ADVANCED_GRAMMAR track f) — "why
|
|
2384
2615
|
// does X still/again import Y": names the presupposition being checked
|
|
@@ -2387,7 +2618,11 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
|
|
|
2387
2618
|
// one is still an honest, confident correction, not a miss.
|
|
2388
2619
|
if (miss && recordMiss && via === "composed") {
|
|
2389
2620
|
const presup = await presuppositionNudge(query, { graph, memoryDir });
|
|
2390
|
-
if (presup) {
|
|
2621
|
+
if (presup) {
|
|
2622
|
+
answer = presup.text; via = "presupposition"; recordMiss = false;
|
|
2623
|
+
note(trace, "lane: (4b2) PRESUPPOSITION HONEST-NUDGE — a still/again-marked question's presupposition was checked against the graph");
|
|
2624
|
+
note(trace, "goal: verify an assumption baked into the question, then answer what survives");
|
|
2625
|
+
}
|
|
2391
2626
|
}
|
|
2392
2627
|
// (4c) CAPABILITY NUDGES (0.8.2 WS4) — risk scoring / code opinions / "write me
|
|
2393
2628
|
// code" imperatives / motive-"why": an honest wall pointing at the nearest real
|
|
@@ -2396,7 +2631,11 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
|
|
|
2396
2631
|
// short-miss's "is a <thing> a <kind>" membership hint could claim the line.
|
|
2397
2632
|
if (miss && recordMiss && via === "composed") {
|
|
2398
2633
|
const nudged = nudgeAnswer(query, newFocus);
|
|
2399
|
-
if (nudged) {
|
|
2634
|
+
if (nudged) {
|
|
2635
|
+
answer = nudged; via = "miss";
|
|
2636
|
+
note(trace, "lane: (4c) CAPABILITY NUDGE — the question asked tmct to do something outside its scope (opinion/generation/risk-scoring)");
|
|
2637
|
+
note(trace, "goal: out of scope for a no-LLM graph reader — pointed at the nearest real query shapes");
|
|
2638
|
+
}
|
|
2400
2639
|
}
|
|
2401
2640
|
// (4d) DESCRIBE-WRAPPER RESCUE (playtest sprint round 2, SKILL_PLAYTEST_SPRINT.md)
|
|
2402
2641
|
// — "can you describe X for me" / "tell me more about X": a closed wrapper
|
|
@@ -2410,7 +2649,11 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
|
|
|
2410
2649
|
// it only claims the turn if /describe actually resolves the captured term.
|
|
2411
2650
|
if (miss && recordMiss && via === "composed") {
|
|
2412
2651
|
const described = await describeWrapperAnswer(query, { config, source });
|
|
2413
|
-
if (described) {
|
|
2652
|
+
if (described) {
|
|
2653
|
+
answer = described.text; via = "describe"; recordMiss = false;
|
|
2654
|
+
note(trace, "lane: (4d) DESCRIBE-WRAPPER RESCUE — a polite wrapper around \"describe/tell me about <symbol>\" resolved via /describe, tried last after every other lane declined");
|
|
2655
|
+
note(trace, "goal: get a symbol's definition/kind/relations (phrased conversationally)");
|
|
2656
|
+
}
|
|
2414
2657
|
}
|
|
2415
2658
|
// (5) #1 SHORT TAILORED MISS — replace ONLY the engine's full grammar cheat-sheet
|
|
2416
2659
|
// wall (WALL_MISS_RE). Receipt-bearing misses keep their specific wording.
|
|
@@ -2419,10 +2662,10 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
|
|
|
2419
2662
|
// to a one-liner whose text does NOT match WALL_MISS_RE — self-limiting, so a
|
|
2420
2663
|
// third consecutive miss re-offers the tailored hint instead of droning.
|
|
2421
2664
|
if (miss && recordMiss && via === "composed" && WALL_MISS_RE.test(answer)) {
|
|
2422
|
-
|
|
2423
|
-
|
|
2424
|
-
: shortMissHint(query);
|
|
2665
|
+
const repeat = last?.answer && WALL_MISS_RE.test(String(last.answer));
|
|
2666
|
+
answer = repeat ? WALL_REPEAT_ONELINER : shortMissHint(query);
|
|
2425
2667
|
via = "miss";
|
|
2668
|
+
note(trace, `lane: (5) SHORT TAILORED MISS — every lane above declined; ${repeat ? "REPEAT collapsed to one-liner (wall kindness)" : "the full grammar wall was shortened + tailored to the query's keywords"}`);
|
|
2426
2669
|
}
|
|
2427
2670
|
// #4 HONEST-EMPTY POLISH — an empty CODE graph: any still-standing engine
|
|
2428
2671
|
// dead-end (an honest empty, the short miss, the bootstrap note) carries the exit
|
|
@@ -2430,6 +2673,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
|
|
|
2430
2673
|
if (recordMiss && (via === "composed" || via === "miss")
|
|
2431
2674
|
&& noCodeGraph(graph) && !/--repo|tmct init|no code graph/i.test(answer)) {
|
|
2432
2675
|
answer = `${answer}\n(this repo has no code graph — for structure, point me at a \`.tmct/graph.json\` with \`--repo <path>\` or run \`npm run example:mini\`; tmct doesn't index code itself.)`;
|
|
2676
|
+
note(trace, "intermediate: HONEST-EMPTY POLISH — the loaded graph has 0 modules, so the dead-end got a --repo/tmct init pointer appended");
|
|
2433
2677
|
}
|
|
2434
2678
|
// W5 (flag-gated, default OFF): an unknown-term miss may consult the LOCAL
|
|
2435
2679
|
// committed corpus slice — a hit APPENDS a grounded, licence-cited aside under
|
|
@@ -2439,6 +2683,8 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
|
|
|
2439
2683
|
if (aside) {
|
|
2440
2684
|
answer = `${answer}\n${aside}`;
|
|
2441
2685
|
via = "corpus";
|
|
2686
|
+
note(trace, "lane: W5 corpus aside — an unknown term matched the local committed corpus slice (TMCT_CORPUS_LOOKUP=1)");
|
|
2687
|
+
note(trace, "source: local committed corpus slice (licence-cited in the aside itself)");
|
|
2442
2688
|
}
|
|
2443
2689
|
}
|
|
2444
2690
|
// ADVANCED_GRAMMAR track (a) — counterfactual marker (PLAN_ADVANCED_GRAMMAR.md
|
|
@@ -2454,6 +2700,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
|
|
|
2454
2700
|
const counterfactualSubject = String(query).trim().match(COUNTERFACTUAL_RE);
|
|
2455
2701
|
if (!recordMiss && via === "composed" && counterfactualSubject) {
|
|
2456
2702
|
answer = `hypothetically, if ${counterfactualSubject[1].trim()} were removed: ${answer}`;
|
|
2703
|
+
note(trace, `intermediate: COUNTERFACTUAL_RE matched — compiled to a real traversal, wrapped as hypothetical ("${counterfactualSubject[1].trim()}" removed)`);
|
|
2457
2704
|
}
|
|
2458
2705
|
// The concept force answers WITH real example instances — those are the entities the
|
|
2459
2706
|
// turn "asked about" (the SchemaClass meta-node is documentation, not a code entity),
|
|
@@ -2493,28 +2740,52 @@ function plainTurn(query, answer, { command, via = "composed", miss = false, foc
|
|
|
2493
2740
|
};
|
|
2494
2741
|
}
|
|
2495
2742
|
|
|
2496
|
-
/** A slash-command → the mapped tool (or the /help, /focus, unknown
|
|
2497
|
-
* the same { answer, logLines, record, focus } shape as
|
|
2498
|
-
* the command name and the resolved entity id
|
|
2499
|
-
* slash-command turn becomes asksAbout graph data
|
|
2500
|
-
|
|
2743
|
+
/** A slash-command → the mapped tool (or the /help, /focus, /narrate, unknown
|
|
2744
|
+
* cases). Returns the same { answer, logLines, record, focus } shape as
|
|
2745
|
+
* runAsk; the record carries the command name and the resolved entity id
|
|
2746
|
+
* (for entity commands) so a slash-command turn becomes asksAbout graph data
|
|
2747
|
+
* wherever it resolves an entity. `ctx.trace` (narrate mode, or undefined
|
|
2748
|
+
* when off) gets one "goal:"/"lane:" note per branch — a slash-command's
|
|
2749
|
+
* "decision" is simply which command+tool ran, so this is intentionally
|
|
2750
|
+
* lighter than runAsk's miss-cascade instrumentation. */
|
|
2751
|
+
async function runCommand(line, { config, source, graph, focus, memoryDir, trace, narrate = false }) {
|
|
2501
2752
|
const ts = new Date().toISOString();
|
|
2502
2753
|
const sp = line.indexOf(" ");
|
|
2503
2754
|
const name = (sp === -1 ? line.slice(1) : line.slice(1, sp)).toLowerCase();
|
|
2504
2755
|
const argText = (sp === -1 ? "" : line.slice(sp + 1)).trim();
|
|
2505
|
-
const mk = (answer, { resolvedIds = [], miss = false, newFocus = focus } = {}) => ({
|
|
2756
|
+
const mk = (answer, { resolvedIds = [], miss = false, newFocus = focus, narrateNext } = {}) => ({
|
|
2506
2757
|
answer,
|
|
2507
2758
|
logLines: [ts, `> ${line}`, answer, ""],
|
|
2508
2759
|
record: { type: "turn", ts, query: line, command: name, via: "command", resolvedIds, answeredIds: [], miss },
|
|
2509
2760
|
focus: newFocus,
|
|
2761
|
+
...(narrateNext !== undefined ? { narrate: narrateNext } : {}),
|
|
2510
2762
|
});
|
|
2511
2763
|
|
|
2512
|
-
if (name === "help") return mk(await helpText());
|
|
2513
|
-
if (name === "stats")
|
|
2764
|
+
if (name === "help") { note(trace, "goal: get oriented / learn available commands"); return mk(await helpText()); }
|
|
2765
|
+
if (name === "stats") {
|
|
2766
|
+
note(trace, "goal: get a one-screen overview of the loaded graph");
|
|
2767
|
+
return graph ? mk(renderStats(graph)) : mk("no graph loaded — /stats needs an index.", { miss: true });
|
|
2768
|
+
}
|
|
2769
|
+
|
|
2770
|
+
// /narrate on|off — the debug-mode toggle itself (session-scoped, mirrors the
|
|
2771
|
+
// /focus pattern: the new state rides the turn RESULT as `narrate`, and
|
|
2772
|
+
// createSession's turn() applies it to its own mutable state; a bare
|
|
2773
|
+
// runTurn caller threads it the same way it threads `focus`/`last`). A
|
|
2774
|
+
// status-only "/narrate" (no on/off) reports the CURRENT state and changes
|
|
2775
|
+
// nothing — never silently flips it.
|
|
2776
|
+
if (name === "narrate") {
|
|
2777
|
+
const arg = argText.toLowerCase();
|
|
2778
|
+
if (arg !== "on" && arg !== "off") {
|
|
2779
|
+
return mk(`narrate mode is ${narrate ? "on" : "off"} — /narrate on or /narrate off to change it.`);
|
|
2780
|
+
}
|
|
2781
|
+
const next = arg === "on";
|
|
2782
|
+
return mk(`narrate mode ${next ? "on" : "off"}.`, { narrateNext: next });
|
|
2783
|
+
}
|
|
2514
2784
|
|
|
2515
2785
|
// /memory [verbose] — what tmct remembers, as text (the ROADMAP "Memory
|
|
2516
2786
|
// inspection" surface; the same renderer serves the `tmct memory` CLI).
|
|
2517
2787
|
if (name === "memory") {
|
|
2788
|
+
note(trace, "goal: inspect tmct's memory store (facts/utterances/sessions)");
|
|
2518
2789
|
if (!memoryDir) return mk("no memory store here — /memory works inside a repo session.", { miss: true });
|
|
2519
2790
|
try {
|
|
2520
2791
|
const { inspectMemory } = await import("./memory/inspect.mjs");
|
|
@@ -2525,18 +2796,28 @@ async function runCommand(line, { config, source, graph, focus, memoryDir }) {
|
|
|
2525
2796
|
}
|
|
2526
2797
|
|
|
2527
2798
|
if (name === "focus") {
|
|
2799
|
+
note(trace, "goal: set the working focus entity for follow-up pronouns (it/this/that)");
|
|
2528
2800
|
if (!argText) return mk(focus ? `focus is ${focus.label}` : "no focus set — /focus <symbol> to set one.");
|
|
2529
2801
|
const ent = await resolveEntity(graph, isPronoun(argText) ? focus?.label : argText);
|
|
2530
2802
|
if (!ent) return mk(`could not resolve "${argText}" to a single entity — focus unchanged${focus ? ` (still ${focus.label})` : ""}.`, { miss: true });
|
|
2803
|
+
note(trace, `result: resolved "${argText}" -> ${ent.label} (${ent.id})`);
|
|
2531
2804
|
return mk(`focus set to ${ent.label}.`, { resolvedIds: [ent.id], newFocus: ent });
|
|
2532
2805
|
}
|
|
2533
2806
|
|
|
2534
2807
|
const spec = COMMANDS[name];
|
|
2535
|
-
if (!spec)
|
|
2808
|
+
if (!spec) {
|
|
2809
|
+
note(trace, `pattern: /${name} is not a registered command (see COMMANDS in src/chat.mjs)`);
|
|
2810
|
+
return mk(`unknown command /${name} — type /help for the list of commands.`, { miss: true });
|
|
2811
|
+
}
|
|
2812
|
+
note(trace, `goal: ${spec.help}`);
|
|
2813
|
+
note(trace, `lane: slash-command /${name} -> dispatchTool("${spec.tool}"${spec.arg ? `, {${spec.arg}}` : ""})`);
|
|
2536
2814
|
|
|
2537
2815
|
const entityArg = ENTITY_ARGS.has(spec.arg);
|
|
2538
2816
|
let value = argText;
|
|
2539
|
-
if (entityArg && (!value || isPronoun(value)))
|
|
2817
|
+
if (entityArg && (!value || isPronoun(value))) {
|
|
2818
|
+
value = focus?.label || "";
|
|
2819
|
+
if (value) note(trace, `intermediate: no/pronoun argument -> fell back to the standing focus "${value}"`);
|
|
2820
|
+
}
|
|
2540
2821
|
if (spec.arg && !spec.optional && !value) {
|
|
2541
2822
|
const need = entityArg ? `${spec.arg} (none given and no focus set — /focus <x> or pass one)` : spec.arg;
|
|
2542
2823
|
return mk(`/${name} needs a ${need}.`, { miss: true });
|
|
@@ -2546,6 +2827,7 @@ async function runCommand(line, { config, source, graph, focus, memoryDir }) {
|
|
|
2546
2827
|
try {
|
|
2547
2828
|
answer = await dispatchTool(spec.tool, spec.arg ? { [spec.arg]: value } : {}, { config, source });
|
|
2548
2829
|
} catch (e) {
|
|
2830
|
+
note(trace, `intermediate: dispatchTool("${spec.tool}") threw — ${String(e?.message || e)}`);
|
|
2549
2831
|
return mk(String(e?.message || e), { miss: true }); // the tool's own clean error, never a stack
|
|
2550
2832
|
}
|
|
2551
2833
|
// Entity commands resolve their subject for the sidecar/graph AND set the focus so a
|
|
@@ -2556,16 +2838,18 @@ async function runCommand(line, { config, source, graph, focus, memoryDir }) {
|
|
|
2556
2838
|
// Commit/Session/schema node records the resolution but does not displace a
|
|
2557
2839
|
// standing code-entity focus that "it" is meant to keep binding to.
|
|
2558
2840
|
if (ent) {
|
|
2841
|
+
note(trace, `result: resolved "${value}" -> ${ent.label} (${ent.id}, class=${graph?.byId?.get?.(ent.id)?.class || "?"})`);
|
|
2559
2842
|
// Bug B4 (0.8.2 follow-up): /describe's code-map render never sees memory,
|
|
2560
2843
|
// so a taught fact about the resolved entity is invisible to it — append
|
|
2561
2844
|
// matching taught facts (subject === the resolved entity, trust-ranked)
|
|
2562
2845
|
// under the code-map answer, mirroring the ask-path's fact-append pattern.
|
|
2563
2846
|
if (name === "describe" && memoryDir) {
|
|
2564
2847
|
const facts = await describedFacts(memoryDir, ent.label);
|
|
2565
|
-
if (facts) answer = `${answer}\n${facts}`;
|
|
2848
|
+
if (facts) { answer = `${answer}\n${facts}`; note(trace, "source: memory facts (describedFacts) appended to the code-map answer"); }
|
|
2566
2849
|
}
|
|
2567
2850
|
return mk(answer, { resolvedIds: [ent.id], newFocus: nextFocus(graph, focus, ent) });
|
|
2568
2851
|
}
|
|
2852
|
+
note(trace, `intermediate: "${value}" did not resolve to a single entity — the tool's own (unresolved) answer stands`);
|
|
2569
2853
|
}
|
|
2570
2854
|
return mk(answer);
|
|
2571
2855
|
}
|
|
@@ -2640,10 +2924,17 @@ function morePage(query, { last, focus }) {
|
|
|
2640
2924
|
return turn;
|
|
2641
2925
|
}
|
|
2642
2926
|
|
|
2643
|
-
export async function runTurn(input, { config, source = defaultSource, graph = null, focus = null, last = null, memoryDir = null, sessionId = "", env = process.env, lexicon = null } = {}) {
|
|
2927
|
+
export async function runTurn(input, { config, source = defaultSource, graph = null, focus = null, last = null, memoryDir = null, sessionId = "", env = process.env, lexicon = null, narrate = false } = {}) {
|
|
2644
2928
|
const line = String(input ?? "").trim();
|
|
2645
2929
|
const templates = await chatTemplates(); // failure-tolerated: null degrades, never throws
|
|
2646
|
-
|
|
2930
|
+
// narrate mode: allocate the mutable trace array ONLY when on (`null` when off,
|
|
2931
|
+
// matching every OTHER optional collaborator here — templates/memoryDir/lexicon
|
|
2932
|
+
// all null-degrade the same way). Every note()/withNarration() call below is a
|
|
2933
|
+
// cheap `if (trace)`/`if (!trace || !trace.length)` no-op when this is null, so
|
|
2934
|
+
// the narrate:false path allocates nothing extra and renders byte-identically to
|
|
2935
|
+
// before this feature existed — see the "---- narrate mode ----" section above.
|
|
2936
|
+
const trace = narrate ? [] : null;
|
|
2937
|
+
const ctx = { config, source, graph, focus, last, memoryDir, sessionId, templates, env, lexicon, trace, narrate };
|
|
2647
2938
|
// A DISPATCHED turn (count / slash-command / ask) becomes the new "last answer"
|
|
2648
2939
|
// that why/say-more re-renders; a conversational turn does not (it preserves it).
|
|
2649
2940
|
// FINISH SEAM (PLAN_RESPONSE_FINISHING §"Where it lives"): every dispatched turn's
|
|
@@ -2651,10 +2942,13 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
|
|
|
2651
2942
|
// finished answer becomes the `last` we expand. finish() owns the prose-span
|
|
2652
2943
|
// grammar pass (src/finish.mjs); it rewrites result.answer/logLines and leaves the
|
|
2653
2944
|
// protected spans (entities, paths, numbers, receipts, provenance) byte-invariant,
|
|
2654
|
-
// so `last` and the transcript stay consistent with what the shell prints.
|
|
2655
|
-
|
|
2945
|
+
// so `last` and the transcript stay consistent with what the shell prints. The
|
|
2946
|
+
// narrate block (withNarration, above) is applied AFTER `last` is captured from
|
|
2947
|
+
// the PRE-narration finished result — see withNarration's docblock for why.
|
|
2948
|
+
const withLast = (result, fallbackGoal = "unclear — no goal signal for this turn type") => {
|
|
2656
2949
|
const finished = finish(result, { graph });
|
|
2657
|
-
|
|
2950
|
+
const nextLast = { query: line, answer: finished.answer, detail: finished.detail ?? null };
|
|
2951
|
+
return { ...withNarration(finished, trace, fallbackGoal), last: nextLast };
|
|
2658
2952
|
};
|
|
2659
2953
|
|
|
2660
2954
|
// Slash-optional system commands: a bare leading command word ("stats",
|
|
@@ -2662,28 +2956,36 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
|
|
|
2662
2956
|
// layer, so a forgiving shell answers "stats" the way it answers "/stats" instead
|
|
2663
2957
|
// of falling through to the generic orientation.
|
|
2664
2958
|
const bareCmd = asBareCommand(line);
|
|
2665
|
-
if (bareCmd) return withLast(await runCommand(bareCmd, ctx));
|
|
2959
|
+
if (bareCmd) return withLast(await runCommand(bareCmd, ctx), "use a specific tool/command directly");
|
|
2666
2960
|
|
|
2667
2961
|
// Conversational layer next (greetings, thanks, help, bye, why/say-more) — these
|
|
2668
|
-
// resolve no entity and carry their own preserved `last`.
|
|
2962
|
+
// resolve no entity and carry their own preserved `last`. Bypasses withLast (a
|
|
2963
|
+
// conversational turn is never finish()'d / never becomes a new `last`), so the
|
|
2964
|
+
// narrate block is applied directly here instead.
|
|
2669
2965
|
const convo = conversationalTurn(line, ctx);
|
|
2670
|
-
if (convo) return convo;
|
|
2966
|
+
if (convo) return withNarration(convo, trace, "casual/social — no graph intent");
|
|
2671
2967
|
|
|
2672
2968
|
// "more" — page the remainder of a previous long listing, if one is held. Gated on
|
|
2673
2969
|
// an actual pending remainder so a bare "more" with nothing to continue falls through
|
|
2674
2970
|
// to the ordinary path (an honest miss), never a pretend page.
|
|
2675
2971
|
if (MORE_RE.test(line) && Array.isArray(last?.detail?.pending?.items) && last.detail.pending.items.length) {
|
|
2676
|
-
|
|
2972
|
+
note(trace, "goal: continue viewing a previous long listing (pagination)");
|
|
2973
|
+
note(trace, "lane: MORE_RE matched a held pending remainder from the previous turn's detail.pending");
|
|
2974
|
+
return withLast(morePage(line, ctx), "continue viewing a previous long listing");
|
|
2677
2975
|
}
|
|
2678
2976
|
|
|
2679
|
-
if (line.startsWith("/")) return withLast(await runCommand(line, ctx));
|
|
2977
|
+
if (line.startsWith("/")) return withLast(await runCommand(line, ctx), "use a specific tool/command directly");
|
|
2680
2978
|
// Declarative ACE sentences ("every module is a artifact") ASSERT into tmct's
|
|
2681
2979
|
// own memory and confirm — they are statements to remember, not graph queries.
|
|
2682
2980
|
// Gated on memoryDir: only a session shell provides a write target, so a bare
|
|
2683
2981
|
// runTurn (tests, library callers) stays pure and falls through to the engine.
|
|
2684
2982
|
if (memoryDir) {
|
|
2685
2983
|
const asserted = await assertTurn(line, ctx);
|
|
2686
|
-
if (asserted)
|
|
2984
|
+
if (asserted) {
|
|
2985
|
+
note(trace, "goal: teach/remember a new fact (declarative ACE sentence)");
|
|
2986
|
+
note(trace, "lane: assertTurn — grammar/ace.mjs parseAce matched a full triple with no residue");
|
|
2987
|
+
return withLast(asserted, "teach/remember a new fact");
|
|
2988
|
+
}
|
|
2687
2989
|
}
|
|
2688
2990
|
// MEMORY-STORE counts first ("how many facts / utterances do you know") — the
|
|
2689
2991
|
// memory graph owns Facts + Utterances, so these are answerable and consistent
|
|
@@ -2692,7 +2994,11 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
|
|
|
2692
2994
|
// structural counts (classes/functions/…) and sessions fall through unaffected.
|
|
2693
2995
|
if (memoryDir) {
|
|
2694
2996
|
const memCount = await answerMemoryCount(memoryDir, line);
|
|
2695
|
-
if (memCount != null)
|
|
2997
|
+
if (memCount != null) {
|
|
2998
|
+
note(trace, "goal: get a count of a memory-store kind (facts/utterances)");
|
|
2999
|
+
note(trace, "lane: answerMemoryCount — matched a MEMORY_COUNT_NOUNS entry, answered off the .tmct/memory graph header");
|
|
3000
|
+
return withLast(plainTurn(line, memCount, { via: "count", focus }), "get a count of a memory-store kind");
|
|
3001
|
+
}
|
|
2696
3002
|
}
|
|
2697
3003
|
// Aggregate/count questions are answered mechanically off the loaded graph header,
|
|
2698
3004
|
// BEFORE falling through to the ask engine (focus unchanged — a count names no entity).
|
|
@@ -2703,10 +3009,16 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
|
|
|
2703
3009
|
// class count). countFromFacts declines on a real graph kind, so ordinary
|
|
2704
3010
|
// counts are unaffected; it only speaks for a remembered object noun.
|
|
2705
3011
|
const viaFact = memoryDir ? await countFromFacts(graph, memoryDir, line) : null;
|
|
2706
|
-
if (viaFact != null)
|
|
2707
|
-
|
|
3012
|
+
if (viaFact != null) {
|
|
3013
|
+
note(trace, 'goal: get a count of an asserted-vocabulary kind ("every X is a Y" inherited cardinality)');
|
|
3014
|
+
note(trace, "lane: countFromFacts — the counted noun matched a remembered isa-fact's SUBJECT, whose class IS countable");
|
|
3015
|
+
return withLast(plainTurn(line, viaFact, { via: "fact", focus }), "get a count");
|
|
3016
|
+
}
|
|
3017
|
+
note(trace, "goal: get a count of a graph kind (classes/functions/modules/…)");
|
|
3018
|
+
note(trace, "lane: answerCount — a header-count aggregate question, answered mechanically off the graph header, never dispatched to the ask engine");
|
|
3019
|
+
return withLast(plainTurn(line, count, { via: "count", focus }), "get a count of a graph kind");
|
|
2708
3020
|
}
|
|
2709
|
-
return withLast(await runAsk(line, ctx));
|
|
3021
|
+
return withLast(await runAsk(line, ctx), "unclear — no goal signal computed by the ask engine");
|
|
2710
3022
|
}
|
|
2711
3023
|
|
|
2712
3024
|
// ---- W3: seedMemory → bootstrap (first run in a graph-less repo) ----
|
|
@@ -2824,6 +3136,7 @@ export async function createSession({
|
|
|
2824
3136
|
cwd = process.cwd(),
|
|
2825
3137
|
gitRoot = gitToplevel,
|
|
2826
3138
|
ephemeral = false,
|
|
3139
|
+
narrate = false,
|
|
2827
3140
|
} = {}) {
|
|
2828
3141
|
// EPHEMERAL mode (--ephemeral, or TMCT_EPHEMERAL=1): read the target graph but
|
|
2829
3142
|
// write NOTHING back into it. The shipped examples run this way so a demo never
|
|
@@ -2832,6 +3145,13 @@ export async function createSession({
|
|
|
2832
3145
|
// for structure; only the WRITE base (logs, memory, sessions) is diverted to an OS
|
|
2833
3146
|
// temp dir and the read-time graph upsert is suppressed.
|
|
2834
3147
|
ephemeral = ephemeral || /^(1|true|yes)$/i.test(String(env.TMCT_EPHEMERAL || ""));
|
|
3148
|
+
// NARRATE mode (--narrate, or TMCT_NARRATE=1 — same on/off convention as
|
|
3149
|
+
// TMCT_EPHEMERAL/TMCT_NO_SEED): start the session with narrate mode already
|
|
3150
|
+
// on. Session-scoped and mutable from here — `/narrate on`/`/narrate off`
|
|
3151
|
+
// flips it turn-to-turn the same way `/focus` mutates the session's focus
|
|
3152
|
+
// (see `turn()` below: a turn result's `narrate` field, when present,
|
|
3153
|
+
// updates this closure-private variable). Default OFF, as the operator asked.
|
|
3154
|
+
let narrateOn = narrate || /^(1|true|yes)$/i.test(String(env.TMCT_NARRATE || ""));
|
|
2835
3155
|
// Graph resolution order for the chat surface (documented; --repo wins):
|
|
2836
3156
|
// 1. --repo <path> → pins <path>/.tmct/graph.json (repo AND graph).
|
|
2837
3157
|
// 2. TMCT_GRAPH_FILE env → loads that graph anywhere (loadConfig reads it), so
|
|
@@ -2957,6 +3277,7 @@ export async function createSession({
|
|
|
2957
3277
|
get focus() { return focus; },
|
|
2958
3278
|
get lastAnswer() { return last; },
|
|
2959
3279
|
get turns() { return turns; },
|
|
3280
|
+
get narrate() { return narrateOn; },
|
|
2960
3281
|
promptFor: () => promptFor(focus),
|
|
2961
3282
|
|
|
2962
3283
|
/** One dispatched turn through the FULL sink sequencing (writeLog → writeSidecar
|
|
@@ -2968,7 +3289,7 @@ export async function createSession({
|
|
|
2968
3289
|
async turn(line) {
|
|
2969
3290
|
let result;
|
|
2970
3291
|
try {
|
|
2971
|
-
result = await runTurn(line, { config, source, graph, focus, last, memoryDir: repo, sessionId, env, lexicon });
|
|
3292
|
+
result = await runTurn(line, { config, source, graph, focus, last, memoryDir: repo, sessionId, env, lexicon, narrate: narrateOn });
|
|
2972
3293
|
} catch (e) {
|
|
2973
3294
|
const ts = new Date().toISOString();
|
|
2974
3295
|
const message = e instanceof Error ? e.message : String(e);
|
|
@@ -2979,9 +3300,12 @@ export async function createSession({
|
|
|
2979
3300
|
turns += 1;
|
|
2980
3301
|
return { answer: `Something went wrong answering that (${message}). Try rephrasing, or /help.`, end: false, prompt: promptFor(focus) };
|
|
2981
3302
|
}
|
|
2982
|
-
const { answer, logLines, record, focus: nextFocus, last: nextLast, end } = result;
|
|
3303
|
+
const { answer, logLines, record, focus: nextFocus, last: nextLast, end, narrate: nextNarrate } = result;
|
|
2983
3304
|
focus = nextFocus;
|
|
2984
3305
|
last = nextLast;
|
|
3306
|
+
// /narrate on|off (runCommand) rides the turn RESULT the same way a focus
|
|
3307
|
+
// update does — apply it to this handle's session-scoped state.
|
|
3308
|
+
if (typeof nextNarrate === "boolean") narrateOn = nextNarrate;
|
|
2985
3309
|
await writeLog(logLines.join("\n") + "\n");
|
|
2986
3310
|
await writeSidecar(record);
|
|
2987
3311
|
turnRecords.push(record);
|
|
@@ -3029,6 +3353,7 @@ export async function runChat({
|
|
|
3029
3353
|
cwd = process.cwd(),
|
|
3030
3354
|
gitRoot = gitToplevel,
|
|
3031
3355
|
ephemeral = false,
|
|
3356
|
+
narrate = false,
|
|
3032
3357
|
} = {}) {
|
|
3033
3358
|
// createSession's first-run seed (~2-3s, corpus/seon + ConceptNet) produces ZERO
|
|
3034
3359
|
// output until it fully resolves — found live: an operator reported `npm run chat`
|
|
@@ -3036,7 +3361,7 @@ export async function runChat({
|
|
|
3036
3361
|
// fast subsequent run just flashes it briefly) and removes the "is this even
|
|
3037
3362
|
// running" uncertainty during the one case that's genuinely slow.
|
|
3038
3363
|
output.write("tmct — starting…\n");
|
|
3039
|
-
const session = await createSession({ repoPath, source, env, cwd, gitRoot, ephemeral });
|
|
3364
|
+
const session = await createSession({ repoPath, source, env, cwd, gitRoot, ephemeral, narrate });
|
|
3040
3365
|
|
|
3041
3366
|
const dim = (s) => (env.NO_COLOR || !output.isTTY ? s : `\x1b[2m${s}\x1b[0m`);
|
|
3042
3367
|
for (const line of session.bannerLines) output.write(dim(line) + "\n");
|
|
@@ -40,11 +40,32 @@
|
|
|
40
40
|
// (queries mutate nothing — the STRIPS closed world), so no step can delete a
|
|
41
41
|
// condition another goal depends on. We compute this from the registry rather
|
|
42
42
|
// than assume it (threatsAmong), so the guarantee is grounded, not asserted.
|
|
43
|
+
//
|
|
44
|
+
// THE GLOBAL-MODE DOMAIN GATE (Bug 8 fix). In SCOPED mode, relevance is already
|
|
45
|
+
// proven structurally: the focus is a REAL bound graph entity (resolveObject
|
|
46
|
+
// found it), so the request is provably about something in the graph. In GLOBAL
|
|
47
|
+
// mode there is no focus to bind, and `applicableRules` alone only screens the
|
|
48
|
+
// CALLER'S DECLARED TOOLSET — a caller-constant fact that says nothing about
|
|
49
|
+
// whether THIS request has any connection to the deduced goal (a caller who
|
|
50
|
+
// declares tmct_untested/tmct_impact once per session would ground
|
|
51
|
+
// coverage-invariant for every off-topic turn). The fix reuses ask.mjs's OWN
|
|
52
|
+
// compositional NL grammar (parseQuery — the SAME primitive the C1 resolver
|
|
53
|
+
// already parses every request with, see resolver.mjs mapParse) as a structural
|
|
54
|
+
// relevance check, never a new keyword table: does the request even COMPILE to a
|
|
55
|
+
// recognized graph-query shape naming a known entity kind, and does that kind
|
|
56
|
+
// match the rule's declared focusClass? A request parseQuery cannot place at all
|
|
57
|
+
// (null — "write a haiku about pizza") or places without landing on any
|
|
58
|
+
// recognized entity kind (a miss with no entity kind — "how many pizzas are
|
|
59
|
+
// there") is an honest "not about this graph" signal; a request parseQuery
|
|
60
|
+
// resolves to a real AST naming the rule's focus class ("which module is the
|
|
61
|
+
// biggest testing risk" -> {node:"superlative", entityType:"Module", ...})
|
|
62
|
+
// stays exactly as reachable as before. Zero request keywords added.
|
|
43
63
|
|
|
44
64
|
import { backwardChain, extractEntity } from "./resolver.mjs";
|
|
45
65
|
import { capabilityByName, effectsOf } from "./registry.mjs";
|
|
46
66
|
import { hallucinationsIn } from "./call-validator.mjs";
|
|
47
67
|
import { intersect } from "./set-algebra.mjs";
|
|
68
|
+
import { parseQuery } from "../ask.mjs";
|
|
48
69
|
|
|
49
70
|
// Hard OUTER-tick budget — the meta-loop runs at most this many ticks, then
|
|
50
71
|
// REFUSES (escalate). Independent of BDI convergence and of the monotone
|
|
@@ -178,6 +199,25 @@ function focusOf(request, ctx) {
|
|
|
178
199
|
return r && r.match && !r.ambiguous ? r.match : null;
|
|
179
200
|
}
|
|
180
201
|
|
|
202
|
+
/** The GLOBAL-MODE DOMAIN GATE's primitive: what entity CLASS (if any) did
|
|
203
|
+
* ask.mjs's own compositional NL grammar recognize in the request? Walks
|
|
204
|
+
* parseQuery's AST (the same shapes resolver.mjs's mapParse/mapFrame already
|
|
205
|
+
* consume) for its declared `entityType` field, unwrapping the wrapper nodes
|
|
206
|
+
* (`clause`, `inner`, `base`) that carry no entityType of their own. Returns the
|
|
207
|
+
* class name, or null when the grammar placed nothing (an outright non-parse) or
|
|
208
|
+
* placed a MISS with no recognized entity kind at all ("how many pizzas are
|
|
209
|
+
* there" -> {node:"miss", reason:"count needs a known entity kind..."} carries no
|
|
210
|
+
* entityType, same as a flat null). Pure; no request-string keyword table — it
|
|
211
|
+
* reads a field ask.mjs's grammar already computes for every request. */
|
|
212
|
+
function parsedEntityType(node) {
|
|
213
|
+
if (!node || typeof node !== "object") return null;
|
|
214
|
+
if (typeof node.entityType === "string") return node.entityType;
|
|
215
|
+
if (node.clause) return parsedEntityType(node.clause);
|
|
216
|
+
if (node.inner) return parsedEntityType(node.inner);
|
|
217
|
+
if (node.base) return parsedEntityType(node.base);
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
|
|
181
221
|
/** Ground ONE epistemic sub-goal (a topic + optional bound entity) into a
|
|
182
222
|
* grounded, EXECUTED call, or null when it is not groundable in the declared
|
|
183
223
|
* toolset (=> the meta-loop escalates). Backward-chains topic->capability, binds
|
|
@@ -237,13 +277,31 @@ export async function goalReason(request, tools, ctx, { driver = "goal-0.8.1" }
|
|
|
237
277
|
// request's scope in this toolset); >1 => an AMBIGUOUS meta-goal (arbitration
|
|
238
278
|
// between meta-goals is undeclared) — both are honest refusals, never a guess.
|
|
239
279
|
const applicable = applicableRules(declared, focus, mode);
|
|
240
|
-
|
|
280
|
+
|
|
281
|
+
// THE GLOBAL-MODE DOMAIN GATE (Bug 8 fix, see the module header). SCOPED mode
|
|
282
|
+
// already proved relevance via a bound graph entity; GLOBAL mode has not, so
|
|
283
|
+
// `applicable` alone (a pure function of the caller's DECLARED TOOLSET) is not
|
|
284
|
+
// enough — it says nothing about whether THIS request is even about the graph.
|
|
285
|
+
// Screen it against ask.mjs's own NL grammar: the request must parse to a shape
|
|
286
|
+
// naming the candidate rule's declared focusClass, or it is refused as honestly
|
|
287
|
+
// off-domain rather than answered with someone else's goal.
|
|
288
|
+
let domainRelevant = applicable;
|
|
289
|
+
if (mode === "global" && applicable.length) {
|
|
290
|
+
const requestClass = parsedEntityType(parseQuery(request));
|
|
291
|
+
domainRelevant = applicable.filter((r) => requestClass === r.focusClass);
|
|
292
|
+
if (!domainRelevant.length) {
|
|
293
|
+
const classes = [...new Set(applicable.map((r) => r.focusClass))].join("/");
|
|
294
|
+
return refuse(`open-world: the request does not parse as a query about ${classes} (ask.mjs's own NL grammar names ${requestClass || "no recognized entity kind"} in it) — global goal deduction needs the REQUEST ITSELF to be about the deduced goal's domain, not just a declared toolset that happens to ground it — escalate`, driver);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
if (!domainRelevant.length) {
|
|
241
299
|
return refuse(`open-world: no declared goal-rule is applicable in ${mode} mode (each needs a sub-goal capability outside the declared toolset, or a scope it does not declare) — escalate`, driver);
|
|
242
300
|
}
|
|
243
|
-
if (
|
|
244
|
-
return refuse(`ambiguous meta-goal: ${
|
|
301
|
+
if (domainRelevant.length > 1) {
|
|
302
|
+
return refuse(`ambiguous meta-goal: ${domainRelevant.length} declared goal-rules apply (${domainRelevant.map((r) => r.id).join(", ")}) — meta-goal arbitration is undeclared, refuse rather than guess — escalate`, driver);
|
|
245
303
|
}
|
|
246
|
-
const rule =
|
|
304
|
+
const rule = domainRelevant[0];
|
|
247
305
|
|
|
248
306
|
// the glass-box WHY, citing the declared goal-rule by backward-chain (the C2
|
|
249
307
|
// twin of resolver.mjs's "backward-chain => <capability>" provenance).
|