@polycode-projects/the-mechanical-code-talker 2.11.10 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@polycode-projects/the-mechanical-code-talker",
3
- "version": "2.11.10",
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" }));
@@ -556,6 +556,44 @@ export function worldDigestRows(rows, state) {
556
556
  return out;
557
557
  }
558
558
 
559
+ /** The physical-property lines a close "look <object>" states: the object's
560
+ * own world facts, phrased through the SAME worldDigestRows view a room look
561
+ * reads (so mgx:knows-*, the NPC schedule, is-objective and the rest of the
562
+ * puzzle wiring are already excluded there), minus its bare rdf:type line —
563
+ * the class hierarchy renders that as its own is-a chain instead. A carried
564
+ * object surfaces through the "carries the" line the digest already produces.
565
+ * Pure. */
566
+ export function objectLookProperties(rows, state, object) {
567
+ const subjectCased = sentenceCase(object);
568
+ return worldDigestRows(rows, state)
569
+ .filter((r) => (r.subject === subjectCased && r.predicate !== "is a" && r.predicate !== "is an")
570
+ || (r.predicate === "carries the" && r.object === object))
571
+ .map((r) => `${r.subject} ${r.predicate} ${r.object}.`);
572
+ }
573
+
574
+ /** An object's class hierarchy as an is-a chain, nearest-first and opening
575
+ * with the object itself ("housekeeper → person"): a breadth-first walk up
576
+ * the world's OWN rdf:type and rdfs:subClassOf edges (worldActionRows, so a
577
+ * merged corpus's taxonomy for the same word never joins the chain), the same
578
+ * upward-class rendering chat's "what do you know about X" shows. Pure. */
579
+ export function objectClassChain(rows, object) {
580
+ const worldRows = worldActionRows(rows);
581
+ const parentsOf = (node) => worldRows
582
+ .filter((r) => r.subject === node && (r.predicate === "rdf:type" || r.predicate === "rdfs:subClassOf"))
583
+ .map((r) => r.object);
584
+ const seen = new Set([object]);
585
+ const chain = [object];
586
+ const queue = [...parentsOf(object)];
587
+ while (queue.length) {
588
+ const node = queue.shift();
589
+ if (seen.has(node)) continue;
590
+ seen.add(node);
591
+ chain.push(node);
592
+ queue.push(...parentsOf(node));
593
+ }
594
+ return chain;
595
+ }
596
+
559
597
  async function worldDigest(prompt, { memoryDir, memory, rows, state, graph }) {
560
598
  const view = worldDigestRows(rows, state);
561
599
  const store = {
@@ -689,7 +727,7 @@ async function runWorldCommand(cmd, { world, memoryDir, env, graph, cache }) {
689
727
  );
690
728
  }
691
729
 
692
- if (cmd.verb === "look") {
730
+ if (cmd.verb === "look" && !cmd.object) {
693
731
  const digest = await worldDigest(here, { memoryDir, memory, rows, state, graph });
694
732
  const actions = roomAffordances(rows, state, here);
695
733
  return answer(
@@ -699,13 +737,13 @@ async function runWorldCommand(cmd, { world, memoryDir, env, graph, cache }) {
699
737
  );
700
738
  }
701
739
 
702
- if (cmd.verb === "examine" || cmd.verb === "talk") {
740
+ if (cmd.verb === "examine" || cmd.verb === "talk" || cmd.verb === "look") {
703
741
  const object = cmd.object;
704
742
  // A carried object has no room to be "visible in" (visibleRoomOf returns
705
- // null for anything held by the player) — examine still applies to it,
706
- // the same way "what am I carrying" already reads inventory contents.
743
+ // null for anything held by the player) — examine and look still apply to
744
+ // it, the same way "what am I carrying" already reads inventory contents.
707
745
  // talk has no carried exception: NPCs are never portable.
708
- const carried = cmd.verb === "examine" && carriedByPlayer(state, object);
746
+ const carried = (cmd.verb === "examine" || cmd.verb === "look") && carriedByPlayer(state, object);
709
747
  // The room the player is standing in is never the SUBJECT of a placement
710
748
  // fact (only ever the OBJECT other things are placed in), so
711
749
  // visibleRoomOf(object) can never equal `here` for a room's own name —
@@ -730,7 +768,7 @@ async function runWorldCommand(cmd, { world, memoryDir, env, graph, cache }) {
730
768
  { miss: true },
731
769
  );
732
770
  }
733
- if (notHere && !(cmd.verb === "examine" && backgroundOnlyMention(rows, state, object))) {
771
+ if (notHere && !((cmd.verb === "examine" || cmd.verb === "look") && backgroundOnlyMention(rows, state, object))) {
734
772
  return answer(
735
773
  `I don't see a ${object} here.`,
736
774
  noteFor(`${cmd.verb} — ${object} isn't visible in the ${here}; declined, hidden things stay hidden`),
@@ -738,6 +776,27 @@ async function runWorldCommand(cmd, { world, memoryDir, env, graph, cache }) {
738
776
  );
739
777
  }
740
778
  const person = isTyped(rows, object, "person");
779
+ // "look <object>" on a real placed prop is the grounded close look: every
780
+ // physical fact the world writes about the thing (its placement, its
781
+ // within-room position, any datatype property — all via the SAME
782
+ // worldDigestRows view that already drops the puzzle wiring and the
783
+ // staff-knowledge pointers), plus its class hierarchy as an is-a chain,
784
+ // plus a container's open/locked state. A background-only mention has no
785
+ // placed facts of its own, so it falls through to the examine digest below
786
+ // (the same general-knowledge answer "what is a flower" gives).
787
+ if (cmd.verb === "look" && !backgroundOnlyMention(rows, state, object)) {
788
+ const propLines = objectLookProperties(rows, state, object);
789
+ const chain = objectClassChain(rows, object);
790
+ const parts = [`you look closely at the ${object}.`];
791
+ if (propLines.length) parts.push(propLines.join(" "));
792
+ if (chain.length > 1) parts.push(`Class: ${chain.join(" → ")}.`);
793
+ if (!person && isContainer(rows, object)) parts.push(containerStatusPhrase(object, { state }));
794
+ return answer(
795
+ parts.join(" "),
796
+ noteFor(`look at ${object} — its world-fact properties (via worldDigestRows, knows-*/puzzle-wiring excluded) and its rdf:type/subClassOf is-a chain`),
797
+ { goal: `take a closer look at the ${object}` },
798
+ );
799
+ }
741
800
  // Talking to a person is the game's reveal channel: the staff share what
742
801
  // they know (a hiding place, the quest, a topic) and report their own
743
802
  // room, all resolved from the live fold this turn.
@@ -52,7 +52,8 @@ import {
52
52
  } from "../domain/reference-pack.mjs";
53
53
  import { getReferencePackProvider } from "../adapters/corpus/reference-pack.mjs";
54
54
  import { getLiveReferenceProvider, getResearchProvider } from "../adapters/corpus/wikipedia-live.mjs";
55
- import { researchTurn, researchSnapshot, resolveResearchConfig, RESEARCH_DEFAULTS } from "./research.mjs";
55
+ import { researchTurn, researchSnapshot, resolveResearchConfig, RESEARCH_DEFAULTS, parseResearchRequest } from "./research.mjs";
56
+ import { loadResearchQueue, saveResearchQueue } from "../adapters/research-queue-store.mjs";
56
57
  import { CHILD_PACK_NAME, childProvenanceTag } from "../domain/child-pack.mjs";
57
58
  import { getChildPackProvider } from "../adapters/corpus/child-pack.mjs";
58
59
  import { dialogueActForLane } from "../domain/dialogue-acts.mjs";
@@ -14548,7 +14549,16 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
14548
14549
  // behind /wiki on. Queue state threads turn-to-turn as researchState, the
14549
14550
  // same way planState does.
14550
14551
  {
14551
- const researchHolder = { state: researchState };
14552
+ // A fresh CLI session carries no in-memory queue, so a research-family line
14553
+ // arriving with none resumes the queue persisted under .tmct/ — that is
14554
+ // what makes "research next"/"status"/"stop" work across process restarts.
14555
+ // The gate keeps ordinary turns off the disk (only a parsed research line
14556
+ // loads), and a store with no path (the browser) simply reads back null.
14557
+ let priorResearchState = researchState;
14558
+ if (!priorResearchState && parseResearchRequest(workingLine)) {
14559
+ priorResearchState = await loadResearchQueue(memoryDir);
14560
+ }
14561
+ const researchHolder = { state: priorResearchState };
14552
14562
  const resolvedResearchConfig = researchConfig ?? RESEARCH_DEFAULTS;
14553
14563
  const rTurn = await researchTurn(workingLine, {
14554
14564
  holder: researchHolder,
@@ -14569,6 +14579,10 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
14569
14579
  result.lane = "research";
14570
14580
  const snapshot = researchSnapshot(researchHolder.state);
14571
14581
  if (snapshot) result.record.research = snapshot;
14582
+ // Write-through: persist the queue this turn just mutated (start, next,
14583
+ // skip), and clear the file when it ended (stop, or a failed start that
14584
+ // left no run). A store with no path no-ops, so the browser is untouched.
14585
+ await saveResearchQueue(memoryDir, researchHolder.state);
14572
14586
  const rec = withLast(result, rTurn.goal);
14573
14587
  rec.planState = planHolder.state;
14574
14588
  rec.researchState = researchHolder.state;