@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 +1 -1
- package/src/adapters/research-queue-store.mjs +76 -0
- package/src/domain/grammar/ace.mjs +8 -1
- package/src/services/adventure-viz.mjs +156 -6
- package/src/services/adventure.mjs +65 -6
- package/src/services/chat.mjs +16 -2
- package/src/services/extract-facts.mjs +127 -19
- package/src/surfaces/web/memory-ask-browser.bundle.js +105 -105
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.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
|
-
|
|
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></span>
|
|
932
|
+
<input id="objInput" type="text" placeholder="examine it, take it…" 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
|
-
|
|
1062
|
-
|
|
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
|
|
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
|
|
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.
|
package/src/services/chat.mjs
CHANGED
|
@@ -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
|
-
|
|
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;
|