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

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.12",
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
+ }
@@ -129,8 +129,9 @@ export async function normalizeConfig(raw, { configDir } = {}) {
129
129
 
130
130
  // Research-lane knobs (src/services/research.mjs): sparse PASS-THROUGH,
131
131
  // same discipline as [games.*] — the raw `[research]` table
132
- // (fanout_limit / depth_limit / min_interval_ms, snake_case) rides through
133
- // unmodified; clamping and default-filling is resolveResearchConfig's job.
132
+ // (fanout_limit / max_depth / max_topics / min_interval_ms, snake_case)
133
+ // rides through unmodified; clamping and default-filling is
134
+ // resolveResearchConfig's job.
134
135
  if (src.research !== undefined) cfg.research = src.research;
135
136
 
136
137
  const idx = src.index || {};
@@ -528,6 +528,15 @@ export function parseAce(sentence, lexicon = loadLexicon()) {
528
528
  const IMPERATIVE_VERBS = new Set(["go", "take", "drop", "open", "unlock", "close", "give", "look", "talk", "examine"]);
529
529
  const IMPERATIVE_DIRECTIONS = new Set(["north", "south", "east", "west", "up", "down"]);
530
530
 
531
+ // The object pronouns an imperative object slot may carry ("examine it", "take
532
+ // them", "talk to him"). This parser only MARKS such a slot with the bare
533
+ // pronoun as its term — the antecedent lives in the running world, not the
534
+ // sentence, so binding it to a concrete object is the adventure lane's job (it
535
+ // alone holds the session's FOCUS). Kept out of resolveNP's lexicon gate on
536
+ // purpose: a pronoun is never a declared noun, so without this it rides out as
537
+ // residue and mis-declines as an unknown word.
538
+ export const OBJECT_PRONOUNS = new Set(["it", "them", "him", "her"]);
539
+
531
540
  const VERB_SYNONYMS = new Map([
532
541
  ["pick up", "take"], ["pick", "take"], ["grab", "take"],
533
542
  ["put down", "drop"], ["set down", "drop"], ["leave", "drop"],
@@ -603,8 +612,14 @@ function resolveImperativeVerb(toks) {
603
612
  return { ...retried, corrected: { from: first, to: fixedFirst } };
604
613
  }
605
614
 
606
- /** Resolve one imperative object phrase to its bare lexicon term. */
615
+ /** Resolve one imperative object phrase to its bare lexicon term. A lone
616
+ * object pronoun ("it", "them", "him", "her") rides through as its own term
617
+ * for the lane to bind against the session focus — never a lexicon lookup,
618
+ * never residue. */
607
619
  function imperativeNP(lexicon, tokens) {
620
+ if (tokens.length === 1 && OBJECT_PRONOUNS.has(tokens[0].toLowerCase())) {
621
+ return { term: tokens[0].toLowerCase(), unknown: [] };
622
+ }
608
623
  const np = resolveNP(lexicon, tokens);
609
624
  if (np.term == null) return { term: null, unknown: np.unknown };
610
625
  return { term: local(lexicon, np.term), unknown: [] };
@@ -641,7 +656,14 @@ export function parseImperative(sentence, lexicon = loadLexicon()) {
641
656
 
642
657
  if (verb === "look") {
643
658
  if (!rest.length || (rest.length === 1 && lower[0] === "around")) return command({});
644
- return null;
659
+ // "look <object>" — a bare noun after look reads as a close look at that
660
+ // thing, routed through the same object handler examine/talk share (which
661
+ // resolves presence and declines an absent thing by name). "look at <x>"
662
+ // never reaches here — its 2-token synonym prefix already resolved to
663
+ // examine — so this arm only ever sees the bare-noun form.
664
+ const object = imperativeNP(lexicon, rest);
665
+ if (object.term == null) return miss(object.unknown);
666
+ return command({ object: object.term });
645
667
  }
646
668
  if (verb === "examine" || verb === "talk") {
647
669
  if (!rest.length) return null;
@@ -428,6 +428,20 @@ export function pillsForRoom(rows, state, here) {
428
428
  return roomAffordances(rows, state, here);
429
429
  }
430
430
 
431
+ /** The chat input's grounding placeholder, built from an affordance list: the
432
+ * first couple of OBJECT commands (examine/take/open/unlock/talk to <thing>)
433
+ * spelled with the room's real props, so the empty input teaches the grounded
434
+ * noun form ("examine lamp, take letter…") rather than a bare pronoun the
435
+ * player has no antecedent for yet. `fallback` stands in when the list holds
436
+ * no object command (a room with only exits, or a just-emptied object dock).
437
+ * Pure and `.toString()`-splice-safe — the page splices it in and drives both
438
+ * input docks off it every redraw. */
439
+ export function groundedPlaceholder(actions, fallback) {
440
+ const objectActions = (actions || []).filter((a) => /^(?:examine|take|open|unlock|talk to) /.test(a));
441
+ if (!objectActions.length) return fallback;
442
+ return objectActions.slice(0, 2).join(", ") + "…";
443
+ }
444
+
431
445
  /** Edit mode's cursor-driven suggestion pills for one typed `term`: the
432
446
  * lateral SKOS neighbourhood (`relatedForTerm`'s own synonyms/related
433
447
  * concepts) plus the vertical rdfs:subClassOf ancestor chain
@@ -682,6 +696,13 @@ ${THEME_TOKENS_CSS}
682
696
  underneath always names the real, specific thing (the cabinet, the
683
697
  butler) — chrome around honest content, never instead of it. */
684
698
  .sprite-card { display: flex; flex-direction: column; align-items: center; width: 70px; }
699
+ /* a room sprite the visitor can click for a lights-down close look — a
700
+ pointer cursor and a gilt ring on hover. Wall-mounted cards live in a
701
+ pointer-events: none band (so the band never eats a floor click), so a
702
+ clickable one re-enables its own pointer events. */
703
+ .sprite-card.clickable { cursor: pointer; }
704
+ .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); }
705
+ .wall-row .sprite-card.clickable { pointer-events: auto; }
685
706
  /* a squared inventory-slot tile (the 90s-RPG idiom) instead of the old
686
707
  thin circular ring: a raised card with an inner mat, the class color on
687
708
  the outer border, and a soft ground shadow so the tile sits ON the
@@ -759,6 +780,29 @@ ${THEME_TOKENS_CSS}
759
780
  .roommap .room-node.clickable:hover rect { stroke: var(--gilt); stroke-width: 2.5; }
760
781
  .roommap .room-node.selected rect { stroke: var(--alert); stroke-width: 2.5; }
761
782
 
783
+ /* the object lightbox — the SAME lights-down treatment the map lightbox
784
+ uses (fixed dimmed backdrop, z-index 60, close on backdrop click/Escape),
785
+ framed as a parchment case-file card: the clicked object's large sprite
786
+ (the sprites-page 400px tier, via the same resolveObjectSprite the room
787
+ cards use), its live "look <object>" reply, and an object-scoped chat
788
+ dock — its own affordance pills filtered to this object, plus free text.
789
+ Every dock turn runs through the ONE live session, so the main
790
+ transcript/quest/satchel reflect it. */
791
+ .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); }
792
+ .obj-lightbox[hidden] { display: none; }
793
+ .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; }
794
+ .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); }
795
+ .obj-scene { display: flex; align-items: center; justify-content: center; padding: .5rem 0 1rem; }
796
+ .obj-sprite { width: min(46vmin, 300px); height: min(46vmin, 300px); }
797
+ .obj-sprite svg { width: 100%; height: 100%; display: block; }
798
+ .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; }
799
+ .obj-look:empty { display: none; }
800
+ .docklog { display: flex; flex-direction: column; gap: .4rem; max-height: 200px; overflow-y: auto; margin-bottom: .5rem; }
801
+ .docklog:empty { display: none; }
802
+ .docklog .u { font-family: ${MONO_STACK}; font-size: .74rem; color: var(--muted); }
803
+ .docklog .u::before { content: "tmct> "; color: var(--taught); }
804
+ .docklog .a { font-size: .86rem; line-height: 1.4; white-space: pre-wrap; }
805
+
762
806
  .goal-status { display: flex; flex-direction: column; gap: .4rem; }
763
807
  .goal-status .g { display: flex; align-items: baseline; gap: .4rem; font-size: .86rem; line-height: 1.35; }
764
808
  .goal-status .dot { width: .5rem; height: .5rem; border-radius: 50%; flex: none; background: var(--muted); }
@@ -890,6 +934,19 @@ ${THEME_TOKENS_CSS}
890
934
  <div class="map-lightbox" id="mapLightbox" role="dialog" aria-modal="true" aria-label="The manor map, enlarged" hidden>
891
935
  <div class="map-lightbox-inner roommap" id="mapLightboxInner"></div>
892
936
  </div>
937
+ <div class="obj-lightbox" id="objLightbox" role="dialog" aria-modal="true" aria-label="A closer look at an object" hidden>
938
+ <div class="obj-lightbox-inner" id="objLightboxInner">
939
+ <h2 class="obj-title" id="objTitle"></h2>
940
+ <div class="obj-scene" id="objScene"></div>
941
+ <div class="obj-look" id="objLook"></div>
942
+ <div class="pills" id="objPills"></div>
943
+ <div class="docklog" id="objDockLog" aria-live="polite"></div>
944
+ <form class="chatask" id="objForm">
945
+ <span class="prompt mono">tmct&gt;</span>
946
+ <input id="objInput" type="text" placeholder="examine lamp, take key&hellip;" aria-label="Type a command for this object">
947
+ </form>
948
+ </div>
949
+ </div>
893
950
 
894
951
  <div class="stage editor-stage" id="editStage" aria-label="The world editor">
895
952
  <div class="panel edittext">
@@ -932,6 +989,7 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
932
989
  const carriedItems = ${carriedItems.toString()};
933
990
  const visitedRoomGraph = ${visitedRoomGraph.toString()};
934
991
  const allRoomIds = ${allRoomIds.toString()};
992
+ const groundedPlaceholder = ${groundedPlaceholder.toString()};
935
993
  const spriteAncestryRows = ${spriteAncestryRows.toString()};
936
994
  const factsForSubject = ${factsForSubject.toString()};
937
995
  const renderWorldEditorText = ${renderWorldEditorText.toString()};
@@ -960,6 +1018,14 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
960
1018
  const mapViewportEl = el("mapViewport");
961
1019
  const mapLightboxEl = el("mapLightbox");
962
1020
  const mapLightboxInnerEl = el("mapLightboxInner");
1021
+ const objLightboxEl = el("objLightbox");
1022
+ const objTitleEl = el("objTitle");
1023
+ const objSceneEl = el("objScene");
1024
+ const objLookEl = el("objLook");
1025
+ const objPillsEl = el("objPills");
1026
+ const objDockLogEl = el("objDockLog");
1027
+ const objFormEl = el("objForm");
1028
+ const objInputEl = el("objInput");
963
1029
  const goalListEl = el("goalList");
964
1030
  const editModeBtn = el("editModeBtn");
965
1031
  const editorTextEl = el("editorText");
@@ -980,6 +1046,7 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
980
1046
  let lastTicks = 0;
981
1047
  let lastSnapshot = null;
982
1048
  let selectedRoomId = null;
1049
+ let objLightboxSubject = null;
983
1050
  let editRows = [];
984
1051
  let editState = { placements: new Map(), openness: new Map(), exits: new Map() };
985
1052
  let allStoreRows = [];
@@ -1058,8 +1125,15 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
1058
1125
  // the caption text — is never replaced by it.
1059
1126
  const CLASS_BADGE = { adventurer: "hero", person: "townsfolk", container: "fixture", furniture: "fixture", portable: "item", room: "room" };
1060
1127
  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>'
1128
+ // A truthy subject marks the card clickable and tags it with the object it
1129
+ // names, so the room frame's own delegated handler can open the object
1130
+ // lightbox for it. The player's own "you" card and the edit-mode / legend /
1131
+ // satchel cards pass none, so only real room props are clickable.
1132
+ function clickAttrs(subject) {
1133
+ return subject ? ' clickable" data-look-subject="' + esc(subject) + '"' : '"';
1134
+ }
1135
+ function spriteCardHtml(label, cls, svg, subject) {
1136
+ 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
1137
  + '<div class="sprite-label">' + esc(label) + '</div>'
1064
1138
  + '<div class="class-badge" data-cls="' + esc(cls) + '">' + esc(badgeFor(cls)) + "</div></div>";
1065
1139
  }
@@ -1073,8 +1147,8 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
1073
1147
  // spriteCardHtml identification; every item resting on something else
1074
1148
  // shows an aria-label in its place, so the name is still available to
1075
1149
  // 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>";
1150
+ function stackedSpriteCardHtml(label, cls, svg, subject) {
1151
+ 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
1152
  }
1079
1153
 
1080
1154
  function captionFor(rows, state, here) {
@@ -1174,6 +1248,100 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
1174
1248
  mapLightboxEl.addEventListener("click", (e) => { if (e.target === mapLightboxEl) closeMapLightbox(); });
1175
1249
  document.addEventListener("keydown", (e) => { if (e.key === "Escape" && !mapLightboxEl.hidden) closeMapLightbox(); });
1176
1250
 
1251
+ // ---- the object lightbox — clicking a room sprite opens a lights-down
1252
+ // close look at that thing (the SAME lights-down/backdrop/Escape pattern as
1253
+ // the map lightbox): its large sprite, its live "look <object>" reply
1254
+ // (adventure.mjs's grounded look, run through THIS session), and an
1255
+ // object-scoped chat dock. The dock's pills are the room's own affordance
1256
+ // list filtered to this object (pillsFor, the same source the main hint
1257
+ // pills read), plus a free-text input. Every dock turn runs through the ONE
1258
+ // live session, echoes into the MAIN transcript and redraws the main page,
1259
+ // so the satchel/quest/room all reflect it after close — no forked state.
1260
+ function addObjDockLine(cls, html) {
1261
+ const d = document.createElement("div");
1262
+ d.className = cls; d.innerHTML = html;
1263
+ objDockLogEl.appendChild(d); objDockLogEl.scrollTop = objDockLogEl.scrollHeight;
1264
+ }
1265
+ function objectPillsFor(rows, state, here, subject) {
1266
+ return pillsFor(rows, state, here).filter((a) => a.split(" ").pop() === subject);
1267
+ }
1268
+ function renderObjScene() {
1269
+ if (!lastSnapshot || !objLightboxSubject) { objSceneEl.innerHTML = ""; return; }
1270
+ const cls = spriteClassForObject(lastSnapshot.rows, objLightboxSubject);
1271
+ const svg = resolveObjectSprite(lastSnapshot.rows, { subject: objLightboxSubject, spriteClass: cls });
1272
+ objSceneEl.innerHTML = '<div class="obj-sprite" data-cls="' + esc(cls) + '">' + svg + "</div>";
1273
+ }
1274
+ function renderObjPills() {
1275
+ if (!lastSnapshot || !objLightboxSubject) { objPillsEl.innerHTML = ""; return; }
1276
+ const actions = objectPillsFor(lastSnapshot.rows, lastSnapshot.state, lastSnapshot.here, objLightboxSubject);
1277
+ objPillsEl.innerHTML = actions.map((a) => '<button type="button" class="pill">' + esc(a) + "</button>").join("");
1278
+ // Grounded to the open object itself ("take lamp…"), so the dock teaches
1279
+ // the noun form even though a bare pronoun now binds here too.
1280
+ objInputEl.placeholder = groundedPlaceholder(actions, "examine " + objLightboxSubject);
1281
+ }
1282
+ // A dock turn: paused, echoed into the main transcript AND the dock, run on
1283
+ // the one session, then the main page and the lightbox both redraw off the
1284
+ // fresh snapshot — the board stays open.
1285
+ function runObjTurn(line) {
1286
+ ticker.pause();
1287
+ addChatLine("u", esc(line));
1288
+ addObjDockLine("u", esc(line));
1289
+ return withLock(async () => {
1290
+ const result = await session.turn(line);
1291
+ addChatLine("a", esc(result.answer).replace(/\\n/g, "<br>"));
1292
+ addObjDockLine("a", esc(result.answer).replace(/\\n/g, "<br>"));
1293
+ const snap = await session.snapshot();
1294
+ redraw(snap);
1295
+ renderObjScene();
1296
+ renderObjPills();
1297
+ });
1298
+ }
1299
+ async function openObjectLightbox(subject) {
1300
+ if (!session || !lastSnapshot) return;
1301
+ ticker.pause();
1302
+ objLightboxSubject = subject;
1303
+ objTitleEl.textContent = "the " + subject;
1304
+ objDockLogEl.innerHTML = "";
1305
+ objLookEl.textContent = "";
1306
+ objInputEl.value = "";
1307
+ renderObjScene();
1308
+ renderObjPills();
1309
+ objLightboxEl.hidden = false;
1310
+ objInputEl.focus();
1311
+ // The initial look is a real, read-only turn on the live session (it writes
1312
+ // nothing, so it needs no main-transcript echo — it IS the board's own
1313
+ // content); pill/typed turns below do echo, since they can change state.
1314
+ await withLock(async () => {
1315
+ const result = await session.turn("look " + subject);
1316
+ objLookEl.textContent = result.answer;
1317
+ });
1318
+ }
1319
+ function closeObjectLightbox() {
1320
+ objLightboxEl.hidden = true;
1321
+ objLightboxSubject = null;
1322
+ objDockLogEl.innerHTML = "";
1323
+ objInputEl.value = "";
1324
+ }
1325
+ roomFrameEl.addEventListener("click", (e) => {
1326
+ const card = e.target.closest(".sprite-card[data-look-subject]");
1327
+ if (!card) return;
1328
+ openObjectLightbox(card.getAttribute("data-look-subject"));
1329
+ });
1330
+ objPillsEl.addEventListener("click", (e) => {
1331
+ const btn = e.target.closest(".pill");
1332
+ if (!btn) return;
1333
+ runObjTurn(btn.textContent);
1334
+ });
1335
+ objFormEl.addEventListener("submit", (e) => {
1336
+ e.preventDefault();
1337
+ const q = objInputEl.value.trim();
1338
+ if (!q || !session) return;
1339
+ objInputEl.value = "";
1340
+ runObjTurn(q);
1341
+ });
1342
+ objLightboxEl.addEventListener("click", (e) => { if (e.target === objLightboxEl) closeObjectLightbox(); });
1343
+ document.addEventListener("keydown", (e) => { if (e.key === "Escape" && !objLightboxEl.hidden) closeObjectLightbox(); });
1344
+
1177
1345
  function renderEditMap(rows, state) {
1178
1346
  editMapWrapEl.innerHTML = roomMapSvg(visitedRoomGraph(state, allRoomIds(rows)), true) || '<span class="empty-note">this world defines no rooms</span>';
1179
1347
  }
@@ -1219,6 +1387,10 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
1219
1387
  function renderPills(rows, state, here) {
1220
1388
  const actions = pillsFor(rows, state, here);
1221
1389
  pillsEl.innerHTML = actions.map((a) => '<button type="button" class="pill">' + esc(a) + "</button>").join("");
1390
+ // The empty input teaches the grounded noun form off the same affordance
1391
+ // list the pills read — real props from THIS room, so a first-time player
1392
+ // types "examine lamp", not a pronoun with nothing to bind to yet.
1393
+ chatqEl.placeholder = groundedPlaceholder(actions, "go north");
1222
1394
  }
1223
1395
  pillsEl.addEventListener("click", (e) => {
1224
1396
  const btn = e.target.closest(".pill");
@@ -1268,12 +1440,12 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
1268
1440
  function redraw(snap) {
1269
1441
  lastSnapshot = snap;
1270
1442
  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("");
1443
+ wallRowEl.innerHTML = layout.wall.map((s) => spriteCardHtml(s.subject, s.spriteClass, resolveObjectSprite(snap.rows, s), s.subject)).join("");
1272
1444
  floorRowEl.innerHTML = layout.floor.map((stack) => {
1273
1445
  const baseIndex = stack.items.length - 1;
1274
1446
  return '<div class="sprite-stack">' + stack.items.map((s, i) => {
1275
1447
  const svg = resolveObjectSprite(snap.rows, s);
1276
- return i === baseIndex ? spriteCardHtml(s.subject, s.spriteClass, svg) : stackedSpriteCardHtml(s.subject, s.spriteClass, svg);
1448
+ return i === baseIndex ? spriteCardHtml(s.subject, s.spriteClass, svg, s.subject) : stackedSpriteCardHtml(s.subject, s.spriteClass, svg, s.subject);
1277
1449
  }).join("") + "</div>";
1278
1450
  }).join("");
1279
1451
  youSlotEl.innerHTML = spriteCardHtml("you", "adventurer", resolveObjectSprite(snap.rows, { subject: "you", spriteClass: "adventurer" }));
@@ -9,7 +9,7 @@
9
9
  // share the slot.
10
10
 
11
11
  import { worldProvenanceTag } from "../domain/worlds-pack.mjs";
12
- import { parseImperative } from "../domain/grammar/ace.mjs";
12
+ import { parseImperative, OBJECT_PRONOUNS } from "../domain/grammar/ace.mjs";
13
13
  import { createCompletionsGraphAdapter } from "../domain/completions/graph-adapter.mjs";
14
14
  import { actionFamilies } from "../domain/router/taught.mjs";
15
15
  import { compileDomain, precondHolds, roleBinding } from "../domain/domain.mjs";
@@ -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.
@@ -1165,6 +1224,63 @@ function renderedImperativeCommand(cmd) {
1165
1224
  return parts.join(" ");
1166
1225
  }
1167
1226
 
1227
+ // ---- pronoun binding: the session focus ---------------------------------------
1228
+ //
1229
+ // A world command may name its object with a pronoun ("examine it", "take
1230
+ // them", "talk to him") instead of a noun. The antecedent is not in the
1231
+ // sentence — it's the last thing the player successfully acted on this
1232
+ // session, the FOCUS — so the parser leaves the pronoun bare (ace.mjs's
1233
+ // OBJECT_PRONOUNS) and the lane binds it here, through ONE seam that every
1234
+ // object-taking verb passes on its way to runWorldCommand. With no focus
1235
+ // standing, a pronoun gets an honest reference nudge, never the vocabulary
1236
+ // decline (a pronoun is a reference, not an unknown word).
1237
+
1238
+ const PRONOUN_SLOTS = ["object", "indirectObject", "instrument"];
1239
+
1240
+ const commandHasPronoun = (cmd) => PRONOUN_SLOTS.some((s) => cmd[s] && OBJECT_PRONOUNS.has(cmd[s]));
1241
+
1242
+ /** A pronoun command with no focus standing: the reference nudge, embedding a
1243
+ * real, actionable object from the current room when one is on show (else a
1244
+ * static example). Never the "I don't know the word" line — the vocabulary
1245
+ * misdiagnosis is unreachable for a pronoun. */
1246
+ async function noFocusPronounNudge(pronoun, { memoryDir }) {
1247
+ let example = null;
1248
+ try {
1249
+ const rows = readFactRows(await loadMemory(memoryDir));
1250
+ const state = foldWorldState(worldActionRows(rows));
1251
+ const here = state.placements.get("player")?.object ?? null;
1252
+ if (here) {
1253
+ for (const action of roomAffordances(rows, state, here)) {
1254
+ const m = action.match(/^(?:examine|take|open|unlock|talk to) (.+)$/);
1255
+ if (m) { example = m[1]; break; }
1256
+ }
1257
+ }
1258
+ } catch { /* no probe available — the static example carries the nudge */ }
1259
+ const eg = example ?? "lamp";
1260
+ return answer(
1261
+ `I'm not sure what "${pronoun}" refers to yet — name the thing, e.g. "examine ${eg}".`,
1262
+ `ADVENTURE — pronoun "${pronoun}" arrived with no focus standing; asked which thing it means, never the vocabulary decline`,
1263
+ { miss: true },
1264
+ );
1265
+ }
1266
+
1267
+ /** Bind any pronoun object/indirect/instrument slot to the session focus.
1268
+ * Returns `{ cmd }` with the pronouns rewritten to the focus term, or `{
1269
+ * nudge }` (the reference nudge) when a pronoun stands but no focus does. A
1270
+ * command with no pronoun passes straight through untouched. */
1271
+ async function bindPronouns(cmd, { focus, memoryDir }) {
1272
+ if (!commandHasPronoun(cmd)) return { cmd };
1273
+ if (!focus) {
1274
+ const pronoun = PRONOUN_SLOTS.map((s) => cmd[s]).find((v) => v && OBJECT_PRONOUNS.has(v));
1275
+ return { nudge: await noFocusPronounNudge(pronoun, { memoryDir }) };
1276
+ }
1277
+ const bound = { ...cmd };
1278
+ for (const s of PRONOUN_SLOTS) {
1279
+ if (bound[s] && OBJECT_PRONOUNS.has(bound[s])) bound[s] = focus;
1280
+ }
1281
+ return { cmd: bound };
1282
+ }
1283
+
1168
1284
  // ---- the lane ----------------------------------------------------------------
1169
1285
 
1170
1286
  /**
@@ -1233,9 +1349,18 @@ export async function adventureTurn(line, { planHolder, memoryDir, sessionId = "
1233
1349
  };
1234
1350
  }
1235
1351
  if (INVENTORY_RE.test(line)) return inventoryAnswer({ memoryDir, graph });
1236
- const cmd = parseImperative(line, lexicon ?? undefined);
1237
- if (cmd) {
1352
+ const parsed = parseImperative(line, lexicon ?? undefined);
1353
+ if (parsed) {
1354
+ const bound = await bindPronouns(parsed, { focus: adventure.focus, memoryDir });
1355
+ if (bound.nudge) return bound.nudge;
1356
+ const cmd = bound.cmd;
1238
1357
  const result = await runWorldCommand(cmd, { world: adventure.world, memoryDir, env, graph, cache });
1358
+ // The object a command SUCCESSFULLY named becomes the focus a later
1359
+ // pronoun binds to — so "look lamp" then "examine it" reads the lamp, and
1360
+ // "talk to housekeeper" makes "him"/"her" the housekeeper. A miss leaves
1361
+ // the standing focus untouched; a bare room look or a move carries no
1362
+ // object and so never disturbs it.
1363
+ if (!result.miss && cmd.object) adventure.focus = cmd.object;
1239
1364
  if (!cmd.corrected?.length) return result;
1240
1365
  // A fuzzy-repaired verb or direction still executes normally, but the
1241
1366
  // response says what it read the line as, so a genuine miss is never