@polycode-projects/the-mechanical-code-talker 2.10.0 → 2.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -157,6 +157,32 @@ npx tmct chat --prompt 'disk-1 rests on disk-2. disk-2 rests on disk-3.
157
157
 
158
158
  More on the game file and the planner under "Teach it a game" below.
159
159
 
160
+ ### The code explorer (desktop)
161
+
162
+ The same ledger UI, refocused on a code graph, also runs as a desktop app. It
163
+ reads a `graph.json` (or a repo's `.tmct/` folder), shows every import, call and
164
+ `contains` edge as a plain sentence around a focus symbol, and docks a live chat
165
+ over the same graph. A hint rail suggests the next question from what the graph
166
+ actually holds — "what does X import", "which functions call Y", "list
167
+ functions" — so every suggestion resolves to a real answer.
168
+
169
+ Electron is a dev-only dependency and never ships in the npm package. Because
170
+ `.npmrc` sets `ignore-scripts=true`, installing it does not fetch the runtime
171
+ binary; fetch it once, then build and launch:
172
+
173
+ ```bash skip=network
174
+ npm i -D electron
175
+ node node_modules/electron/install.js # fetch the Electron binary (ignore-scripts skips this)
176
+ npm run build:electron # render electron/renderer/ from the demo graph
177
+ npm run electron # open the code explorer on the demo graph
178
+ ```
179
+
180
+ Open a graph or a repo from the window's title bar to explore your own code.
181
+ The UI is channel-agnostic — only the Electron shell (`electron/main.mjs` +
182
+ `electron/preload.cjs`) is desktop-specific; the same page stays servable as a
183
+ plain web page. `npm run test:electron` runs the shell smoke via Playwright and
184
+ skips cleanly when the binary is absent.
185
+
160
186
  ## How it interprets you
161
187
 
162
188
  Every message runs through **multiple concurrent interpretation strategies**:
@@ -498,6 +524,13 @@ nudges you to ground one side first. Quantified teaching stores the
498
524
  quantifier ("some functions are risky" … "how many functions are risky" →
499
525
  "A few."), and "how many facts are there" counts the store back.
500
526
 
527
+ The store answers about its own contents directly. "list facts" and "list
528
+ utterances" enumerate what it holds; "how many sessions are there", "how many
529
+ sources", and "how many rules" count the store's own book-keeping classes
530
+ rather than the code graph. A taught class answers both shapes too: after
531
+ "dog is a kind of animal", "how many animals are there" counts its members and
532
+ "list all animals" reads them back, each cited to where it came from.
533
+
501
534
  Teaching doesn't have to be typed, either. `tmct extract` runs a plain text
502
535
  file through the same recognizer the chat's teach lane uses. Sentences the
503
536
  recognizer grounds become fact rows; everything else is skipped and counted,
@@ -616,6 +649,9 @@ Usage:
616
649
  tmct memory [--repo <abs>] what tmct remembers: facts, utterances, sessions,
617
650
  [--config <path>] folded blocks (the /memory chat command, from the shell)
618
651
  [--verbose]
652
+ [--export <file.jsonl>] write every stored fact as JSONL (subject/predicate/object/
653
+ provenance) to a file and exit — the shape `tmct extract`
654
+ emits, for audit or backup
619
655
  ```
620
656
 
621
657
  `tmct init` sets up a repo for the first time: `.tmct/`, `tmct.toml`, a seed, and a
@@ -657,9 +693,10 @@ already set up. Its `--graph` flag works differently from the others: it appends
657
693
  [--ontology <name|path>] DIFFERENT operation from the others: it APPENDS to
658
694
  [--lexicon <name|path>] tmct.toml's graph_files array (multi-graph growth),
659
695
  [--graph <path>] never an extensions-bundle activation.
660
- [--file <definition.txt>] teach a plain-text definition file sentence by
661
- sentence (# lines are comments); any declined
662
- sentence exits non-zero with the sentence named
696
+ [--file <defs.txt|facts.jsonl>] teach a definition file: a .txt taught sentence by
697
+ sentence (# lines are comments; a declined sentence exits
698
+ non-zero, named), or a .jsonl triple dump loaded fact by
699
+ fact, keeping each line's own provenance
663
700
  [--memory-backend <default|memory|sqlite>] same knob as `tmct init`
664
701
  [--config <path>]
665
702
  ```
@@ -972,7 +1009,7 @@ loads a repo's graph into a `{ dispatch, resolve, graph }` context,
972
1009
 
973
1010
  ## The tool surface
974
1011
 
975
- Everything above runs on the same 23 tools. Each one is read-only, answers one question in a single call, and returns bounded output. None of them calls a model. A tool that cannot ground an answer says so — the same honest miss you get everywhere else in tmct.
1012
+ Everything above runs on the same 24 tools. Each one is read-only, answers one question in a single call, and returns bounded output. None of them calls a model. A tool that cannot ground an answer says so — the same honest miss you get everywhere else in tmct.
976
1013
 
977
1014
  Three of them are **hot**: their schemas stay resident, so an agent driving tmct sees them every turn and reaches for one call instead of a Read/Grep loop.
978
1015
 
@@ -1044,6 +1081,7 @@ The remaining tools are **cold**: still served, but not billed to an agent every
1044
1081
  | `tmct_calls` | The in-repo symbols a function calls (fn→fn), each with file:line. | `symbol` (required) |
1045
1082
  | `tmct_cochanges` | Modules that historically change in the same commit as a symbol's module (git co-change). | `symbol` (required) |
1046
1083
  | `tmct_context_more` | The bundle sections a lean tmct_context omitted (siblings / tests / cochange / class members / re-exports). | `symbol` (required) |
1084
+ | `tmct_export` | Every stored memory fact as JSONL (subject/predicate/object/provenance) — the shape `tmct extract` emits, for backup or audit. | none |
1047
1085
 
1048
1086
  Add `repo_path` to any of them to point at a repository other than the working directory. `tmct init` also writes this catalog, with a worked invocation per tool, to `.tmct/TOOLS.md` inside the repo it indexed.
1049
1087
 
package/bin/tmct.mjs CHANGED
@@ -729,10 +729,11 @@ async function main() {
729
729
  // with every other subcommand. No `--graph`: memory reads no code graph.
730
730
  const rest = process.argv.slice(3);
731
731
  const verbose = rest.includes("--verbose") || rest.includes("-v");
732
- const { resolveRuntimeConfig } = await import("../src/services/cli-args.mjs");
732
+ const { resolveRuntimeConfig, strFlag } = await import("../src/services/cli-args.mjs");
733
733
  const { renderMemory } = await import("../src/adapters/memory/inspect.mjs");
734
734
  const { loadMemory, openMemoryBackend } = await import("../src/adapters/memory/core.mjs");
735
735
  const { loadBlockIndex } = await import("../src/adapters/memory/blocks.mjs");
736
+ const exportPath = strFlag(rest, ["--export"]);
736
737
  const { repo, toml } = await resolveRuntimeConfig({ argv: rest });
737
738
  // Same backend resolution as chat's createSession, minus the (nonexistent
738
739
  // here) CLI-flag tier: env > tmct.toml > the sqlite default — so this verb
@@ -741,6 +742,20 @@ async function main() {
741
742
  const { dir: memoryDir, close: closeMemoryStore } = await openMemoryBackend(repo, backendChoice);
742
743
  try {
743
744
  const memory = await loadMemory(memoryDir);
745
+ // `--export <file.jsonl>` dumps every stored fact in the extract shape and
746
+ // exits — a backup/audit trail of the same store the tool layer and the
747
+ // browser pages serialize, one serializer for all three.
748
+ if (exportPath) {
749
+ const { serializeFactsJsonl } = await import("../src/adapters/memory/export-jsonl.mjs");
750
+ const { writeFile } = await import("node:fs/promises");
751
+ const { resolve } = await import("node:path");
752
+ const jsonl = serializeFactsJsonl(memory);
753
+ const out = resolve(process.cwd(), exportPath);
754
+ await writeFile(out, jsonl, "utf8");
755
+ const count = jsonl ? jsonl.trimEnd().split("\n").length : 0;
756
+ process.stderr.write(`wrote ${count} fact${count === 1 ? "" : "s"} to ${exportPath}\n`);
757
+ return;
758
+ }
744
759
  // The folded-block index is file-backed beside the session logs, not part
745
760
  // of the memory store — read it off the repo path directly.
746
761
  const blocks = await loadBlockIndex(repo);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@polycode-projects/the-mechanical-code-talker",
3
- "version": "2.10.0",
3
+ "version": "2.10.1",
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.",
@@ -139,6 +139,10 @@
139
139
  "build:spider-fly-bundle": "node scripts/build-spider-fly-bundle.mjs",
140
140
  "build:plan-bundle": "node scripts/build-plan-bundle.mjs",
141
141
  "build:ledger-bundle": "node scripts/build-ledger-bundle.mjs",
142
+ "build:code-explorer-bundle": "node scripts/build-code-explorer-bundle.mjs",
143
+ "build:electron": "node scripts/build-electron-app.mjs",
144
+ "electron": "electron electron/main.mjs",
145
+ "test:electron": "node --test electron/smoke.test.mjs",
142
146
  "build:sprites-bundle": "node scripts/build-sprites-bundle.mjs",
143
147
  "build:wink-vendor": "node scripts/build-wink-vendor.mjs",
144
148
  "build:chat-seed": "node scripts/build-chat-seed.mjs",
@@ -159,6 +163,7 @@
159
163
  "extract:facts": "node --disable-warning=ExperimentalWarning bin/tmct.mjs extract"
160
164
  },
161
165
  "devDependencies": {
166
+ "electron": "^43.2.0",
162
167
  "esbuild": "0.28.1",
163
168
  "ink-testing-library": "4.0.0",
164
169
  "playwright": "1.61.1",
@@ -0,0 +1,38 @@
1
+ // export-jsonl.mjs — serialize a memory store's Facts to JSONL in the same
2
+ // shape `tmct extract` emits: one JSON object per line, each carrying
3
+ // { subject, predicate, object, provenance } (and `quantifier` when a plural
4
+ // class-membership teach set one). Provenance is the store's own legacy compat
5
+ // string, verbatim — so an exported line names where the fact came from, and a
6
+ // re-import through the recognizer or a fact loader lands it back.
7
+ //
8
+ // Pure: no I/O. The CLI (`tmct memory --export`), the tool layer
9
+ // (`tmct_export`) and the browser pages all read a loaded memory (or its fact
10
+ // rows) and call one of these — one serializer, one shape, everywhere.
11
+
12
+ import { readFactRows } from "./core.mjs";
13
+
14
+ /** One export record from a readFactRows row — the fields a re-import needs,
15
+ * and nothing the store computed internally (id, trust, justification). */
16
+ export function factRowToExportRecord(row) {
17
+ const record = {
18
+ subject: row.subject,
19
+ predicate: row.predicate,
20
+ object: row.object,
21
+ provenance: row.provenance || "",
22
+ };
23
+ if (row.quantifier) record.quantifier = row.quantifier;
24
+ return record;
25
+ }
26
+
27
+ /** JSONL for an array of readFactRows rows — one object per line, a trailing
28
+ * newline when there is at least one row, the empty string for none. */
29
+ export function factRowsToJsonl(rows) {
30
+ const records = (rows || []).map(factRowToExportRecord);
31
+ return records.map((r) => JSON.stringify(r)).join("\n") + (records.length ? "\n" : "");
32
+ }
33
+
34
+ /** JSONL for a loaded memory (loadMemory's result, or a Backend-B payload) —
35
+ * every stored Fact, in the extract shape. */
36
+ export function serializeFactsJsonl(memory) {
37
+ return factRowsToJsonl(readFactRows(memory));
38
+ }
@@ -4074,7 +4074,7 @@ const DYNAMIC_LIST_TRIGGER_RE = /^(?:list|show(?:\s+me)?)\s+(?:all\s+|the\s+)?([
4074
4074
  const DYNAMIC_COUNT_TRIGGER_RE = /^(?:how\s+many|number\s+of|count(?:\s+the)?)\s+([a-z][a-z'-]*)\s*(.*)$/i;
4075
4075
  // A closed set of harmless trailing fillers; anything else (a real
4076
4076
  // restrictor like "that mention X") is left alone.
4077
- const DYNAMIC_TAIL_OK_RE = /^(?:are there(?:\s+in\s+total)?|is there|do you know(?:\s+about)?|do you have|exist(?:s)?|are known|in (?:the |a )?(?:graph|memory)|you know(?:\s+about)?)?[?.!\s]*$/i;
4077
+ export const DYNAMIC_TAIL_OK_RE = /^(?:are there(?:\s+in\s+total)?|is there|do you know(?:\s+about)?|do you have|exist(?:s)?|are known|in (?:the |a )?(?:graph|memory)|you know(?:\s+about)?)?[?.!\s]*$/i;
4078
4078
 
4079
4079
  /** Compile "list/how many <memory-class-noun>" into the same count/list AST
4080
4080
  * every code-graph count/list query already builds, or null when this isn't
@@ -47,6 +47,7 @@ export const CLI_VERBS = [
47
47
  flags: [
48
48
  { flag: "[--config <path>]", prose: ["folded blocks (the /memory chat command, from the shell)"] },
49
49
  { flag: "[--verbose]", prose: [] },
50
+ { flag: "[--export <file.jsonl>]", prose: ["write every stored fact as JSONL (subject/predicate/object/", "provenance) to a file and exit — the shape `tmct extract`", "emits, for audit or backup"] },
50
51
  ],
51
52
  },
52
53
  {
@@ -77,7 +78,7 @@ export const CLI_VERBS = [
77
78
  { flag: "[--ontology <name|path>]", prose: ["DIFFERENT operation from the others: it APPENDS to"] },
78
79
  { flag: "[--lexicon <name|path>]", prose: ["tmct.toml's graph_files array (multi-graph growth),"] },
79
80
  { flag: "[--graph <path>]", prose: ["never an extensions-bundle activation."] },
80
- { flag: "[--file <definition.txt>]", prose: ["teach a plain-text definition file sentence by", "sentence (# lines are comments); any declined", "sentence exits non-zero with the sentence named"] },
81
+ { flag: "[--file <defs.txt|facts.jsonl>]", prose: ["teach a definition file: a .txt taught sentence by", "sentence (# lines are comments; a declined sentence exits", "non-zero, named), or a .jsonl triple dump loaded fact by", "fact, keeping each line's own provenance"] },
81
82
  { flag: "[--memory-backend <default|memory|sqlite>]", prose: ["same knob as `tmct init`"] },
82
83
  { flag: "[--config <path>]", prose: [] },
83
84
  ],
@@ -0,0 +1,176 @@
1
+ // code-explorer-hints.mjs — suggested next queries drawn from what a loaded
2
+ // CODE graph actually holds. Pure over the entities payload (individuals +
3
+ // objectProperties), so the desktop shell and any future plain page share one
4
+ // tested generator and neither hand-writes prompt strings.
5
+ //
6
+ // The shapes are exactly the ones ask.mjs already answers, phrased from its own
7
+ // closed vocabulary (ask-vocab.mjs's RELATIONS/EDGE_NOUN_TO_METRIC): a relation
8
+ // only appears in a hint when the graph carries an edge of that kind, and a
9
+ // focus symbol only fills a slot when it actually sits at that end of an edge —
10
+ // so every suggestion resolves to a real answer, never a guess.
11
+
12
+ import { RELATIONS, EDGE_NOUN_TO_METRIC } from "./ask-vocab.mjs";
13
+
14
+ // Node classes this explorer reads as code, with the singular/plural nouns
15
+ // ask.mjs's own list and count lanes accept (ENTITY_TO_TYPE's inverse). Schema
16
+ // documentation individuals (SchemaClass/SchemaPredicate) and anything else are
17
+ // left out — they are not what a reader explores.
18
+ const CODE_CLASS_NOUN = Object.freeze({
19
+ Module: ["module", "modules"],
20
+ Class: ["class", "classes"],
21
+ Function: ["function", "functions"],
22
+ Method: ["method", "methods"],
23
+ Attribute: ["attribute", "attributes"],
24
+ GlobalVariable: ["variable", "variables"],
25
+ Commit: ["commit", "commits"],
26
+ });
27
+
28
+ // The classes a focus symbol is drawn from — the things a reader centres on.
29
+ const FOCUSABLE_CLASSES = new Set(["Module", "Class", "Function", "Method"]);
30
+
31
+ // Fine, symbol-grain predicates fold onto their coarse sibling for phrasing:
32
+ // "what does X call" covers callsSymbol too, exactly as ask.mjs's
33
+ // SYMBOL_GRAIN_SIBLING pairs them.
34
+ const GRAIN_BASE = Object.freeze({ callsSymbol: "calls", touchesSymbol: "touches" });
35
+ const baseKind = (predicate) => GRAIN_BASE[predicate] || predicate;
36
+
37
+ // Forward reading (focus is the subject): "what does <focus> <bare>". The bare
38
+ // verb form is ask-vocab's own, so the phrasing tracks the vocabulary.
39
+ const FORWARD_TEMPLATE = Object.freeze({
40
+ imports: (f) => `what does ${f} import`,
41
+ calls: (f) => `what does ${f} call`,
42
+ contains: (f) => `what is in ${f}`,
43
+ defines: (f) => `what does ${f} define`,
44
+ inherits: (f) => `what does ${f} inherit from`,
45
+ });
46
+
47
+ // Reverse reading (focus is the object): "what <kind> <focus>".
48
+ const REVERSE_TEMPLATE = Object.freeze({
49
+ imports: (f) => `what imports ${f}`,
50
+ calls: (f) => `what calls ${f}`,
51
+ tests: (f) => `what tests ${f}`,
52
+ inherits: (f) => `what inherits from ${f}`,
53
+ contains: (f) => `what contains ${f}`,
54
+ });
55
+
56
+ /** Index a payload into the counts and adjacency the hints read: class →
57
+ * count, base relation kind → { count, subjects, objects }, and a label →
58
+ * { class, degree } term table over every example edge. */
59
+ function indexGraph(payload) {
60
+ const individuals = Array.isArray(payload?.individuals) ? payload.individuals : [];
61
+ const classOfLabel = new Map();
62
+ const classCounts = new Map();
63
+ for (const ind of individuals) {
64
+ if (!ind || !ind.label) continue;
65
+ if (CODE_CLASS_NOUN[ind.class]) classCounts.set(ind.class, (classCounts.get(ind.class) || 0) + 1);
66
+ if (!classOfLabel.has(ind.label)) classOfLabel.set(ind.label, ind.class);
67
+ }
68
+
69
+ const groups = Array.isArray(payload?.objectProperties) ? payload.objectProperties : [];
70
+ const kinds = new Map(); // base kind -> { count, subjects:Set, objects:Set }
71
+ const degree = new Map(); // label -> degree over example edges
72
+ const bumpKind = (k) => kinds.get(k) || kinds.set(k, { count: 0, subjects: new Set(), objects: new Set() }).get(k);
73
+ for (const g of groups) {
74
+ if (!g || !g.predicate) continue;
75
+ const k = baseKind(String(g.predicate));
76
+ const entry = bumpKind(k);
77
+ entry.count += Number(g.count) || (Array.isArray(g.examples) ? g.examples.length : 0);
78
+ for (const e of Array.isArray(g.examples) ? g.examples : []) {
79
+ const s = e?.subjectLabel || e?.subject;
80
+ const o = e?.objectLabel || e?.object;
81
+ if (s) { entry.subjects.add(s); degree.set(s, (degree.get(s) || 0) + 1); }
82
+ if (o) { entry.objects.add(o); degree.set(o, (degree.get(o) || 0) + 1); }
83
+ }
84
+ }
85
+ return { classCounts, classOfLabel, kinds, degree };
86
+ }
87
+
88
+ /** Pick the focus symbol: the caller's choice when it is a real term, else the
89
+ * highest-degree focusable-class label, else the highest-degree label at all,
90
+ * else null. Ties break on label for determinism. */
91
+ function pickFocus(index, requested) {
92
+ if (requested && index.degree.has(requested)) return requested;
93
+ const ranked = [...index.degree.entries()]
94
+ .sort((a, b) => b[1] - a[1] || String(a[0]).localeCompare(String(b[0])));
95
+ const focusable = ranked.find(([label]) => FOCUSABLE_CLASSES.has(index.classOfLabel.get(label)));
96
+ if (focusable) return focusable[0];
97
+ return ranked.length ? ranked[0][0] : null;
98
+ }
99
+
100
+ /**
101
+ * Suggested queries for a loaded code graph.
102
+ *
103
+ * @param {object} payload the entities payload (individuals + objectProperties).
104
+ * @param {object} [opts]
105
+ * @param {string} [opts.focus] centre the neighbourhood on this symbol.
106
+ * @param {number} [opts.limit] cap the returned hints (default 12).
107
+ * @returns {{ focus: (string|null), hints: Array<{text,group,rationale}> }}
108
+ * `hints` is ordered neighbourhood → compositional → explore; empty
109
+ * when the graph holds nothing to explore.
110
+ */
111
+ export function generateCodeHints(payload, { focus: requestedFocus = null, limit = 12 } = {}) {
112
+ const index = indexGraph(payload);
113
+ const focus = pickFocus(index, requestedFocus);
114
+ const hints = [];
115
+ const add = (text, group, rationale) => hints.push({ text, group, rationale });
116
+ const kindPresent = (k) => (index.kinds.get(k)?.count || 0) > 0;
117
+
118
+ if (focus) {
119
+ for (const [kind, template] of Object.entries(FORWARD_TEMPLATE)) {
120
+ if (index.kinds.get(kind)?.subjects.has(focus)) {
121
+ add(template(focus), "neighbourhood", `${focus} ${verbBare(kind)} other symbols in this graph`);
122
+ }
123
+ }
124
+ for (const [kind, template] of Object.entries(REVERSE_TEMPLATE)) {
125
+ if (index.kinds.get(kind)?.objects.has(focus)) {
126
+ add(template(focus), "neighbourhood", `other symbols ${kind} ${focus}`);
127
+ }
128
+ }
129
+ }
130
+
131
+ // Compositional shapes over the graph's own edges and classes.
132
+ const calls = index.kinds.get("calls");
133
+ if (calls && index.classCounts.has("Function") && calls.objects.size) {
134
+ const target = [...calls.objects].sort()[0];
135
+ add(`which functions call ${target}`, "compositional", `${target} is called somewhere in this graph`);
136
+ }
137
+ const contains = index.kinds.get("contains");
138
+ if (contains && index.classCounts.has("Method")) {
139
+ const owner = [...contains.subjects].sort().find((s) => index.classOfLabel.get(s) === "Class");
140
+ if (owner) add(`public methods of ${owner}`, "compositional", `${owner} contains members`);
141
+ }
142
+
143
+ // Explore-the-whole-graph shapes. The rankings and the coverage filter come
144
+ // first — they are the more useful whole-graph questions — then a list and a
145
+ // count per present class. EDGE_NOUN_TO_METRIC names the nouns ask.mjs's
146
+ // superlative lane accepts, so a hint only offers a ranking the graph can
147
+ // actually compute.
148
+ const superlatives = [
149
+ { noun: "imports", entity: "module", filter: "Module" },
150
+ { noun: "callers", entity: "function", filter: "Function" },
151
+ ];
152
+ for (const s of superlatives) {
153
+ const metric = EDGE_NOUN_TO_METRIC[s.noun];
154
+ if (metric && kindPresent(metric.kind) && index.classCounts.has(s.filter)) {
155
+ add(`which ${s.entity} has the most ${s.noun}`, "explore", `rank ${s.entity}s by ${s.noun}`);
156
+ }
157
+ }
158
+ if (kindPresent("tests")) {
159
+ add("which of those are tested", "explore", "filter a listing by test coverage");
160
+ }
161
+ const classesByCount = [...index.classCounts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
162
+ for (const [cls, count] of classesByCount) {
163
+ const [, plural] = CODE_CLASS_NOUN[cls];
164
+ add(`list ${plural}`, "explore", `${count} ${plural} in this graph`);
165
+ add(`how many ${plural}`, "explore", `count the ${plural}`);
166
+ }
167
+
168
+ return { focus, hints: hints.slice(0, limit) };
169
+ }
170
+
171
+ // The infinitive ask-vocab curates for each relation, used in rationales.
172
+ function verbBare(kind) {
173
+ return RELATIONS[kind]?.bare || kind;
174
+ }
175
+
176
+ export { CODE_CLASS_NOUN, indexGraph };
@@ -430,13 +430,19 @@ ${THEME_TOKENS_CSS}
430
430
  .stage { display: grid; grid-template-columns: minmax(0, 1fr) 280px; gap: 1rem; align-items: start; }
431
431
  @media (max-width: 760px) { .stage { grid-template-columns: 1fr; } }
432
432
 
433
+ /* the left column: quest and satchel as full-width strips above the room,
434
+ the room itself, then the reset/play/step/turn strip pinned directly
435
+ below it — everything in this column shares the room's own width, so
436
+ the column's total height tracks the side column instead of leaving a
437
+ gap under a lone, short room panel. */
438
+ .stage-left { display: flex; flex-direction: column; gap: 1rem; min-width: 0; }
439
+
433
440
  /* the room view — a 90s-RPG interior cutaway, all CSS gradients, no
434
441
  external asset: a striped papered wall over a gilt dado rail over a
435
442
  diagonal-plank floor, framed by the same ornate double rule as before.
436
443
  The sprite tiles sit on the floor band (align-items flex-end), so
437
444
  furniture and people read as standing IN the room, against its wall,
438
445
  rather than floating in a chip strip. */
439
- #playStage .room-frame { align-self: start; position: sticky; top: 1rem; }
440
446
  .room-frame {
441
447
  position: relative; min-height: 250px;
442
448
  background:
@@ -557,7 +563,7 @@ ${THEME_TOKENS_CSS}
557
563
  .chatask .prompt { color: var(--taught); font-size: .78rem; font-family: ${MONO_STACK}; }
558
564
  .chatask input { flex: 1; font-family: ${MONO_STACK}; font-size: .78rem; background: var(--bg); color: var(--ink); border: 1px solid var(--line); padding: .32rem .55rem; min-width: 0; }
559
565
  .chatask input:disabled { opacity: .5; }
560
- .controls-row { display: flex; align-items: center; gap: .6rem; margin-top: 1rem; flex-wrap: wrap; }
566
+ .controls-row { display: flex; align-items: center; gap: .6rem; flex-wrap: wrap; }
561
567
  .controls-row button { font-family: ${MONO_STACK}; font-size: .72rem; letter-spacing: .05em; text-transform: uppercase; padding: .38rem .85rem; border: 1px solid var(--gilt); background: var(--parchment); color: var(--ink); }
562
568
  .controls-row button:hover:not(:disabled) { background: var(--parchment-strong); }
563
569
  .controls-row button:disabled { opacity: .4; cursor: default; }
@@ -567,7 +573,7 @@ ${THEME_TOKENS_CSS}
567
573
 
568
574
  /* edit mode */
569
575
  body:not(.editing) #editStage { display: none; }
570
- body.editing #playStage, body.editing #playControls { display: none; }
576
+ body.editing #playStage { display: none; }
571
577
  /* .stage's inherited align-items: start (kept for #playStage's own fixed-
572
578
  size room panel) would otherwise top-align .edittext against the
573
579
  manor-map/room-detail/legend column and leave a dead gap below the
@@ -583,7 +589,7 @@ ${THEME_TOKENS_CSS}
583
589
  .roomdetail .sprite-row { min-height: 3.2rem; }
584
590
  #legendList { display: flex; flex-wrap: wrap; gap: .5rem .3rem; }
585
591
 
586
- body.preview .side, body.preview .controls-row, body.preview .status { display: none; }
592
+ body.preview .side, body.preview .stage-left > .panel, body.preview .controls-row, body.preview .status { display: none; }
587
593
  body.preview main { padding: 0; max-width: none; }
588
594
  body.preview .stage { display: block; }
589
595
  body.preview .eyebrow, body.preview h1, body.preview .mode-toggle, body.preview #editStage { display: none; }
@@ -597,9 +603,25 @@ ${THEME_TOKENS_CSS}
597
603
  <button id="editModeBtn" type="button" class="mode-toggle" disabled>edit the world</button>
598
604
  </div>
599
605
  <div class="stage" id="playStage">
600
- <div class="room-frame" id="roomFrame">
601
- <div class="room-plaque mono" id="roomName"></div>
602
- <div class="sprite-row" id="spriteRow"></div>
606
+ <div class="stage-left">
607
+ <div class="panel goals">
608
+ <h2>quest</h2>
609
+ <div id="goalList"></div>
610
+ </div>
611
+ <div class="panel carrying">
612
+ <h2>satchel</h2>
613
+ <div class="chips" id="carryList"></div>
614
+ </div>
615
+ <div class="room-frame" id="roomFrame">
616
+ <div class="room-plaque mono" id="roomName"></div>
617
+ <div class="sprite-row" id="spriteRow"></div>
618
+ </div>
619
+ <div class="controls-row" id="playControls">
620
+ <button id="resetBtn" type="button" disabled>reset</button>
621
+ <button id="playBtn" type="button" disabled>&#9654; play</button>
622
+ <button id="stepBtn" type="button" disabled>step</button>
623
+ <span class="turn mono" id="turnLabel">turn: 0</span>
624
+ </div>
603
625
  </div>
604
626
  <aside class="side" aria-label="The adventure's log and chat">
605
627
  <div class="chat">
@@ -612,18 +634,10 @@ ${THEME_TOKENS_CSS}
612
634
  <input id="chatq" type="text" placeholder="go north" aria-label="Type a command, or ask a question" disabled>
613
635
  </form>
614
636
  </div>
615
- <div class="panel carrying">
616
- <h2>satchel</h2>
617
- <div class="chips" id="carryList"></div>
618
- </div>
619
637
  <div class="panel roommap">
620
638
  <h2>the manor, so far</h2>
621
639
  <div class="map-viewport"><div id="mapWrap"></div></div>
622
640
  </div>
623
- <div class="panel goals">
624
- <h2>quest</h2>
625
- <div id="goalList"></div>
626
- </div>
627
641
  </aside>
628
642
  </div>
629
643
 
@@ -652,12 +666,6 @@ ${THEME_TOKENS_CSS}
652
666
  </div>
653
667
 
654
668
  <div class="goal-line" id="goalLine"></div>
655
- <div class="controls-row" id="playControls">
656
- <button id="resetBtn" type="button" disabled>reset</button>
657
- <button id="playBtn" type="button" disabled>&#9654; play</button>
658
- <button id="stepBtn" type="button" disabled>step</button>
659
- <span class="turn mono" id="turnLabel">turn: 0</span>
660
- </div>
661
669
  <div class="status" id="status">loading the engine&hellip;</div>
662
670
  </main>
663
671
  <script>
@@ -311,7 +311,9 @@ ${THEME_TOKENS_CSS}
311
311
  </label>
312
312
  <span class="tool-cluster">
313
313
  <button type="button" id="exportMd" class="tool-btn" title="download this conversation as Markdown">export .md</button>
314
+ <button type="button" id="exportFacts" class="tool-btn" title="download this session's facts as JSONL (the tmct extract shape, provenance included)">export facts</button>
314
315
  <button type="button" id="printChat" class="tool-btn" title="print the whole conversation">print</button>
316
+ <button type="button" id="reinitStore" class="tool-btn" title="drop everything saved on this device and reload from the shipped seed">reset to seed</button>
315
317
  </span>
316
318
  </div>
317
319
  </form>
@@ -802,6 +804,42 @@ ${THEME_TOKENS_CSS}
802
804
  });
803
805
  el("printChat").addEventListener("click", () => window.print());
804
806
 
807
+ // ---- store controls: export the triple store, or reset it whole ----------
808
+ // "export facts" downloads the session's whole memory as JSONL (the same
809
+ // subject/predicate/object/provenance shape the extract and memory-export
810
+ // CLI paths emit), so what you taught leaves in the standard shape.
811
+ el("exportFacts").addEventListener("click", async () => {
812
+ const session = window.tmctChatSession;
813
+ if (!session || !window.tmctChat.exportFactsJsonl) return;
814
+ let jsonl;
815
+ try {
816
+ jsonl = await window.tmctChat.exportFactsJsonl(session.memoryDir);
817
+ } catch (err) {
818
+ statusEl.textContent = "couldn't export the facts (" + (err && err.message ? err.message : err) + ")";
819
+ return;
820
+ }
821
+ const blob = new Blob([jsonl], { type: "application/x-ndjson" });
822
+ const url = URL.createObjectURL(blob);
823
+ const link = document.createElement("a");
824
+ link.href = url;
825
+ link.download = "tmct-facts.jsonl";
826
+ document.body.appendChild(link);
827
+ link.click();
828
+ link.remove();
829
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
830
+ });
831
+
832
+ // "reset to seed" is the full re-initialisation: drop the persisted payload
833
+ // outright and reload, so boot re-seeds from the page's shipped seed as if on
834
+ // a first visit. Harder than "forget everything", which only swaps the live
835
+ // session — this trusts nothing in memory and re-fetches the seed asset.
836
+ el("reinitStore").addEventListener("click", async () => {
837
+ clearTimeout(saveTimer);
838
+ saveTimer = null;
839
+ if (persist) await persist.clear();
840
+ window.location.reload();
841
+ });
842
+
805
843
  async function boot() {
806
844
  if (!window.tmctChat) {
807
845
  statusEl.textContent = "the chat engine didn't load \\u2014 this page needs its build step (npm run demo:build)";
@@ -836,7 +874,7 @@ ${THEME_TOKENS_CSS}
836
874
  addSystemLine("tmct \\u2014 the real engine, running in this page \\u2014 " + statsSummaryLine(stats)
837
875
  + "." + restoredNote + " Ask it something, or teach it a fact of your own.");
838
876
  await renderStatsPanel(stats);
839
- inputEl.placeholder = seedPayload ? 'try "what is a dog"' : window.tmctChat.vocabExampleHint(false);
877
+ inputEl.placeholder = seedPayload ? 'try "what is a dog" or "list facts"' : window.tmctChat.vocabExampleHint(false);
840
878
  renderStatus();
841
879
  setBusy(false);
842
880
  inputEl.focus();