@polycode-projects/the-mechanical-code-talker 2.11.6 → 2.11.10

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/README.md CHANGED
@@ -124,14 +124,14 @@ page answers codebase questions live, and eight more pages each ground their
124
124
  own domain: a full chat seeded with 32,646 facts (the same nine bands as
125
125
  `npm run init:xl`, capped to a 38.7 MB download, 2.1 MB on the wire), the
126
126
  **memory ledger** (every fact as a readable sentence; drill by clicking the
127
- terms inside), the **code explorer** (the same ledger UI refocused on a code
128
- graph, with a hint rail of suggested next questions), an **ingest page** that
129
- turns pasted or dropped text into grounded facts and downloads them as
130
- canonical JSONL, a Towers-of-Hanoi plan replayed move by move, the
131
- spider-and-fly and text-adventure games, and a sprite gallery whose chat dock
132
- answers from 1,033 generated sprite facts. The chat page and the ledger take
133
- the same paste-or-drop text in place; every page that holds a fact store
134
- exports it as JSONL.
127
+ terms inside), and the **code explorer** (the same ledger UI refocused on a
128
+ code graph, with a hint rail of suggested next questions). An **ingest
129
+ page** turns pasted or dropped text into grounded facts and downloads them
130
+ as canonical JSONL. The rest are a Towers-of-Hanoi plan replayed move by
131
+ move, the spider-and-fly and text-adventure games, and a sprite gallery
132
+ whose chat dock answers from 1,033 generated sprite facts. The chat page and
133
+ the ledger take the same paste-or-drop text in place; every page that holds
134
+ a fact store exports it as JSONL.
135
135
  The site hosts its own copy of wink-nlp, ships its assets precompressed,
136
136
  and a service worker precaches the big ones, so a second visit works
137
137
  offline. `tmct chat --render spider-fly|adventure|sprites [--output <path>]`
@@ -168,13 +168,14 @@ The same ledger UI, refocused on a code graph, also runs as a desktop app. It
168
168
  reads a `graph.json` (or a repo's `.tmct/` folder), shows every import, call and
169
169
  `contains` edge as a plain sentence around a focus symbol, and docks a live chat
170
170
  over the same graph. A hint rail suggests the next question from what the graph
171
- actually holds "what does X import", "which functions call Y", "list
172
- functions" so every suggestion resolves to a real answer.
171
+ actually holds: "what does X import", "which functions call Y", "list
172
+ functions". Every suggestion resolves to a real answer.
173
173
 
174
- The identical page also runs as a plain hosted page, over the demo code
175
- graph **[try it live →](https://polycode-projects.gitlab.io/the-mechanical-code-talker/code.html)**
176
- for a look with nothing to install. The desktop build below is for
177
- exploring your own repo or graph, which the hosted page cannot reach.
174
+ The identical page also runs as a plain hosted page over the demo code
175
+ graph, with nothing to install:
176
+ **[try it live →](https://polycode-projects.gitlab.io/the-mechanical-code-talker/code.html)**.
177
+ The desktop build below is for exploring your own repo or graph, which the
178
+ hosted page cannot reach.
178
179
 
179
180
  Electron is a dev-only dependency and never ships in the npm package. Because
180
181
  `.npmrc` sets `ignore-scripts=true`, installing it does not fetch the runtime
@@ -188,8 +189,8 @@ npm run electron # open the code explorer on the demo gra
188
189
  ```
189
190
 
190
191
  Open a graph or a repo from the window's title bar to explore your own code.
191
- The UI is channel-agnostic only the Electron shell (`electron/main.mjs` +
192
- `electron/preload.cjs`) is desktop-specific; the same page stays servable as a
192
+ The UI is channel-agnostic. Only the Electron shell (`electron/main.mjs` +
193
+ `electron/preload.cjs`) is desktop-specific. The same page stays servable as a
193
194
  plain web page. `npm run test:electron` runs the shell smoke via Playwright and
194
195
  skips cleanly when the binary is absent.
195
196
 
@@ -226,8 +227,8 @@ resolves to a real graph traversal or declines cleanly:
226
227
  (*because/although/while*), conditionals, and false-premise flags ("why
227
228
  does X still import Y" when it no longer does).
228
229
 
229
- The full catalog with measured coverage lives in `CAPABILITIES_2.7.12.md` and
230
- the `BENCHMARK_*.md` reports.
230
+ The full catalog with measured coverage lives in the `BENCHMARK_*.md`
231
+ reports.
231
232
 
232
233
  **Response finishing.** Before an answer prints, it is segmented into typed
233
234
  spans: prose versus *protected* entities, paths, numbers, code, provenance,
@@ -310,7 +311,7 @@ planner over the same read-only graph-query tools chat/serve use
310
311
  step in order with a provable causal-link proof chain, and folds the results
311
312
  into one answer. A request neither the planner nor a single lookup can ground
312
313
  escalates to a closed-world goal-reasoner, which deduces maintenance goals
313
- (coverage gaps, change-coupling risk) straight from the graph never from
314
+ (coverage gaps, change-coupling risk) straight from the graph, never from
314
315
  keywords in your question. Anything none of that grounds is an honest "no plan
315
316
  found", the same "grounded or an honest miss" rule as everywhere else in tmct.
316
317
 
@@ -344,7 +345,7 @@ composed answer (4): src/handlers/base.mjs, src/handlers/users.mjs, src/server/a
344
345
  ```
345
346
 
346
347
  tmct planned two calls (`tmct_impact` then `tmct_untested`), ran both against the
347
- real graph, and intersected the results itself you get the four modules that
348
+ real graph, and intersected the results itself. You get the four modules that
348
349
  are both downstream of the change AND missing coverage, not two separate lists
349
350
  you'd have to cross-reference by hand.
350
351
 
@@ -361,7 +362,7 @@ composed answer (1): src/lib/http.mjs
361
362
 
362
363
  It deduced the goal ("an impactful module must be tested"), gathered every
363
364
  untested module, ranked each by blast radius, and named the one worth testing
364
- first `src/lib/http.mjs`, the module with the widest reach.
365
+ first: `src/lib/http.mjs`, the module with the widest reach.
365
366
 
366
367
  `--tools tmct_impact,tmct_untested` restricts which capabilities the planner is
367
368
  allowed to use; `--json` prints the full machine-readable loop result (calls,
@@ -372,9 +373,9 @@ flag reference.
372
373
  ## Teach it a game, then ask it to plan
373
374
 
374
375
  The planner above works over a fixed toolset. This one works over rules you
375
- teach. A game definition is a plain-text file of controlled English the
376
+ teach. A game definition is a plain-text file of controlled English: the
376
377
  classes, the pieces, the ordering, and the legal moves as taught action
377
- rules with `#` comment lines carrying example prompts. `tmct init`
378
+ rules, with `#` comment lines carrying example prompts. `tmct init`
378
379
  scaffolds one at `.tmct/imports/games/hanoi-3.txt`, and
379
380
  `tmct import --file` teaches it sentence by sentence, reporting every line
380
381
  and refusing (exit 1) if any sentence declines.
@@ -402,8 +403,8 @@ Goal (inferred): Plan a move sequence from the current state to the goal (7 move
402
403
  facts stamped with the step that produced them ("disk-1@step1 rests on peg-c",
403
404
  sourced to the plan). The final step re-reads the store and confirms the goal
404
405
  from those written facts, never assuming success. The stamp is what makes each
405
- step a separate record; a question about the piece itself ("where does disk-1
406
- rest?", "is disk-1 clear?") reads the current board the latest step's facts,
406
+ step a separate record. A question about the piece itself ("where does disk-1
407
+ rest?", "is disk-1 clear?") reads the current board: the latest step's facts,
407
408
  not every step at once. The search is
408
409
  domain-general: the test
409
410
  suite teaches Towers of Hanoi purely as sentences for 1 to 8 disks and
@@ -417,17 +418,17 @@ the plan as a self-contained animated page (see "Two more surfaces" above).
417
418
  Three games run inside an ordinary chat session, no setup.
418
419
 
419
420
  **Guess the number.** Say `I'm thinking of a number between 1 and 100` and
420
- tmct guesses by narrowing an interval answer `higher`, `lower`, or
421
+ tmct guesses by narrowing an interval: answer `higher`, `lower`, or
421
422
  `correct`. It finds any number in at most 7 guesses, and if your answers
422
423
  contradict each other it names the contradicting pair and stops rather than
423
424
  guessing on. Say `think of a number` to swap seats: tmct commits to a secret
424
- and answers your guesses honestly, reveals on request, and corrects you from
425
- its own record if you claim it already said `correct`. The behaviour is
426
- pinned by `test/corpus/games/guess-number.jsonl`.
425
+ and sticks to it. It answers your guesses, reveals the number on request, and
426
+ corrects you from its own record if you claim it already said `correct`. The
427
+ behaviour is pinned by `test/corpus/games/guess-number.jsonl`.
427
428
 
428
429
  **A text adventure.** Say `start the adventure` (or `play ashcombe hall`)
429
430
  and tmct loads a small country-house mystery from a lazily-fetched worlds
430
- pack (`corpus/worlds/`) into the session's ordinary memory graph rooms,
431
+ pack (`corpus/worlds/`) into the session's ordinary memory graph: rooms,
431
432
  objects and people become graph facts, and the verbs (`go`, `take`, `open`,
432
433
  `unlock`, `look`…) are taught action rules, not hard-wired code. Every move
433
434
  writes per-turn snapshot facts, `look` is an extractive digest of the graph,
@@ -436,30 +437,30 @@ schedule whether you are there to see it or not. The full worked mystery is
436
437
  pinned step by step in `test/corpus/games/adventure.jsonl`.
437
438
 
438
439
  **Two agents, planning against each other.** Say `play spider and fly` (or
439
- `watch the spider and the fly`) and tmct runs both sides itself neither is
440
+ `watch the spider and the fly`) and tmct runs both sides itself. Neither is
440
441
  player-controlled. A spider hunts a fly across a 10×10 web; each side only
441
442
  believes what it can currently see (`vision_radius`, tunable), a fly wanders
442
443
  when nothing threatens it and evades when something does, a spider avoids
443
444
  other spiders, chases what it believes it sees, and builds a web when it
444
445
  holds position. Mass is real: both sides waste away each turn they don't
445
446
  eat, and a spider gains exactly the mass of what it catches. You can address
446
- either side directly (`@spider the fly is east`) to feed it a belief true
447
- or false and watch a wrong assertion mislead it for as long as the real
447
+ either side directly (`@spider the fly is east`) to feed it a belief, true
448
+ or false, and watch a wrong assertion mislead it for as long as the real
448
449
  target stays out of sight. `tmct.toml`'s `[games.spider-fly]` table tunes
449
450
  every rate; the full mechanic is pinned in `test/corpus/games/spider-fly.jsonl`.
450
451
 
451
452
  ## Learning on a miss
452
453
 
453
- A question tmct cannot ground is still an honest miss but on the cleanest
454
- kind of miss (a recognised word, a clean parse, simply no facts anywhere) it
454
+ A question tmct cannot ground is still an honest miss. But on the cleanest
455
+ kind of miss (a recognised word, a clean parse, simply no facts anywhere), it
455
456
  now consults two shipped, lazily-loaded packs before giving up:
456
457
 
457
- - `corpus/child/` 93k everyday-world triples filtered from ConceptNet by a
458
+ - `corpus/child/`: 93k everyday-world triples filtered from ConceptNet by a
458
459
  child-concept seed. Asked `what is a kettle` cold, tmct loads the term's
459
460
  triples into memory (provenance `child:conceptnet:kettle`, ranked below
460
461
  anything you teach) and answers from them; the next ask answers from
461
462
  memory directly.
462
- - `corpus/reference/` 3,887 Simple English Wikipedia summaries. When the
463
+ - `corpus/reference/`: 3,887 Simple English Wikipedia summaries. When the
463
464
  triples cannot answer, a matching article answers as a cited read-out
464
465
  (`source: reference article "Otter"…, CC BY-SA 4.0`).
465
466
 
@@ -495,7 +496,7 @@ by cleaned session logs:
495
496
  relevance rather than loaded wholesale.
496
497
 
497
498
  Every session also writes its own human-readable transcript,
498
- `.tmct/session-<id>.md` a glow-friendly Markdown file with one heading per
499
+ `.tmct/session-<id>.md`, a glow-friendly Markdown file with one heading per
499
500
  turn, the question as a blockquote, the reply in a fenced block. The
500
501
  browser chat page's "export .md" button writes the same shape.
501
502
 
@@ -538,7 +539,7 @@ Teaching isn't limited to the ACE grammar's fixed shapes. Tell tmct an
538
539
  arbitrary fact, like "margo really eats ribs", and it mints a fact you can
539
540
  ask about directly: "what does margo eat". New vocabulary compounds as you
540
541
  teach: "redis is a cache" mints "redis" even though it was never in the
541
- built-in lexicon, as long as one side of the sentence is already grounded
542
+ built-in lexicon, as long as one side of the sentence is already grounded.
542
543
  tmct never mints a fact between two totally ungrounded terms; it declines and
543
544
  nudges you to ground one side first. Quantified teaching stores the
544
545
  quantifier ("some functions are risky" … "how many functions are risky" →
@@ -553,13 +554,13 @@ rather than the code graph. A taught class answers both shapes too: after
553
554
 
554
555
  When you ask about a term, the read-back shows each "is a kind of" object with
555
556
  its own superclass chain: "what is rover" answers "rover is a kind of dog →
556
- canine → mammal → animal". If one label carries two unrelated senses you
557
- taught "rover is a dog" and a corpus row says "rover is a scout" the answer
557
+ canine → mammal → animal". If one label carries two unrelated senses (you
558
+ taught "rover is a dog" and a corpus row says "rover is a scout"), the answer
558
559
  groups by concept ("rover, the dog:" / "rover, the scout:") instead of listing
559
560
  two unrelated lines as if they were one thing. The split is deterministic over
560
561
  the stored hierarchy: two senses part when a stored disjointness separates
561
562
  their ancestors, when their chains never meet, or when they meet only at the
562
- very top. When the evidence is thin the answer stays a flat list grouping is
563
+ very top. When the evidence is thin the answer stays a flat list. Grouping is
563
564
  presentation, and it never retracts or reranks a fact.
564
565
 
565
566
  Teaching doesn't have to be typed, either. `tmct extract` runs a plain text
@@ -586,14 +587,14 @@ carries an `extracted:<file>` provenance tag at its own trust tier.
586
587
  recognizer skips: a copula or a known relation verb flanked by two nouns
587
588
  becomes a candidate triple, stored under its own `optimistic-extract:<file>`
588
589
  provenance (prior 0.35, below every curated pack) with no operator tag riding
589
- alongside so a fuzzy guess can never corroborate a curated fact. It is an
590
+ alongside, so a fuzzy guess can never corroborate a curated fact. It is an
590
591
  attempt, not full NLU: a sentence with no clean pair yields nothing.
591
592
  `--canonical` prints each grounded fact as a triple, noting how each endpoint
592
593
  already links into the store.
593
594
 
594
595
  The same pipeline is one library seam, `ingestText(text, options)` (exported
595
596
  as `@polycode-projects/the-mechanical-code-talker/ingest`), and one cold tool,
596
- `tmct_ingest` so a browser page, a script, or a tool-calling agent can ground
597
+ `tmct_ingest`. A browser page, a script, or a tool-calling agent can ground
597
598
  text without the CLI.
598
599
 
599
600
  ### Provenance and trust
@@ -754,7 +755,7 @@ already set up. Its `--graph` flag works differently from the others: it appends
754
755
  ```
755
756
 
756
757
  `tmct extract` is the document route into memory described under "Teach it"
757
- above the same teach recognizer, reading a file instead of your typing:
758
+ above: the same teach recognizer, reading a file instead of your typing:
758
759
 
759
760
  ```output:help:extract
760
761
  tmct extract <text-file> read a plain text file's sentences through the chat's own
@@ -787,7 +788,7 @@ inference" above:
787
788
  retractable entailed facts (never on the chat path)
788
789
  ```
789
790
 
790
- `tmct viz` renders the memory graph as the ledger explorer a single,
791
+ `tmct viz` renders the memory graph as the ledger explorer, a single,
791
792
  self-contained HTML file you can open in a browser:
792
793
 
793
794
  ```output:help:viz
@@ -867,7 +868,7 @@ aws/python/java) into ~7,380 facts on the default sqlite backend, a working
867
868
  example to copy from. `init:xl` starts from the large persona tier and adds
868
869
  the wordnet-xl corpus (~72,000 facts); `init:xxl` swaps wordnet-xl for the
869
870
  full WordNet slice plus namenet (~239,000 facts, the biggest committed
870
- vocabulary expect its imports to take a minute). The xl chain, spelled out:
871
+ vocabulary, so expect its imports to take a minute). The xl chain, spelled out:
871
872
 
872
873
  ```bash e2e heavy
873
874
  npx tmct init --persona-size large # npm run init:xl runs this whole chain from a clone
@@ -1171,12 +1172,12 @@ The full tables, judge scores, and transcripts are in the linked write-ups.
1171
1172
 
1172
1173
  | What it does | Result (2.7.12) | Read the number with this | Method |
1173
1174
  |---|---|---|---|
1174
- | Multi-hop entailment | 379/379 chat cases and 100/100 kernel cases, 0% fabrication, all bands pass | The case set is unchanged from 2.6.0 (same templates, same counts) the one real move this cycle is INF-4's ceiling-graded count dropping 35→30, five cases that now pass as genuine capability instead of against the declared honest-miss floor. | `BENCHMARK_INFERENCE_2.7.12.md` |
1175
- | Tool-call planning | 68/68 cases, 100% plan-completion, 100% result-completion, 0% hallucination, every rung A0→C2 | Goal driver. 2.6.0 gated at TOOL-7 (62/66, 94%) this cycle's router uplift (a guarded RECOVER step, a tied-candidate composer) cleared it, a real capability move, not a ruler change. | `BENCHMARK_AGENT_2.7.12.md` |
1175
+ | Multi-hop entailment | 379/379 chat cases and 100/100 kernel cases, 0% fabrication, all bands pass | The case set is unchanged from 2.6.0 (same templates, same counts). The one real move this cycle is INF-4's ceiling-graded count dropping 35→30: five cases that now pass as genuine capability instead of against the declared honest-miss floor. | `BENCHMARK_INFERENCE_2.7.12.md` |
1176
+ | Tool-call planning | 68/68 cases, 100% plan-completion, 100% result-completion, 0% hallucination, every rung A0→C2 | Goal driver. 2.6.0 gated at TOOL-7 (62/66, 94%). This cycle's router uplift (a guarded RECOVER step, a tied-candidate composer) cleared it: a real capability move, not a ruler change. | `BENCHMARK_AGENT_2.7.12.md` |
1176
1177
  | Groundedness | Every answer carries a source, and an empty graph reports itself empty. Judge-scored mean 1.809/2 over 138 cases, 5 hard fails, 136/138 tier-1. | Judged (`claude-haiku-4-5-20251001`, `judge-prompt-v2`) at N=1. The judge prompt moved v1→v2 since 2.6.0, so this is a measurement, not a clean lever comparison against the prior cycle. The judge runs in the offline eval harness, never in the product. | `BENCHMARK_CEFR_ENGLISH_2.7.12.md` |
1177
1178
  | Abstention (the honest miss) | 0% fabrication across 479 inference rows (379 chat + 100 kernel) and 0% hallucination across 272 agent rows | Structural, not a tuned threshold. tmct abstains because nothing matched, so the rows test a property of a no-model design rather than a score. | `BENCHMARK_INFERENCE_2.7.12.md`, `BENCHMARK_AGENT_2.7.12.md` |
1178
- | Determinism | Byte-identical on rerun a 379-case `--replay` clean across 2 runs, no LLM, no network, $0 per turn | A property of the no-model pipeline. | `BENCHMARK_INFERENCE_2.7.12.md` |
1179
- | Dialogue robustness (persona sweep) | A 6-persona sweep (textbook logician, casual newcomer, new developer, adversarial sceptic, returning user, planning user) fixed 25 of the prior cycle's 29 routed findings (21 clean, 4 with a residual noted); 4 remain broken, 2 in a shape distinct from the original complaint | Free exploration across all six personas surfaced roughly 60 fresh findings beyond the ratchet check the single highest-signal pattern: tmct's own suggested repair text was itself frequently broken when followed verbatim (since fixed, see `NEXT.md`). | `BENCHMARK_CONVERSATION_2.7.11.md` |
1179
+ | Determinism | Byte-identical on rerun: a 379-case `--replay` clean across 2 runs, no LLM, no network, $0 per turn | A property of the no-model pipeline. | `BENCHMARK_INFERENCE_2.7.12.md` |
1180
+ | Dialogue robustness (persona sweep) | A 6-persona sweep (textbook logician, casual newcomer, new developer, adversarial sceptic, returning user, planning user) fixed 25 of the prior cycle's 29 routed findings (21 clean, 4 with a residual noted); 4 remain broken, 2 in a shape distinct from the original complaint | Free exploration across all six personas surfaced roughly 60 fresh findings beyond the ratchet check. The single highest-signal pattern: tmct's own suggested repair text was itself frequently broken when followed verbatim (since fixed, see `NEXT.md`). | `BENCHMARK_CONVERSATION_2.7.11.md` |
1180
1181
 
1181
1182
  Three offline benchmark rigs live in a clone (they are not in the npm
1182
1183
  package). Each replays a committed case set through the real product and
package/bin/tmct.mjs CHANGED
@@ -1261,7 +1261,8 @@ async function main() {
1261
1261
  const rest = process.argv.slice(3);
1262
1262
  if (rest.includes("--help") || rest.includes("-h")) {
1263
1263
  process.stdout.write(
1264
- "tmct serve — Anthropic Messages API-compatible endpoint (POST /v1/messages)\n\n" +
1264
+ "tmct serve — Anthropic Messages API-compatible endpoint (POST /v1/messages)\n" +
1265
+ " plus a capability-router plan verb (POST /v1/plan)\n\n" +
1265
1266
  "Usage:\n" +
1266
1267
  " tmct serve [--repo <abs>] [--graph <path>] [--config <path>] [--host <h>] [--port <n>]\n\n" +
1267
1268
  " --repo <abs> target a repo's graph (<abs>/.tmct/graph.json); default: git root/cwd\n" +
@@ -1270,9 +1271,14 @@ async function main() {
1270
1271
  " --config <path> an alternate tmct.toml location (a file or a directory)\n" +
1271
1272
  " --host <h> bind address (default 127.0.0.1)\n" +
1272
1273
  " --port <n> TCP port (default 8787; 0 picks an ephemeral port)\n\n" +
1273
- "Request: { model, messages:[...], tools:[...], max_tokens, system? }\n" +
1274
- "Response: { id, type:\"message\", role:\"assistant\", content:[...blocks], stop_reason, usage }\n" +
1275
- " usage is always { input_tokens: 0, output_tokens: 0 } — tmct is the $0 floor.\n",
1274
+ "POST /v1/messages\n" +
1275
+ " Request: { model, messages:[...], tools:[...], max_tokens, system? }\n" +
1276
+ " Response: { id, type:\"message\", role:\"assistant\", content:[...blocks], stop_reason, usage }\n" +
1277
+ " usage is always { input_tokens: 0, output_tokens: 0 } — tmct is the $0 floor.\n\n" +
1278
+ "POST /v1/plan\n" +
1279
+ " Request: { request: \"<NL request>\", tools?: [\"tmct_impact\", ...] }\n" +
1280
+ " Response: the capability-router loop result — grounded { driver, calls, proof,\n" +
1281
+ " composed?, usage } or an in-band honest { refused: true, why }.\n",
1276
1282
  );
1277
1283
  return;
1278
1284
  }
@@ -1287,13 +1293,19 @@ async function main() {
1287
1293
  // REPLACES serve's old cwd-only default (loadConfig had no git-root
1288
1294
  // fallback) with the same git-root-aware default every other subcommand
1289
1295
  // now shares — a deliberate, documented unification, not a regression.
1290
- const { config } = await resolveRuntimeConfig({ argv: rest });
1291
- const srv = await startServer({ config, host, port });
1296
+ const { repo, config, toml } = await resolveRuntimeConfig({ argv: rest });
1297
+ // Open the taught store the same env > tmct.toml > default way `tmct plan`
1298
+ // does, so /v1/plan reasons over the taught world/rule records chat wrote —
1299
+ // registered per request and unregistered after, never mutated by serve.
1300
+ const { openMemoryBackend } = await import("../src/adapters/memory/core.mjs");
1301
+ const backendChoice = String(process.env.TMCT_MEMORY_BACKEND || toml?.memory?.backend || "").trim().toLowerCase();
1302
+ const { dir: memoryDir, close: closeMemoryStore } = await openMemoryBackend(repo, backendChoice);
1303
+ const srv = await startServer({ config, host, port, memoryDir });
1292
1304
  process.stdout.write(
1293
- `tmct serve — Anthropic Messages API at ${srv.url}/v1/messages (POST) — ` +
1305
+ `tmct serve — Anthropic Messages API at ${srv.url}/v1/messages (POST), plan at ${srv.url}/v1/plan (POST) — ` +
1294
1306
  `graph ${srv.config.graphFile} — usage billed $0 — Ctrl+C to stop\n`,
1295
1307
  );
1296
- const shutdown = async () => { await srv.close(); process.exit(0); };
1308
+ const shutdown = async () => { await srv.close(); await closeMemoryStore(); process.exit(0); };
1297
1309
  process.on("SIGINT", shutdown);
1298
1310
  process.on("SIGTERM", shutdown);
1299
1311
  return; // the listening server keeps the event loop alive
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@polycode-projects/the-mechanical-code-talker",
3
- "version": "2.11.6",
3
+ "version": "2.11.10",
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.",
@@ -144,6 +144,7 @@
144
144
  "build:plan-bundle": "node scripts/build-plan-bundle.mjs",
145
145
  "build:ledger-bundle": "node scripts/build-ledger-bundle.mjs",
146
146
  "build:ingest-bundle": "node scripts/build-ingest-bundle.mjs",
147
+ "build:research-bundle": "node scripts/build-research-bundle.mjs",
147
148
  "build:code-explorer-bundle": "node scripts/build-code-explorer-bundle.mjs",
148
149
  "build:electron": "node scripts/build-electron-app.mjs",
149
150
  "electron": "electron electron/main.mjs",
@@ -108,6 +108,8 @@ export async function normalizeConfig(raw, { configDir } = {}) {
108
108
  const seedCfg = {};
109
109
  if (seed.enabled !== undefined) seedCfg.enabled = seed.enabled;
110
110
  if (seed.limit !== undefined) seedCfg.limit = seed.limit;
111
+ if (seed.capture_unknown_context !== undefined) seedCfg.captureUnknownContext = seed.capture_unknown_context;
112
+ if (seed.unknown_context_limit !== undefined) seedCfg.unknownContextLimit = seed.unknown_context_limit;
111
113
  if (Object.keys(seedCfg).length) cfg.seed = seedCfg;
112
114
 
113
115
  // Extension-pack seam (src/services/extensions.mjs): sparse PASS-THROUGH only — the
@@ -317,6 +317,38 @@ export const PASSIVE_PARTICIPLE_TO_KIND = Object.freeze({
317
317
  touched: "touches", changed: "touches", modified: "touches", edited: "touches", updated: "touches",
318
318
  });
319
319
 
320
+ // ---- stacked reduced-relative clauses: a "<participle> <preposition>" bigram
321
+ // that opens a reduced relative modifying a head noun ("classes INHERITED FROM
322
+ // Widget DEFINED IN c.mjs"). Each entry names the relation kind and which role
323
+ // the following term fills:
324
+ // role "object" — the surface is active-disguised, the preposition marks the
325
+ // relation's OBJECT, so the answer is the SUBJECTS pointing at the term (a
326
+ // reverse traversal): "inherited from Widget" -> the classes that inherit
327
+ // Widget.
328
+ // role "agent" — a genuine passive whose "by"/"in" marks the AGENT, so the
329
+ // answer is the term's own FORWARD targets: "defined in c.mjs" -> what
330
+ // c.mjs defines.
331
+ // Only consulted by parseStackedReducedRelative, which requires TWO such
332
+ // bigrams on one head noun; a single reduced relative keeps its existing route.
333
+ // Naming senses and directionally-ambiguous bigrams ("imported from", "used
334
+ // in/for", "called <name>") are deliberately absent so they stay honest misses.
335
+ export const REDUCED_RELATIVE_CLAUSES = Object.freeze({
336
+ "inherited from": { kind: "inherits", role: "object" },
337
+ "extended from": { kind: "inherits", role: "object" },
338
+ "subclassed from": { kind: "inherits", role: "object" },
339
+ "defined in": { kind: "defines", role: "agent" },
340
+ "declared in": { kind: "defines", role: "agent" },
341
+ "contained in": { kind: "contains", role: "agent" },
342
+ "imported by": { kind: "imports", role: "agent" },
343
+ "called by": { kind: "calls", role: "agent" },
344
+ "used by": { kind: "uses", role: "agent" },
345
+ "tested by": { kind: "tests", role: "agent" },
346
+ "covered by": { kind: "tests", role: "agent" },
347
+ "touched by": { kind: "touches", role: "agent" },
348
+ "changed by": { kind: "touches", role: "agent" },
349
+ "exported by": { kind: "reexports", role: "agent" },
350
+ });
351
+
320
352
  // ---- normalization: contractions/informal spellings expanded before parsing,
321
353
  // shared by both parse strategies. ----
322
354
  export const CONTRACTIONS = Object.freeze({
@@ -23,7 +23,7 @@ import {
23
23
  CONTEXT_PRONOUNS, META_MEANING_VERBS,
24
24
  WHERE_MARKERS, MENTION_MARKERS,
25
25
  RELATIVE_PRONOUNS, PLACEHOLDER_NOUNS, BOOLEAN_CONNECTIVES, QUALIFIERS,
26
- PASSIVE_PARTICIPLE_TO_KIND, GENERIC_AGENT_WORDS,
26
+ PASSIVE_PARTICIPLE_TO_KIND, GENERIC_AGENT_WORDS, REDUCED_RELATIVE_CLAUSES,
27
27
  AGGREGATE_TRIGGERS, LIST_TRIGGERS, SUPERLATIVE_EXTREMES, EDGE_NOUN_TO_METRIC, METRIC_IMPLIES_ENTITY, ANAPHORA_TRIGGERS,
28
28
  MEMBERSHIP_KINDS, CASCADE_NOISE, CASCADE_SYNONYMS, HELP_TRIGGERS,
29
29
  stripTrailingScopeFiller,
@@ -236,9 +236,57 @@ function parseComposite(text, nlp) {
236
236
  || parseList(w, lc, nlp, 0)
237
237
  || parseNested(w, lc, nlp, 0)
238
238
  || parsePluralAnaphoraObject(w, lc, nlp)
239
+ || parseStackedReducedRelative(w, lc)
239
240
  || parseRelationalOrQualified(w, lc, nlp, 0);
240
241
  }
241
242
 
243
+ // Two reduced relatives stacked on one head noun ("classes INHERITED FROM
244
+ // Widget DEFINED IN c.mjs") — a garden-path shape a naive incremental parser
245
+ // misattaches as a second main clause. Both clauses modify the head, so the
246
+ // reading is their intersection. Each clause's REDUCED_RELATIVE_CLAUSES entry
247
+ // says whether its term is the relation's object (reverse: the subjects that
248
+ // point at it) or its agent (forward: the term's own targets). The head noun's
249
+ // entityType rides the SEED clause, so a forward "defines" leg that would
250
+ // otherwise return every symbol is filtered to the asked kind. Anything that
251
+ // isn't exactly [lead] head bigram term bigram term returns null, leaving
252
+ // every other shape's behavior byte-identical.
253
+ const STACKED_RRC_LEAD = new Set(["which", "the", "all"]);
254
+ function parseStackedReducedRelative(w, lc) {
255
+ let i = 0;
256
+ if (STACKED_RRC_LEAD.has(lc[i])) i += 1;
257
+ const noun = entityNoun(lc[i]);
258
+ if (!noun || noun.placeholder || !noun.entityType) return null;
259
+ const entityType = noun.entityType;
260
+ i += 1;
261
+ const bigramAt = (k) => (k + 1 < lc.length ? REDUCED_RELATIVE_CLAUSES[`${lc[k]} ${lc[k + 1]}`] : undefined);
262
+ const rr1 = bigramAt(i);
263
+ if (!rr1) return null;
264
+ const term1Start = i + 2;
265
+ let split = -1;
266
+ let rr2;
267
+ for (let k = term1Start; k + 1 < lc.length; k += 1) {
268
+ const hit = bigramAt(k);
269
+ if (hit) { split = k; rr2 = hit; break; }
270
+ }
271
+ if (split < 0) return null;
272
+ const term1 = w.slice(term1Start, split).join(" ").trim();
273
+ const term2 = w.slice(split + 2).join(" ").trim();
274
+ if (!term1 || !term2) return null;
275
+ const clauseFor = (rr, term) => (rr.role === "object"
276
+ ? { shape: "reverse", kind: rr.kind, entityType, modifier: "direct", object: term }
277
+ : { shape: "forward", kind: rr.kind, modifier: "direct", object: term });
278
+ const seed = clauseFor(rr1, term1);
279
+ seed.entityType = entityType; // head-noun class filter on the seed set
280
+ return {
281
+ node: "boolean",
282
+ entityType,
283
+ atoms: [
284
+ { op: "seed", kind: "set", ast: { node: "clause", clause: seed } },
285
+ { op: "intersection", kind: "set", ast: { node: "clause", clause: clauseFor(rr2, term2) } },
286
+ ],
287
+ };
288
+ }
289
+
242
290
  // Negation as set complement: "which X do not <verb> Y" compiles to
243
291
  // allOfClass(kind) DIFFERENCE (the positive result set). The "Change"
244
292
  // pseudo-type has no bounded enumerable universe, so a complement over
@@ -598,6 +646,12 @@ function parsePredicateFilter(words, nlp) {
598
646
  const restLc = lc.slice(i);
599
647
  if (!rest.length) return { type: "all" };
600
648
  if (restLc.every((x) => QUALIFIERS[x])) return { type: "qual", filters: restLc };
649
+ // A bare concrete entity noun ("which of them are functions") narrows the
650
+ // prior set to one class rather than testing a relation.
651
+ if (rest.length === 1) {
652
+ const en = entityNoun(restLc[0]);
653
+ if (en && !en.placeholder && en.entityType) return { type: "entity", entityType: en.entityType };
654
+ }
601
655
  const clause = parseSimpleClause(`what ${rest.join(" ")}`, nlp);
602
656
  if (clause && (clause.shape === "reverse" || clause.shape === "forward") && clause.object) {
603
657
  return { type: "clause", clause };
@@ -1729,6 +1783,8 @@ function evalAnaphora(graph, ast, opts) {
1729
1783
  const f = ast.filter;
1730
1784
  if (f && f.type === "qual") {
1731
1785
  items = items.filter((ind) => f.filters.every((q) => qualHolds(graph, ind, QUALIFIERS[q])));
1786
+ } else if (f && f.type === "entity") {
1787
+ items = items.filter((ind) => ind.class === f.entityType);
1732
1788
  } else if (f && f.type === "clause") {
1733
1789
  const r = resolveObject(graph, f.clause.object);
1734
1790
  if (!r.match) items = [];
@@ -1743,9 +1799,12 @@ function evalAnaphora(graph, ast, opts) {
1743
1799
  }
1744
1800
  // A count over a prior set names the entity kind when survivors share a
1745
1801
  // class; fall back to the prior set's own class when the filter empties it,
1746
- // so the honest-empty render still names what was checked.
1802
+ // so the honest-empty render still names what was checked. An entity-type
1803
+ // filter that empties the set names the FILTER's kind ("functions"), not the
1804
+ // base set's — the reader asked which of them were that kind.
1747
1805
  const sameClass = (list) => (list.length && list.every((x) => x.class === list[0].class) ? list[0].class : null);
1748
- const common = items.length ? sameClass(items) : sameClass(baseItems);
1806
+ const emptyClass = f && f.type === "entity" ? f.entityType : sameClass(baseItems);
1807
+ const common = items.length ? sameClass(items) : emptyClass;
1749
1808
  if (ast.mode === "count") return { compositeKind: "count", count: items.length, entityType: common, matches: [] };
1750
1809
  return { compositeKind: "set", matches: items, entityType: common };
1751
1810
  }
@@ -3296,6 +3355,15 @@ function bareVerbFor(kind) {
3296
3355
  return RELATIONS[kind]?.bare || kind;
3297
3356
  }
3298
3357
 
3358
+ /** The passive participle of a relation ("uses" -> "used", "imports" ->
3359
+ * "imported"), for the confirming "X is <participle> by Y" frame. The bare
3360
+ * forms in this vocabulary are all regular, so a single +d/+ed rule covers
3361
+ * them. */
3362
+ function passiveParticipleFor(kind) {
3363
+ const bare = bareVerbFor(kind);
3364
+ return bare.endsWith("e") ? `${bare}d` : `${bare}ed`;
3365
+ }
3366
+
3299
3367
  /** English gloss of a SET-COMPLEMENT AST — the shape parseNegation compiles
3300
3368
  * "which X do not <verb> Y" into (allOfClass DIFFERENCE the positive set).
3301
3369
  * Restates the question in the same grammar the positive canonical uses
@@ -3747,11 +3815,28 @@ function renderCore(parsed, result, graph) {
3747
3815
  };
3748
3816
  }
3749
3817
  const entityWord = nounFor(parsed.entityType || "Module", 2);
3818
+ // Name the resolved antecedent, not the raw pronoun: "who touched it" that
3819
+ // bound "it" to fnAlpha must say so, or the receipt reads as though nothing
3820
+ // was resolved at all. Scoped to a context pronoun so a typed term keeps the
3821
+ // wording the reader chose, never its normalized graph label.
3822
+ const object = (result.objMatch && CONTEXT_PRONOUNS.includes(String(parsed.object || "").toLowerCase()))
3823
+ ? result.objMatch.label
3824
+ : parsed.object;
3750
3825
  return {
3751
- content: `No ${entityWord} found whose module directly ${verbFor(parsed.kind)} ${parsed.object}. ${touchesRephraseHint(graph)}`,
3826
+ content: `No ${entityWord} found whose module directly ${verbFor(parsed.kind)} ${object}. ${touchesRephraseHint(graph)}`,
3752
3827
  miss: true, ambiguous: false,
3753
3828
  };
3754
3829
  }
3830
+ // A polar reverse question ("is X used anywhere") with exactly one match
3831
+ // reads as a confirming yes that names the single subject, rather than a bare
3832
+ // one-item list. Scoped to one match: two or more keep the plain list.
3833
+ if (parsed.polar && result.matches.length === 1) {
3834
+ const objLabel = result.objMatch?.label || parsed.object;
3835
+ return {
3836
+ content: `Yes — ${objLabel} is ${passiveParticipleFor(parsed.kind)} by ${result.matches[0].label}.`,
3837
+ miss: false, ambiguous: false, matches: result.matches,
3838
+ };
3839
+ }
3755
3840
  // Route by the matched entities' actual class, not just the parsed hint —
3756
3841
  // grouping module-level matches by-module would read as nonsense ("in
3757
3842
  // a.mjs there is a.mjs"). Fine-grained grouping only applies to sub-module
@@ -228,6 +228,20 @@ export function stateFromFacts(factRows, domain) {
228
228
  return state;
229
229
  }
230
230
 
231
+ /** The highest @stepN snapshot index present for a domain individual, or 0
232
+ * when the board carries no snapshot layer yet. A freshly minted plan reads
233
+ * this as its stepBase, so its own @stepK writes stack ABOVE any standing
234
+ * snapshot instead of colliding with it and being read as the same layer. */
235
+ export function maxSnapshotStep(factRows, domain) {
236
+ const individuals = domainIndividuals(domain);
237
+ let max = 0;
238
+ for (const row of factRows || []) {
239
+ const m = SNAPSHOT_RE.exec(normTerm(row.subject));
240
+ if (m && individuals.has(m[1])) max = Math.max(max, Number(m[2]));
241
+ }
242
+ return max;
243
+ }
244
+
231
245
  /** Canonical identity for a state (rows are kept sorted). NUL-joined so
232
246
  * multi-word terms can never collide with the separator; spelled without an
233
247
  * escape sequence because tooling has twice turned a source-level \\0 into a
@@ -19,6 +19,11 @@ import { VOCAB_WORDS, eligibleForCanon, fuzzyVocabWord } from "../fuzzy.mjs";
19
19
  const PASSIVE_AUX = new Set(["is", "are", "was", "were", "be", "been", "being", "get", "gets", "got"]);
20
20
  const WH_WORDS = new Set(["which", "what", "who", "whom", "whose"]);
21
21
  const PLACEHOLDER_SET = new Set(PLACEHOLDER_NOUNS.map((w) => w.toLowerCase()));
22
+ // A trailing time adverb on a bare passive ("was X touched RECENTLY") is not the
23
+ // relation's object — it modifies the whole clause. Only consulted on the
24
+ // participle path with no agent "by", so it can never touch an active-verb
25
+ // object slot. Mirrors ask.mjs's TEMPORAL_TRAIL_FILLER for the when-shape.
26
+ const TEMPORAL_TRAILING_ADVERBS = new Set(["recently", "lately", "yet", "already", "ever", "again"]);
22
27
  // See ask-vocab.mjs's own HAS_FAMILY_VERBS for why a bare have-family verb
23
28
  // never resolves to `defines` in this strategy's two-named-role "ask" shape
24
29
  // below (the forward/reverse branches keep their own tested grain-check
@@ -198,7 +203,14 @@ export function parseKeywordSpot(text, nlp = null) {
198
203
  .join(" ")
199
204
  .trim();
200
205
  const beforeText = sideText(0, verbHit.start);
201
- const afterText = sideText(verbHit.end, words.length);
206
+ let afterText = sideText(verbHit.end, words.length);
207
+ // A lone trailing time adverb after a bare participle ("was it touched
208
+ // recently") is clause-level, not the relation's object; drop it so the
209
+ // sentence reaches the bare-passive branch and answers over the patient
210
+ // instead of trying to resolve "recently" as a term.
211
+ if (verbFromParticiple && !lcWords.includes("by") && TEMPORAL_TRAILING_ADVERBS.has(afterText.toLowerCase())) {
212
+ afterText = "";
213
+ }
202
214
  const kind = verbHit.kind;
203
215
  // slices read canonWords, not lcWords: the entity/modifier spans were matched
204
216
  // against the canonicalized array, whose word IS the table key.
@@ -316,7 +328,11 @@ export function parseKeywordSpot(text, nlp = null) {
316
328
  // nobody asked, so this declines and the sentence misses honestly.
317
329
  if (beforeText.split(/\s+/).length > 1) return null;
318
330
  if (kind === "touches") return stamp({ shape: "when", entityType: null, modifier: "direct", kind, object: beforeText });
319
- return stamp({ shape: "reverse", entityType, modifier, kind, object: beforeText });
331
+ // A sentence that LEADS with the passive auxiliary is an interrogative
332
+ // yes/no ("is X used anywhere"), not a declarative patient statement; mark
333
+ // it so a single reverse match can render a confirming Yes frame.
334
+ const polar = PASSIVE_AUX.has(lcWords[0]);
335
+ return stamp({ shape: "reverse", entityType, modifier, kind, object: beforeText, ...(polar ? { polar: true } : {}) });
320
336
  }
321
337
  // forward keeps the spotted entityType (traverse()'s commit-as-subject grain
322
338
  // selection); modifier stays hardcoded since no forward closure traversal exists.