@polycode-projects/the-mechanical-code-talker 2.11.9 → 2.11.11

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@polycode-projects/the-mechanical-code-talker",
3
- "version": "2.11.9",
3
+ "version": "2.11.11",
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.",
@@ -0,0 +1,76 @@
1
+ // research-queue-store.mjs — persists the "research <topic>" queue as one JSON
2
+ // file under the repo's .tmct/ (research-queue.json, beside the memory dir), so
3
+ // a run started in one CLI session resumes in the next: "research next" steps
4
+ // the queue, "research status" reports it, "research stop" clears it, all
5
+ // across process restarts. The file is per-repo and machine-local (.tmct/ is
6
+ // gitignored), exactly like the graph/sqlite store it sits next to.
7
+ //
8
+ // The seam is the memoryDir backend token runTurn already carries:
9
+ // - a repo-path string (Backend A): the queue file is <repo>/.tmct/…;
10
+ // - a sqlite handle carrying dbPath (Backend C, the CLI default): the queue
11
+ // file is the dbPath's .tmct/ sibling;
12
+ // - an in-memory handle (Backend B) or null (the browser session): no path,
13
+ // so persistence is a silent no-op and the in-page queue behaves as before.
14
+ //
15
+ // Fail closed: an absent, unreadable, or invalid file reads as "no run" — never
16
+ // a crash, never a fabricated queue.
17
+
18
+ import { readFile, writeFile, mkdir, unlink } from "node:fs/promises";
19
+ import { dirname, join } from "node:path";
20
+
21
+ const QUEUE_FILE = "research-queue.json";
22
+
23
+ /** The on-disk path for `memoryDir`'s queue file, or null when this session has
24
+ * nowhere to persist (in-memory backend, or a browser session with no store).
25
+ * A repo-path string keys off <repo>/.tmct; a store handle keys off its own
26
+ * dbPath's .tmct/ sibling — any handle without a real dbPath has no home. */
27
+ function researchQueuePath(memoryDir) {
28
+ if (!memoryDir) return null;
29
+ if (typeof memoryDir === "string") return join(memoryDir, ".tmct", QUEUE_FILE);
30
+ if (typeof memoryDir.dbPath === "string") return join(dirname(dirname(memoryDir.dbPath)), QUEUE_FILE);
31
+ return null;
32
+ }
33
+
34
+ /** A parsed value is a resumable queue only if it carries the run identity and
35
+ * the three lists the lifecycle mutates. Anything else fails closed to null. */
36
+ function isQueueState(state) {
37
+ return !!state && typeof state === "object"
38
+ && typeof state.topic === "string"
39
+ && typeof state.key === "string"
40
+ && Array.isArray(state.pending)
41
+ && Array.isArray(state.done)
42
+ && Array.isArray(state.skipped);
43
+ }
44
+
45
+ /** The persisted queue for `memoryDir`, or null when none is stored, the store
46
+ * cannot persist, or the file is missing/corrupt/ill-shaped. */
47
+ export async function loadResearchQueue(memoryDir) {
48
+ const path = researchQueuePath(memoryDir);
49
+ if (!path) return null;
50
+ let raw;
51
+ try { raw = await readFile(path, "utf8"); } catch { return null; }
52
+ let state;
53
+ try { state = JSON.parse(raw); } catch { return null; }
54
+ return isQueueState(state) ? state : null;
55
+ }
56
+
57
+ /** Write-through the current queue. A null/absent state clears the file, so a
58
+ * stopped or completed-and-cleared run leaves nothing behind. A store with no
59
+ * path is a no-op. A write that fails leaves the in-memory queue standing. */
60
+ export async function saveResearchQueue(memoryDir, state) {
61
+ const path = researchQueuePath(memoryDir);
62
+ if (!path) return;
63
+ if (!state) { await clearResearchQueue(memoryDir); return; }
64
+ try {
65
+ await mkdir(dirname(path), { recursive: true });
66
+ await writeFile(path, JSON.stringify(state), "utf8");
67
+ } catch { /* a queue we can't persist stays in memory for this session */ }
68
+ }
69
+
70
+ /** Delete the persisted queue file (idempotent — an already-absent file is the
71
+ * cleared state we want). A store with no path is a no-op. */
72
+ export async function clearResearchQueue(memoryDir) {
73
+ const path = researchQueuePath(memoryDir);
74
+ if (!path) return;
75
+ try { await unlink(path); } catch { /* already gone */ }
76
+ }
@@ -641,7 +641,14 @@ export function parseImperative(sentence, lexicon = loadLexicon()) {
641
641
 
642
642
  if (verb === "look") {
643
643
  if (!rest.length || (rest.length === 1 && lower[0] === "around")) return command({});
644
- return null;
644
+ // "look <object>" — a bare noun after look reads as a close look at that
645
+ // thing, routed through the same object handler examine/talk share (which
646
+ // resolves presence and declines an absent thing by name). "look at <x>"
647
+ // never reaches here — its 2-token synonym prefix already resolved to
648
+ // examine — so this arm only ever sees the bare-noun form.
649
+ const object = imperativeNP(lexicon, rest);
650
+ if (object.term == null) return miss(object.unknown);
651
+ return command({ object: object.term });
645
652
  }
646
653
  if (verb === "examine" || verb === "talk") {
647
654
  if (!rest.length) return null;
@@ -682,6 +682,13 @@ ${THEME_TOKENS_CSS}
682
682
  underneath always names the real, specific thing (the cabinet, the
683
683
  butler) — chrome around honest content, never instead of it. */
684
684
  .sprite-card { display: flex; flex-direction: column; align-items: center; width: 70px; }
685
+ /* a room sprite the visitor can click for a lights-down close look — a
686
+ pointer cursor and a gilt ring on hover. Wall-mounted cards live in a
687
+ pointer-events: none band (so the band never eats a floor click), so a
688
+ clickable one re-enables its own pointer events. */
689
+ .sprite-card.clickable { cursor: pointer; }
690
+ .sprite-card.clickable:hover .sprite-frame { border-color: var(--gilt); box-shadow: inset 0 0 0 3px var(--parchment), 0 0 0 2px var(--gilt), 0 2px 3px rgba(0, 0, 0, .22); }
691
+ .wall-row .sprite-card.clickable { pointer-events: auto; }
685
692
  /* a squared inventory-slot tile (the 90s-RPG idiom) instead of the old
686
693
  thin circular ring: a raised card with an inner mat, the class color on
687
694
  the outer border, and a soft ground shadow so the tile sits ON the
@@ -759,6 +766,29 @@ ${THEME_TOKENS_CSS}
759
766
  .roommap .room-node.clickable:hover rect { stroke: var(--gilt); stroke-width: 2.5; }
760
767
  .roommap .room-node.selected rect { stroke: var(--alert); stroke-width: 2.5; }
761
768
 
769
+ /* the object lightbox — the SAME lights-down treatment the map lightbox
770
+ uses (fixed dimmed backdrop, z-index 60, close on backdrop click/Escape),
771
+ framed as a parchment case-file card: the clicked object's large sprite
772
+ (the sprites-page 400px tier, via the same resolveObjectSprite the room
773
+ cards use), its live "look <object>" reply, and an object-scoped chat
774
+ dock — its own affordance pills filtered to this object, plus free text.
775
+ Every dock turn runs through the ONE live session, so the main
776
+ transcript/quest/satchel reflect it. */
777
+ .obj-lightbox { position: fixed; inset: 0; z-index: 60; display: flex; align-items: center; justify-content: center; padding: 2.4rem; background: rgba(10, 8, 4, .74); }
778
+ .obj-lightbox[hidden] { display: none; }
779
+ .obj-lightbox-inner { width: min(92vw, 560px); max-height: 88vh; overflow-y: auto; background: var(--parchment); color: var(--ink); border: 3px solid var(--gilt); box-shadow: inset 0 0 0 2px var(--parchment-strong), 0 12px 48px rgba(0, 0, 0, .5); padding: 1.1rem 1.2rem 1.2rem; box-sizing: border-box; }
780
+ .obj-title { font-family: ${SERIF_STACK}; font-variant: small-caps; font-size: 1rem; letter-spacing: .06em; color: var(--gilt); font-weight: 600; margin: 0 0 .6rem; padding-bottom: .3rem; border-bottom: 1px solid var(--line); }
781
+ .obj-scene { display: flex; align-items: center; justify-content: center; padding: .5rem 0 1rem; }
782
+ .obj-sprite { width: min(46vmin, 300px); height: min(46vmin, 300px); }
783
+ .obj-sprite svg { width: 100%; height: 100%; display: block; }
784
+ .obj-look { font-size: .9rem; line-height: 1.45; white-space: pre-wrap; background: var(--card); border-left: 3px solid var(--gilt); padding: .55rem .7rem; margin: 0 0 .8rem; }
785
+ .obj-look:empty { display: none; }
786
+ .docklog { display: flex; flex-direction: column; gap: .4rem; max-height: 200px; overflow-y: auto; margin-bottom: .5rem; }
787
+ .docklog:empty { display: none; }
788
+ .docklog .u { font-family: ${MONO_STACK}; font-size: .74rem; color: var(--muted); }
789
+ .docklog .u::before { content: "tmct> "; color: var(--taught); }
790
+ .docklog .a { font-size: .86rem; line-height: 1.4; white-space: pre-wrap; }
791
+
762
792
  .goal-status { display: flex; flex-direction: column; gap: .4rem; }
763
793
  .goal-status .g { display: flex; align-items: baseline; gap: .4rem; font-size: .86rem; line-height: 1.35; }
764
794
  .goal-status .dot { width: .5rem; height: .5rem; border-radius: 50%; flex: none; background: var(--muted); }
@@ -890,6 +920,19 @@ ${THEME_TOKENS_CSS}
890
920
  <div class="map-lightbox" id="mapLightbox" role="dialog" aria-modal="true" aria-label="The manor map, enlarged" hidden>
891
921
  <div class="map-lightbox-inner roommap" id="mapLightboxInner"></div>
892
922
  </div>
923
+ <div class="obj-lightbox" id="objLightbox" role="dialog" aria-modal="true" aria-label="A closer look at an object" hidden>
924
+ <div class="obj-lightbox-inner" id="objLightboxInner">
925
+ <h2 class="obj-title" id="objTitle"></h2>
926
+ <div class="obj-scene" id="objScene"></div>
927
+ <div class="obj-look" id="objLook"></div>
928
+ <div class="pills" id="objPills"></div>
929
+ <div class="docklog" id="objDockLog" aria-live="polite"></div>
930
+ <form class="chatask" id="objForm">
931
+ <span class="prompt mono">tmct&gt;</span>
932
+ <input id="objInput" type="text" placeholder="examine it, take it&hellip;" aria-label="Type a command for this object">
933
+ </form>
934
+ </div>
935
+ </div>
893
936
 
894
937
  <div class="stage editor-stage" id="editStage" aria-label="The world editor">
895
938
  <div class="panel edittext">
@@ -960,6 +1003,14 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
960
1003
  const mapViewportEl = el("mapViewport");
961
1004
  const mapLightboxEl = el("mapLightbox");
962
1005
  const mapLightboxInnerEl = el("mapLightboxInner");
1006
+ const objLightboxEl = el("objLightbox");
1007
+ const objTitleEl = el("objTitle");
1008
+ const objSceneEl = el("objScene");
1009
+ const objLookEl = el("objLook");
1010
+ const objPillsEl = el("objPills");
1011
+ const objDockLogEl = el("objDockLog");
1012
+ const objFormEl = el("objForm");
1013
+ const objInputEl = el("objInput");
963
1014
  const goalListEl = el("goalList");
964
1015
  const editModeBtn = el("editModeBtn");
965
1016
  const editorTextEl = el("editorText");
@@ -980,6 +1031,7 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
980
1031
  let lastTicks = 0;
981
1032
  let lastSnapshot = null;
982
1033
  let selectedRoomId = null;
1034
+ let objLightboxSubject = null;
983
1035
  let editRows = [];
984
1036
  let editState = { placements: new Map(), openness: new Map(), exits: new Map() };
985
1037
  let allStoreRows = [];
@@ -1058,8 +1110,15 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
1058
1110
  // the caption text — is never replaced by it.
1059
1111
  const CLASS_BADGE = { adventurer: "hero", person: "townsfolk", container: "fixture", furniture: "fixture", portable: "item", room: "room" };
1060
1112
  const badgeFor = (cls) => CLASS_BADGE[cls] || cls;
1061
- function spriteCardHtml(label, cls, svg) {
1062
- return '<div class="sprite-card"><div class="sprite-frame" data-cls="' + esc(cls) + '"><div class="sprite" data-cls="' + esc(cls) + '">' + svg + '</div></div>'
1113
+ // A truthy subject marks the card clickable and tags it with the object it
1114
+ // names, so the room frame's own delegated handler can open the object
1115
+ // lightbox for it. The player's own "you" card and the edit-mode / legend /
1116
+ // satchel cards pass none, so only real room props are clickable.
1117
+ function clickAttrs(subject) {
1118
+ return subject ? ' clickable" data-look-subject="' + esc(subject) + '"' : '"';
1119
+ }
1120
+ function spriteCardHtml(label, cls, svg, subject) {
1121
+ return '<div class="sprite-card' + clickAttrs(subject) + '><div class="sprite-frame" data-cls="' + esc(cls) + '"><div class="sprite" data-cls="' + esc(cls) + '">' + svg + '</div></div>'
1063
1122
  + '<div class="sprite-label">' + esc(label) + '</div>'
1064
1123
  + '<div class="class-badge" data-cls="' + esc(cls) + '">' + esc(badgeFor(cls)) + "</div></div>";
1065
1124
  }
@@ -1073,8 +1132,8 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
1073
1132
  // spriteCardHtml identification; every item resting on something else
1074
1133
  // shows an aria-label in its place, so the name is still available to
1075
1134
  // assistive tech even though the sighted layout stays compact.
1076
- function stackedSpriteCardHtml(label, cls, svg) {
1077
- return '<div class="sprite-card" aria-label="' + esc(label) + '"><div class="sprite-frame" data-cls="' + esc(cls) + '"><div class="sprite" data-cls="' + esc(cls) + '">' + svg + "</div></div></div>";
1135
+ function stackedSpriteCardHtml(label, cls, svg, subject) {
1136
+ return '<div class="sprite-card' + clickAttrs(subject) + ' aria-label="' + esc(label) + '"><div class="sprite-frame" data-cls="' + esc(cls) + '"><div class="sprite" data-cls="' + esc(cls) + '">' + svg + "</div></div></div>";
1078
1137
  }
1079
1138
 
1080
1139
  function captionFor(rows, state, here) {
@@ -1174,6 +1233,97 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
1174
1233
  mapLightboxEl.addEventListener("click", (e) => { if (e.target === mapLightboxEl) closeMapLightbox(); });
1175
1234
  document.addEventListener("keydown", (e) => { if (e.key === "Escape" && !mapLightboxEl.hidden) closeMapLightbox(); });
1176
1235
 
1236
+ // ---- the object lightbox — clicking a room sprite opens a lights-down
1237
+ // close look at that thing (the SAME lights-down/backdrop/Escape pattern as
1238
+ // the map lightbox): its large sprite, its live "look <object>" reply
1239
+ // (adventure.mjs's grounded look, run through THIS session), and an
1240
+ // object-scoped chat dock. The dock's pills are the room's own affordance
1241
+ // list filtered to this object (pillsFor, the same source the main hint
1242
+ // pills read), plus a free-text input. Every dock turn runs through the ONE
1243
+ // live session, echoes into the MAIN transcript and redraws the main page,
1244
+ // so the satchel/quest/room all reflect it after close — no forked state.
1245
+ function addObjDockLine(cls, html) {
1246
+ const d = document.createElement("div");
1247
+ d.className = cls; d.innerHTML = html;
1248
+ objDockLogEl.appendChild(d); objDockLogEl.scrollTop = objDockLogEl.scrollHeight;
1249
+ }
1250
+ function objectPillsFor(rows, state, here, subject) {
1251
+ return pillsFor(rows, state, here).filter((a) => a.split(" ").pop() === subject);
1252
+ }
1253
+ function renderObjScene() {
1254
+ if (!lastSnapshot || !objLightboxSubject) { objSceneEl.innerHTML = ""; return; }
1255
+ const cls = spriteClassForObject(lastSnapshot.rows, objLightboxSubject);
1256
+ const svg = resolveObjectSprite(lastSnapshot.rows, { subject: objLightboxSubject, spriteClass: cls });
1257
+ objSceneEl.innerHTML = '<div class="obj-sprite" data-cls="' + esc(cls) + '">' + svg + "</div>";
1258
+ }
1259
+ function renderObjPills() {
1260
+ if (!lastSnapshot || !objLightboxSubject) { objPillsEl.innerHTML = ""; return; }
1261
+ const actions = objectPillsFor(lastSnapshot.rows, lastSnapshot.state, lastSnapshot.here, objLightboxSubject);
1262
+ objPillsEl.innerHTML = actions.map((a) => '<button type="button" class="pill">' + esc(a) + "</button>").join("");
1263
+ }
1264
+ // A dock turn: paused, echoed into the main transcript AND the dock, run on
1265
+ // the one session, then the main page and the lightbox both redraw off the
1266
+ // fresh snapshot — the board stays open.
1267
+ function runObjTurn(line) {
1268
+ ticker.pause();
1269
+ addChatLine("u", esc(line));
1270
+ addObjDockLine("u", esc(line));
1271
+ return withLock(async () => {
1272
+ const result = await session.turn(line);
1273
+ addChatLine("a", esc(result.answer).replace(/\\n/g, "<br>"));
1274
+ addObjDockLine("a", esc(result.answer).replace(/\\n/g, "<br>"));
1275
+ const snap = await session.snapshot();
1276
+ redraw(snap);
1277
+ renderObjScene();
1278
+ renderObjPills();
1279
+ });
1280
+ }
1281
+ async function openObjectLightbox(subject) {
1282
+ if (!session || !lastSnapshot) return;
1283
+ ticker.pause();
1284
+ objLightboxSubject = subject;
1285
+ objTitleEl.textContent = "the " + subject;
1286
+ objDockLogEl.innerHTML = "";
1287
+ objLookEl.textContent = "";
1288
+ objInputEl.value = "";
1289
+ renderObjScene();
1290
+ renderObjPills();
1291
+ objLightboxEl.hidden = false;
1292
+ objInputEl.focus();
1293
+ // The initial look is a real, read-only turn on the live session (it writes
1294
+ // nothing, so it needs no main-transcript echo — it IS the board's own
1295
+ // content); pill/typed turns below do echo, since they can change state.
1296
+ await withLock(async () => {
1297
+ const result = await session.turn("look " + subject);
1298
+ objLookEl.textContent = result.answer;
1299
+ });
1300
+ }
1301
+ function closeObjectLightbox() {
1302
+ objLightboxEl.hidden = true;
1303
+ objLightboxSubject = null;
1304
+ objDockLogEl.innerHTML = "";
1305
+ objInputEl.value = "";
1306
+ }
1307
+ roomFrameEl.addEventListener("click", (e) => {
1308
+ const card = e.target.closest(".sprite-card[data-look-subject]");
1309
+ if (!card) return;
1310
+ openObjectLightbox(card.getAttribute("data-look-subject"));
1311
+ });
1312
+ objPillsEl.addEventListener("click", (e) => {
1313
+ const btn = e.target.closest(".pill");
1314
+ if (!btn) return;
1315
+ runObjTurn(btn.textContent);
1316
+ });
1317
+ objFormEl.addEventListener("submit", (e) => {
1318
+ e.preventDefault();
1319
+ const q = objInputEl.value.trim();
1320
+ if (!q || !session) return;
1321
+ objInputEl.value = "";
1322
+ runObjTurn(q);
1323
+ });
1324
+ objLightboxEl.addEventListener("click", (e) => { if (e.target === objLightboxEl) closeObjectLightbox(); });
1325
+ document.addEventListener("keydown", (e) => { if (e.key === "Escape" && !objLightboxEl.hidden) closeObjectLightbox(); });
1326
+
1177
1327
  function renderEditMap(rows, state) {
1178
1328
  editMapWrapEl.innerHTML = roomMapSvg(visitedRoomGraph(state, allRoomIds(rows)), true) || '<span class="empty-note">this world defines no rooms</span>';
1179
1329
  }
@@ -1268,12 +1418,12 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
1268
1418
  function redraw(snap) {
1269
1419
  lastSnapshot = snap;
1270
1420
  const layout = roomSceneLayout(snap.rows, snap.state, snap.here);
1271
- wallRowEl.innerHTML = layout.wall.map((s) => spriteCardHtml(s.subject, s.spriteClass, resolveObjectSprite(snap.rows, s))).join("");
1421
+ wallRowEl.innerHTML = layout.wall.map((s) => spriteCardHtml(s.subject, s.spriteClass, resolveObjectSprite(snap.rows, s), s.subject)).join("");
1272
1422
  floorRowEl.innerHTML = layout.floor.map((stack) => {
1273
1423
  const baseIndex = stack.items.length - 1;
1274
1424
  return '<div class="sprite-stack">' + stack.items.map((s, i) => {
1275
1425
  const svg = resolveObjectSprite(snap.rows, s);
1276
- return i === baseIndex ? spriteCardHtml(s.subject, s.spriteClass, svg) : stackedSpriteCardHtml(s.subject, s.spriteClass, svg);
1426
+ return i === baseIndex ? spriteCardHtml(s.subject, s.spriteClass, svg, s.subject) : stackedSpriteCardHtml(s.subject, s.spriteClass, svg, s.subject);
1277
1427
  }).join("") + "</div>";
1278
1428
  }).join("");
1279
1429
  youSlotEl.innerHTML = spriteCardHtml("you", "adventurer", resolveObjectSprite(snap.rows, { subject: "you", spriteClass: "adventurer" }));