@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 +1 -1
- package/src/adapters/research-queue-store.mjs +76 -0
- package/src/adapters/toml-config.mjs +3 -2
- package/src/domain/grammar/ace.mjs +24 -2
- package/src/services/adventure-viz.mjs +178 -6
- package/src/services/adventure.mjs +134 -9
- package/src/services/chat.mjs +23 -7
- package/src/services/extract-facts.mjs +127 -19
- package/src/services/research-viz.mjs +34 -6
- package/src/services/research.mjs +154 -44
- package/src/surfaces/web/memory-ask-browser.bundle.js +101 -101
- package/src/surfaces/web/research-browser-entry.mjs +12 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@polycode-projects/the-mechanical-code-talker",
|
|
3
|
-
"version": "2.11.
|
|
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 /
|
|
133
|
-
// unmodified; clamping and default-filling is
|
|
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
|
-
|
|
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></span>
|
|
946
|
+
<input id="objInput" type="text" placeholder="examine lamp, take key…" 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
|
-
|
|
1062
|
-
|
|
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
|
|
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
|
|
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
|
|
1237
|
-
if (
|
|
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
|