@polycode-projects/the-mechanical-code-talker 1.5.5 → 1.8.4

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.
Files changed (45) hide show
  1. package/README.md +123 -14
  2. package/ROADMAP.md +233 -1392
  3. package/bin/tmct.mjs +479 -98
  4. package/corpus/README.md +3 -0
  5. package/corpus/generated/README.md +43 -0
  6. package/corpus/generated/ace-surface-variants.jsonl +17 -0
  7. package/corpus/generated/manifest.json +9 -0
  8. package/corpus/tier2/generate.mjs +14668 -0
  9. package/corpus/tier2/human-examples-large.jsonl +1928 -0
  10. package/corpus/tier2/human-examples-medium.jsonl +356 -0
  11. package/corpus/tier2/human-examples.jsonl +120 -0
  12. package/corpus/tier2/human-large.jsonl +12001 -0
  13. package/corpus/tier2/human-medium.jsonl +944 -0
  14. package/corpus/tier2/human.jsonl +664 -0
  15. package/corpus/tier2/manifest.json +42 -0
  16. package/package.json +14 -8
  17. package/src/answer-variants.json +47 -0
  18. package/src/answer-variants.mjs +67 -0
  19. package/src/ask-browser-entry.mjs +34 -0
  20. package/src/ask-browser.bundle.js +5095 -0
  21. package/src/ask-vocab.mjs +93 -8
  22. package/src/ask.mjs +451 -49
  23. package/src/chat.mjs +1273 -137
  24. package/src/cli-args.mjs +164 -0
  25. package/src/codegraph.mjs +170 -32
  26. package/src/extensions.mjs +100 -19
  27. package/src/grammar/ace.mjs +85 -3
  28. package/src/grammar/lexicon-core.json +9531 -63
  29. package/src/grammar/lexicon.mjs +58 -8
  30. package/src/graph-merge.mjs +114 -0
  31. package/src/index.mjs +14 -0
  32. package/src/init.mjs +40 -14
  33. package/src/interpret/normalize.mjs +75 -1
  34. package/src/interpret/strategies/grammar.mjs +10 -0
  35. package/src/interpret/strategies/keywords.mjs +20 -0
  36. package/src/interpret/strategies/noise-strip.mjs +73 -4
  37. package/src/memory/core.mjs +466 -8
  38. package/src/router/goal-reasoner.mjs +41 -7
  39. package/src/router/guardrail.mjs +37 -7
  40. package/src/router/resolver.mjs +50 -4
  41. package/src/sessions.mjs +5 -1
  42. package/src/source.mjs +54 -1
  43. package/src/syllogise.mjs +398 -27
  44. package/src/toml-config.mjs +13 -4
  45. package/src/viz.mjs +541 -0
@@ -0,0 +1,164 @@
1
+ // cli-args.mjs — the ONE shared argv/config resolver for bin/tmct.mjs's
2
+ // subcommands. Replaces four independently hand-rolled `configFor` copies
3
+ // (bin/tmct.mjs's cli-mode and serve-mode ones, chat.mjs's own, and the richer
4
+ // 4-tier version inlined in chat.mjs's createSession) with a single, tested
5
+ // precedence chain — built on top of toml-config.mjs's already-tested
6
+ // mergeEffective/normalizeConfig (arg > toml > default), not a rebuild of it.
7
+ //
8
+ // Three tiny flag helpers (pure, no I/O) plus the one async resolver:
9
+ // strFlag(rest, names, dflt) → single value, last flag occurrence wins
10
+ // repeatedFlag(rest, names) → every value for a repeatable flag (e.g. --graph)
11
+ // boolFlag(rest, names) → true if any of `names` appears at all
12
+ // resolveRuntimeConfig({argv, cwd, env, gitRoot}) → the resolved repo/config
13
+ //
14
+ // Graph-path precedence (documented once, here — every subcommand shares it):
15
+ // 1. --graph <path> (repeatable; --graph wins outright, even over env)
16
+ // 2. TMCT_GRAPH_FILE (env)
17
+ // 3. tmct.toml's `graph_file` / `graph_files`
18
+ // 4. <repo>/.tmct/graph.json, where repo is --repo, else git root, else cwd
19
+ //
20
+ // Deliberately does NOT import chat.mjs (that would be circular once chat.mjs
21
+ // itself is rewired to call resolveRuntimeConfig) — the git-root lookup below
22
+ // is a small, self-contained copy of chat.mjs's own gitToplevel().
23
+
24
+ import { spawnSync } from "node:child_process";
25
+ import { dirname, resolve } from "node:path";
26
+ import { stat } from "node:fs/promises";
27
+ import { join } from "node:path";
28
+ import { loadTomlConfig, normalizeConfig, mergeEffective, CONFIG_FILE } from "./toml-config.mjs";
29
+ import { DEFAULT_GRAPH_REL } from "./config.mjs";
30
+
31
+ /** The git top-level for `cwd`, or null if not in a repo (or git is
32
+ * unavailable). A deliberate re-declaration of chat.mjs's gitToplevel (not an
33
+ * import) — see the file docblock for why. */
34
+ function defaultGitRoot(cwd) {
35
+ try {
36
+ const r = spawnSync("git", ["rev-parse", "--show-toplevel"], { cwd, encoding: "utf8" });
37
+ if (r.status === 0) { const p = String(r.stdout || "").trim(); return p || null; }
38
+ } catch { /* git missing / not a repo — fall back to cwd */ }
39
+ return null;
40
+ }
41
+
42
+ const asList = (names) => (Array.isArray(names) ? names : [names]);
43
+
44
+ /** Single-value flag, LAST occurrence wins (so `--repo a --repo b` → "b").
45
+ * `names` may be a single flag string or an array of aliases. */
46
+ export function strFlag(rest, names, dflt) {
47
+ const list = asList(names);
48
+ let val = dflt;
49
+ for (let i = 0; i < rest.length; i++) {
50
+ if (list.includes(rest[i]) && rest[i + 1] !== undefined) val = rest[i + 1];
51
+ }
52
+ return val;
53
+ }
54
+
55
+ /** Repeatable flag — every value across every occurrence, in argv order
56
+ * (`--graph a --graph b` → `["a","b"]`). Empty array when absent. */
57
+ export function repeatedFlag(rest, names) {
58
+ const list = asList(names);
59
+ const out = [];
60
+ for (let i = 0; i < rest.length; i++) {
61
+ if (list.includes(rest[i]) && rest[i + 1] !== undefined) out.push(rest[i + 1]);
62
+ }
63
+ return out;
64
+ }
65
+
66
+ /** Presence flag — true if any of `names` appears anywhere in `rest`. */
67
+ export function boolFlag(rest, names) {
68
+ const list = asList(names);
69
+ return rest.some((r) => list.includes(r));
70
+ }
71
+
72
+ /**
73
+ * Resolve one subcommand invocation's repo root, tmct.toml, and graph
74
+ * path(s) — the shared precedence chain every subcommand (chat/memory/init/
75
+ * import/syllogise/serve) now funnels through instead of re-deriving it.
76
+ *
77
+ * @param {object} opts
78
+ * @param {string[]} [opts.argv] the subcommand's own argv tail (already past
79
+ * `tmct <mode>` — the same slice each subcommand hand-parses today).
80
+ * @param {string} [opts.cwd]
81
+ * @param {object} [opts.env]
82
+ * @param {(cwd:string)=>string|null} [opts.gitRoot] injectable for tests,
83
+ * mirrors createSession's own `gitRoot` param.
84
+ * @param {object} [opts.args] extra already-parsed args to fold into
85
+ * mergeEffective's "arg" tier (e.g. a subcommand's own flags) — optional,
86
+ * defaults to {}.
87
+ * @returns {Promise<{
88
+ * repo: string, configPath: string, toml: object,
89
+ * config: {graphFile: string, graphFiles: string[]},
90
+ * effective: object, sources: object,
91
+ * }>}
92
+ */
93
+ export async function resolveRuntimeConfig({
94
+ argv = [],
95
+ cwd = process.cwd(),
96
+ env = process.env,
97
+ gitRoot = defaultGitRoot,
98
+ args = {},
99
+ } = {}) {
100
+ const configFlag = strFlag(argv, ["--config"]);
101
+ const repoFlag = strFlag(argv, ["--repo"]);
102
+ const graphFlags = repeatedFlag(argv, ["--graph"]);
103
+
104
+ const root = typeof gitRoot === "function" ? gitRoot(cwd) : null;
105
+ const repo = repoFlag ? resolve(cwd, repoFlag) : (root || cwd);
106
+
107
+ // --config <path>: a file OR a directory. A directory means "look for
108
+ // tmct.toml under here"; a file is the tmct.toml itself, wherever it lives.
109
+ let configDir = repo;
110
+ let configFileOverride;
111
+ if (configFlag) {
112
+ const abs = resolve(cwd, configFlag);
113
+ let isDir = false;
114
+ try { isDir = (await stat(abs)).isDirectory(); } catch { /* missing path — treat as a file target */ }
115
+ if (isDir) configDir = abs;
116
+ else { configFileOverride = abs; configDir = dirname(abs); }
117
+ }
118
+
119
+ const raw = await loadTomlConfig(configDir, configFileOverride ? { file: configFileOverride } : {});
120
+ const toml = await normalizeConfig(raw, { configDir });
121
+
122
+ const defaultGraphFile = join(repo, DEFAULT_GRAPH_REL);
123
+ const envGraph = env && env.TMCT_GRAPH_FILE && String(env.TMCT_GRAPH_FILE).trim();
124
+
125
+ let graphFiles;
126
+ let graphSource; // "arg" | "env" | "tmct.toml" | "default"
127
+ if (graphFlags.length) {
128
+ graphFiles = graphFlags.map((g) => resolve(cwd, g));
129
+ graphSource = "arg";
130
+ } else if (envGraph) {
131
+ graphFiles = [resolve(cwd, envGraph)];
132
+ graphSource = "env";
133
+ } else if (toml.graphFiles && toml.graphFiles.length) {
134
+ graphFiles = toml.graphFiles;
135
+ graphSource = "tmct.toml";
136
+ } else if (toml.graphFile) {
137
+ graphFiles = [toml.graphFile];
138
+ graphSource = "tmct.toml";
139
+ } else {
140
+ graphFiles = [defaultGraphFile];
141
+ graphSource = "default";
142
+ }
143
+
144
+ // mergeEffective wires in toml-config.mjs's already-tested arg>toml>default
145
+ // precedence for every OTHER knob (index/tune/corpus/…); graphFile's own
146
+ // precedence is richer (it has an env tier mergeEffective doesn't model), so
147
+ // it's resolved above and folded back in below rather than re-derived here.
148
+ const defaults = { graphFile: defaultGraphFile };
149
+ const { effective, sources } = mergeEffective({ args, toml, defaults });
150
+ effective.graphFile = graphFiles[0];
151
+ sources.graphFile = graphSource;
152
+ if (graphFiles.length > 1) effective.graphFiles = graphFiles;
153
+
154
+ const configPath = configFileOverride || join(configDir, CONFIG_FILE);
155
+
156
+ return {
157
+ repo,
158
+ configPath,
159
+ toml,
160
+ config: { graphFile: graphFiles[0], graphFiles },
161
+ effective,
162
+ sources,
163
+ };
164
+ }
package/src/codegraph.mjs CHANGED
@@ -1,5 +1,9 @@
1
1
  import { lookupByProseTokens, proseLayerHits } from "./prose.mjs";
2
2
  import { cosine } from "./embed.mjs";
3
+ // Single-sourced predicate strings (memory/core.mjs owns these constants) — no
4
+ // circular-import risk: core.mjs imports trust.mjs/shacl.mjs/planning.mjs, never
5
+ // codegraph.mjs, in either direction.
6
+ import { CREATED_AT_PROP, UPDATED_AT_PROP } from "./memory/core.mjs";
3
7
 
4
8
  // Pure (no-network, no-fs) query logic over the typed `entities` payload that the
5
9
  // deterministic indexer writes to <repo>/.tmct/graph.json (shape produced by
@@ -91,6 +95,13 @@ const PROP_KIND = {
91
95
  "mg:defines": "defines",
92
96
  "mg:tests": "tests",
93
97
  "mg:touches": "touches",
98
+ // memory-graph predicates (src/memory/core.mjs, src/sessions.mjs) — each maps to
99
+ // itself as its own kind name (no module-rollup abbreviation needed, unlike
100
+ // imports/calls) so adjacencyForKinds/edgesOfKind can walk the memory graph too.
101
+ "mgx:saidinsession": "saidInSession",
102
+ "mgx:inreplyto": "inReplyTo",
103
+ "mgx:statedby": "statedBy",
104
+ "mgx:canonicalisedfrom": "canonicalisedFrom",
94
105
  };
95
106
 
96
107
  export function relationKind(group) {
@@ -579,6 +590,12 @@ const SPIRAL_DEPTH_DEFAULT = 3;
579
590
  const SPIRAL_NODE_LIMIT_DEFAULT = 12;
580
591
  const SPIRAL_Q_DEFAULT = 0.9; // mild hub pruning (drop only the densest 10%) — the centre point
581
592
  const SPIRAL_EXPAND_KINDS = ["imports", "calls", "callsSymbol", "inherits"]; // cochange dropped
593
+ // The memory graph's real edge-kind inventory (traced via every objectProperties.push/.find
594
+ // site in src/memory/*.mjs and src/sessions.mjs) — the `kinds` a memory-graph spiralExpand call
595
+ // passes so it walks Session/Fact/Source/Utterance individuals rather than code-graph Modules.
596
+ // NOTE: mgx:asksAbout (src/sessions.mjs) is deliberately EXCLUDED — that predicate lives in the
597
+ // CODE graph (Session ↔ code entities a chat turn resolved/answered), not the memory graph.
598
+ export const MEMORY_SPIRAL_EXPAND_KINDS = ["saidInSession", "inReplyTo", "statedBy", "canonicalisedFrom"];
582
599
  const SPIRAL_EMIT_FRAC = 0.5; // a newly-surfaced node's base score = maxSeed × this …
583
600
  const SPIRAL_HOP_DECAY = 0.6; // … decayed by this per hop from the seeds (bounded < maxSeed, so a walked-in node never dominates rank 1)
584
601
  const SPIRAL_PROX_FRAC = 0.2; // an ALREADY-matched module the spiral re-reaches gets a bounded nudge …
@@ -623,8 +640,13 @@ function identComponents(name) {
623
640
  /** For one edge-kind group, the depth-1 successor of `fromId` reachable via any edge in `kinds`,
624
641
  * as a Map<moduleId, neighbourModuleId> adjacency (undirected — a module's neighbours via that
625
642
  * kind, in either edge direction). Endpoints are mapped to their containing module first (call
626
- * edges live at function granularity), matching the existing E1a call-adjacency convention. */
627
- function adjacencyForKinds(graph, kinds) {
643
+ * edges live at function granularity), matching the existing E1a call-adjacency convention.
644
+ * `idNormalizer` (default null) lets a caller fold edge endpoints some OTHER way — the memory
645
+ * graph has no "containing module" concept, so a memory-graph caller passes `(id) => id` to walk
646
+ * its raw individual ids unchanged. Defaulting to null (rather than `moduleIdOfId` directly)
647
+ * keeps the sole existing caller (`adjacencyForKinds(graph, kinds)` in beamExpand) byte-identical. */
648
+ export function adjacencyForKinds(graph, kinds, idNormalizer = null) {
649
+ const norm = idNormalizer || ((id) => moduleIdOfId(graph, id));
628
650
  const adj = new Map();
629
651
  const link = (a, b) => {
630
652
  if (!a || !b || a === b) return;
@@ -635,7 +657,7 @@ function adjacencyForKinds(graph, kinds) {
635
657
  };
636
658
  for (const kind of kinds) {
637
659
  for (const e of edgesOfKind(graph, kind)) {
638
- link(moduleIdOfId(graph, e.subject), moduleIdOfId(graph, e.object));
660
+ link(norm(e.subject), norm(e.object));
639
661
  }
640
662
  }
641
663
  return adj;
@@ -707,24 +729,55 @@ function beamExpand(graph, scored, beamWidth) {
707
729
  }
708
730
 
709
731
  /** SPIRAL expansion (opt-in; see the SPIRAL_* constants' comment above for the full design).
710
- * A deterministic bounded-radius ego walk from the lexical seeds (`scored`), popped
711
- * fewest-arcs-first via a min-heap keyed (hop ASC, in-graph degree ASC, id ASC), with a
712
- * degree-quantile hub gate at each expansion step. Emits up to `nodeLimit` newly-reached nodes
713
- * in pop order, scoring each seed-relative and bounded so a hub can't dominate rank 1.
732
+ * A deterministic bounded-radius ego walk from the lexical seeds (`scored`, or an explicit
733
+ * `seeds` override — see below), popped fewest-arcs-first via a min-heap keyed (hop ASC,
734
+ * in-graph degree ASC, id ASC), with a degree-quantile hub gate at each expansion step. Emits
735
+ * up to `nodeLimit` newly-reached nodes in pop order, scoring each seed-relative and bounded so
736
+ * a hub can't dominate rank 1.
714
737
  * CRITICAL vs beamExpand: it deliberately OMITS the `if (!baseScore.has) continue` guard, so it
715
738
  * MAY push modules that had NO lexical match into `scored` — the one path to breaking the lexical
716
- * ceiling. Mutates `scored` (nudges re-reached matches in place; APPENDS newly-surfaced modules).
717
- * Pure otherwise (no fs/network); deterministic total ordering throughout. */
718
- function spiralExpand(graph, scored, { depth = SPIRAL_DEPTH_DEFAULT, q = SPIRAL_Q_DEFAULT, nodeLimit = SPIRAL_NODE_LIMIT_DEFAULT } = {}) {
719
- if (!scored.length) return;
739
+ * ceiling. Mutates `scored` (nudges re-reached matches in place; APPENDS newly-surfaced modules)
740
+ * when the score-nudge machinery is active. Pure otherwise (no fs/network); deterministic total
741
+ * ordering throughout.
742
+ *
743
+ * Generalised (2026-07-11) past its original code-graph-only, `scored`-only shape so a pure
744
+ * graph-visualisation walk (no lexical match list at all) can reuse the exact same traversal:
745
+ * - `scored` is now OPTIONAL (default `[]`) — a bare walk with no ranking machinery.
746
+ * - `kinds` (default `SPIRAL_EXPAND_KINDS`) — the edge-kind set to walk; a memory-graph caller
747
+ * passes `MEMORY_SPIRAL_EXPAND_KINDS`.
748
+ * - `classPredicate` (default `(ind) => (ind.class || "") === "Module"`) — replaces the two
749
+ * hardcoded `"Module"` checks below, so a memory-graph caller can pass `() => true` (every
750
+ * class walkable) or any other individual filter.
751
+ * - `idNormalizer` (default `null`) — threaded straight into the internal `adjacencyForKinds`
752
+ * call; a memory-graph caller passes `(id) => id` (no module-folding).
753
+ * - `seeds` (default derived from `scored`, as before) — an explicit id iterable, so a caller
754
+ * with no `scored` list at all (e.g. `mostRecentIndividual`'s single seed) can still drive
755
+ * the walk.
756
+ * The score-nudge machinery (mutating `scored`/introducing newly-surfaced individuals into it)
757
+ * is gated behind `scored.length > 0 && maxSeed > 0` — the exact condition the original early
758
+ * return checked — so an empty `scored` degrades gracefully into a pure walk rather than erroring.
759
+ * Returns `[{id, hop}]` for every node the walk actually pops (seeds included, at hop 0) — this
760
+ * used to return `undefined`; safe, since the sole caller (`scoreModules`) already discards the
761
+ * return value (confirmed by inspection, not assumed). */
762
+ export function spiralExpand(graph, scored = [], {
763
+ depth = SPIRAL_DEPTH_DEFAULT,
764
+ q = SPIRAL_Q_DEFAULT,
765
+ nodeLimit = SPIRAL_NODE_LIMIT_DEFAULT,
766
+ kinds = SPIRAL_EXPAND_KINDS,
767
+ classPredicate = (ind) => (ind.class || "") === "Module",
768
+ idNormalizer = null,
769
+ seeds: seedsOpt = null,
770
+ } = {}) {
720
771
  const byId = new Map(scored.map((s) => [s.ind.id, s]));
721
- const seeds = new Set(byId.keys());
722
772
  let maxSeed = 0;
723
773
  for (const s of scored) maxSeed = Math.max(maxSeed, s.score);
724
- if (!(maxSeed > 0)) return;
725
- // Combined undirected adjacency over the expansion kinds (cochange dropped). In-graph degree =
726
- // neighbour count over these kinds — the "arcs" the frontier orders and the quantile gate reads.
727
- const adj = adjacencyForKinds(graph, SPIRAL_EXPAND_KINDS);
774
+ const nudgeActive = scored.length > 0 && maxSeed > 0; // the original "!(maxSeed > 0) return" guard, now a gate rather than a bail-out
775
+ const seeds = new Set(seedsOpt != null ? seedsOpt : byId.keys());
776
+ if (!seeds.size) return [];
777
+ // Combined undirected adjacency over the expansion kinds (cochange dropped for the code-graph
778
+ // default). In-graph degree = neighbour count over these kinds — the "arcs" the frontier orders
779
+ // and the quantile gate reads.
780
+ const adj = adjacencyForKinds(graph, kinds, idNormalizer);
728
781
  const degree = (id) => (adj.get(id)?.size || 0);
729
782
  // Binary min-heap over the frontier, keyed (hop ASC, degree ASC, id ASC) — pop the closest,
730
783
  // least-connected node first, so expansion fans through the sparse surroundings and fizzles at
@@ -757,35 +810,41 @@ function spiralExpand(graph, scored, { depth = SPIRAL_DEPTH_DEFAULT, q = SPIRAL_
757
810
  };
758
811
  const visited = new Set(seeds);
759
812
  for (const id of seeds) push({ id, hop: 0, deg: degree(id) });
760
- const defIdx = definesIndex(graph);
813
+ let defIdx = null; // lazy: only the nudge-active path (newly-surfaced individuals) needs this
761
814
  let emitted = 0;
815
+ const results = [];
762
816
  while (heap.length && emitted < nodeLimit) {
763
817
  const node = pop();
818
+ results.push({ id: node.id, hop: node.hop });
764
819
  if (!seeds.has(node.id)) {
765
- // Slot this newly-reached node: seed-relative base, hop-decayed and bounded below maxSeed.
766
- const emitScore = maxSeed * SPIRAL_EMIT_FRAC * Math.pow(SPIRAL_HOP_DECAY, node.hop - 1);
767
- const existing = byId.get(node.id);
768
- if (existing) { // already lexically matched (below-k) → bounded nudge, never a replacement
769
- existing.score += Math.min(emitScore * SPIRAL_PROX_FRAC, existing.score * SPIRAL_PROX_CAP_FRAC);
770
- } else { // lexically INVISIBLE introduce it (the beam structurally cannot)
771
- const ind = graph.byId?.get?.(node.id);
772
- if (ind && (ind.class || "") === "Module") {
773
- const defines = defIdx.get(ind.id) || [];
774
- const entry = { ind, score: emitScore, defineCount: defines.length, matching: [], density: 0 };
775
- scored.push(entry);
776
- byId.set(node.id, entry);
820
+ if (nudgeActive) {
821
+ // Slot this newly-reached node: seed-relative base, hop-decayed and bounded below maxSeed.
822
+ const emitScore = maxSeed * SPIRAL_EMIT_FRAC * Math.pow(SPIRAL_HOP_DECAY, node.hop - 1);
823
+ const existing = byId.get(node.id);
824
+ if (existing) { // already lexically matched (below-k) bounded nudge, never a replacement
825
+ existing.score += Math.min(emitScore * SPIRAL_PROX_FRAC, existing.score * SPIRAL_PROX_CAP_FRAC);
826
+ } else { // lexically INVISIBLE → introduce it (the beam structurally cannot)
827
+ const ind = graph.byId?.get?.(node.id);
828
+ if (ind && classPredicate(ind)) {
829
+ if (!defIdx) defIdx = definesIndex(graph);
830
+ const defines = defIdx.get(ind.id) || [];
831
+ const entry = { ind, score: emitScore, defineCount: defines.length, matching: [], density: 0 };
832
+ scored.push(entry);
833
+ byId.set(node.id, entry);
834
+ }
777
835
  }
778
836
  }
779
837
  emitted++;
780
838
  }
781
839
  if (node.hop >= depth) continue;
782
- // This step's candidate set = the popped node's unvisited MODULE neighbours; quantile-gate by
783
- // degree, keeping the lowest-degree ⌊q·n⌋ (drop the densest hubs), never fewer than one.
840
+ // This step's candidate set = the popped node's unvisited neighbours matching classPredicate;
841
+ // quantile-gate by degree, keeping the lowest-degree ⌊q·n⌋ (drop the densest hubs), never
842
+ // fewer than one.
784
843
  const cands = [];
785
844
  for (const nid of adj.get(node.id) || []) {
786
845
  if (visited.has(nid)) continue;
787
846
  const ind = graph.byId?.get?.(nid);
788
- if (!ind || (ind.class || "") !== "Module") continue; // no phantom (fn-fallback) module ids
847
+ if (!ind || !classPredicate(ind)) continue; // no phantom (fn-fallback) ids
789
848
  cands.push({ id: nid, deg: degree(nid) });
790
849
  }
791
850
  if (!cands.length) continue;
@@ -797,6 +856,24 @@ function spiralExpand(graph, scored, { depth = SPIRAL_DEPTH_DEFAULT, q = SPIRAL_
797
856
  push({ id: c.id, hop: node.hop + 1, deg: c.deg });
798
857
  }
799
858
  }
859
+ return results;
860
+ }
861
+
862
+ /** The individual with the most recent `createdAtProp` attribute — item 1's ("Traversal") seed
863
+ * default: "sort memory-graph individuals by mgx:createdAt descending, seed from the single most
864
+ * recent." Deterministic tie-break by id (lowest id wins) when two individuals share the exact
865
+ * same timestamp — same total-order convention `spiralExpand`'s own heap uses. Null when no
866
+ * individual carries the attribute at all (empty graph, or a graph that predates timestamps).
867
+ * ISO-8601 timestamps compare correctly as plain strings (same zero-padded width throughout this
868
+ * codebase), so no Date parsing is needed. */
869
+ export function mostRecentIndividual(graph, createdAtProp = CREATED_AT_PROP) {
870
+ let best = null; // { ind, v }
871
+ for (const ind of graph?.individuals || []) {
872
+ const v = (ind?.attributes || []).find((a) => a?.prop === createdAtProp)?.value;
873
+ if (!v) continue;
874
+ if (!best || v > best.v || (v === best.v && String(ind.id) < String(best.ind.id))) best = { ind, v };
875
+ }
876
+ return best ? best.ind : null;
800
877
  }
801
878
 
802
879
  /** The shared module-ranking core behind renderSearch (text) and searchModulesRanked (path+score).
@@ -1203,6 +1280,67 @@ export function edgesOfKind(graph, kind) {
1203
1280
  return out;
1204
1281
  }
1205
1282
 
1283
+ /** A node's "last touched" moment, DERIVED rather than stored (PLAN_VIZ.md §2): the node's own
1284
+ * `updatedAtProp`/`createdAtProp` attribute, or the max `createdAt` over every edge (in
1285
+ * `graph.relations`, ACROSS every kind, not just classified ones) touching it as either
1286
+ * subject or object — whichever is newer. `""` when nothing carries a timestamp at all. Compares
1287
+ * ISO-8601 strings directly (correct for same-width zero-padded timestamps, no Date parsing).
1288
+ * Tolerates edges with no `createdAt` field (pre-dating `upsertEdge`'s own stamp, or written by
1289
+ * a path that bypasses `upsertEdge` entirely) by simply skipping them, never throwing. Operates
1290
+ * on the shared parsed-graph shape (`graph.relations`/`graph.individuals`), not memory-specific —
1291
+ * same reasoning `edgesOfKind`/`moduleIdOf` already document. */
1292
+ export function derivedUpdatedAt(graph, ind, { createdAtProp = CREATED_AT_PROP, updatedAtProp = UPDATED_AT_PROP } = {}) {
1293
+ if (!ind) return "";
1294
+ const attrs = ind.attributes || [];
1295
+ const own = attrs.find((a) => a?.prop === updatedAtProp)?.value || attrs.find((a) => a?.prop === createdAtProp)?.value || "";
1296
+ let best = own || "";
1297
+ for (const g of graph?.relations || []) {
1298
+ for (const e of g.edges || []) {
1299
+ if (!e || (e.subject !== ind.id && e.object !== ind.id)) continue;
1300
+ const c = e.createdAt;
1301
+ if (!c) continue; // no timestamp on this edge — skip, never throw
1302
+ if (!best || c > best) best = c;
1303
+ }
1304
+ }
1305
+ return best;
1306
+ }
1307
+
1308
+ /** Turn a `spiralExpand` walk (`[{id, hop}]`) into the `{nodes, edges}` shape
1309
+ * `tmct viz` renders — pure, no I/O, shared verbatim between the CLI
1310
+ * (`src/viz.mjs`'s `computeVizGraph`) and the browser bundle's client-side
1311
+ * re-walk/recentre (PLAN_BREADTH_FIRST_NLU.md §5 follow-on, operator
1312
+ * directive 2026-07-11) so both paths render byte-identically from the same
1313
+ * logic, never two hand-maintained copies. `nodes` enrich each walked id with
1314
+ * its real label/class/timestamps (`derivedUpdatedAt`, above); `edges` are
1315
+ * every relation-group edge connecting two walked nodes (not just the kinds
1316
+ * the walk itself traversed through — an incidental edge between two reached
1317
+ * nodes still renders), de-duped on (subject, object, predicate) across
1318
+ * relation groups. */
1319
+ export function buildVizNodesAndEdges(graph, walked, { createdAtProp = CREATED_AT_PROP, updatedAtProp = UPDATED_AT_PROP } = {}) {
1320
+ const nodeIds = new Set(walked.map((w) => w.id));
1321
+ const nodes = walked.map(({ id, hop }) => {
1322
+ const ind = graph.byId.get(id) || null;
1323
+ const attrs = ind?.attributes || [];
1324
+ const createdAt = attrs.find((a) => a?.prop === createdAtProp)?.value || "";
1325
+ return {
1326
+ id, hop, label: ind?.label || id, class: ind?.class || "", createdAt,
1327
+ updatedAt: derivedUpdatedAt(graph, ind, { createdAtProp, updatedAtProp }),
1328
+ };
1329
+ });
1330
+ const edges = [];
1331
+ const seen = new Set();
1332
+ for (const group of graph.relations || []) {
1333
+ for (const e of group.edges || []) {
1334
+ if (!nodeIds.has(e.subject) || !nodeIds.has(e.object)) continue;
1335
+ const key = `${e.subject} ${e.object} ${group.predicate}`;
1336
+ if (seen.has(key)) continue;
1337
+ seen.add(key);
1338
+ edges.push({ source: e.subject, target: e.object, kind: group.predicate });
1339
+ }
1340
+ }
1341
+ return { nodes, edges };
1342
+ }
1343
+
1206
1344
  /** moduleIdOf by raw edge-endpoint id: resolves through byId when the individual exists,
1207
1345
  * else falls back to parsing an `fn:<path>#name` id directly (callsSymbol objects may name
1208
1346
  * symbols with no individual of their own). Null if it cannot be mapped. */
@@ -5,23 +5,41 @@
5
5
  //
6
6
  // resolveExtensions(repoRoot) → { entries: Map<name, ResolvedEntry>, biasByBundle }
7
7
  //
8
- // BUILTIN_EXTENSIONS ships the exact two bundles chat.mjs's bootstrap has
9
- // always seeded `seon` and `conceptnet`, both active plus four shipped-
10
- // but-INACTIVE tier-2 bundles (`tier2-aws` / `tier2-python` / `tier2-java` /
11
- // `tier2-general`). Activating one is a config-only edit (`tmct init --corpus
8
+ // BUILTIN_EXTENSIONS' DEFAULT ACTIVE BUNDLE IS `human` (PLAN_SEED.md, the
9
+ // persona flip): a fresh repo now seeds an everyday-world vocabulary (people,
10
+ // places, objects, nature, time/events, body/food, mind hand-curated from
11
+ // Open English WordNet + Schema.org) rather than the old implicit code-domain
12
+ // default. `seon` and `conceptnet` are SHIPPED but now INACTIVE — both are
13
+ // equally code/tech-domain-biased (conceptnet's committed slice was filtered
14
+ // via a tech-domain seed-term match, PLAN_SEED.md §2), so BOTH flip together,
15
+ // not just seon — a repo that wants the old behavior asks for it explicitly
16
+ // (`tmct init --with-persona code`, or `[extensions.seon]`/`[extensions.
17
+ // conceptnet]` `active = true`). Four more shipped-but-INACTIVE tier-2 bundles
18
+ // (`tier2-aws` / `tier2-python` / `tier2-java` / `tier2-general`) round out the
19
+ // catalog. Activating any of these is a config-only edit (`tmct init --corpus
12
20
  // aws`, or a `[extensions.tier2-aws] active = true` in tmct.toml) — zero code
13
- // change. `tier2-general` (PLAN_AGENTS.md Phase 1) is deliberately NOT a
14
- // language/domain bundle like the other three everyday-knowledge concepts
15
- // with zero code-domain framing, the "wider general-knowledge seed set"
16
- // bullet made real instead of just mechanically activatable.
21
+ // change. `tier2-general`'s own 49-fact animal/weather set (PLAN_AGENTS.md
22
+ // Phase 1) is superseded IN DEFAULT ROLE by `human`'s much larger
23
+ // `human-nature` clump, but stays shipped/selectable on its own for a caller
24
+ // that wants that narrow slice without the rest of the human persona.
25
+ //
26
+ // `human-medium`/`human-large` (also shipped-but-INACTIVE) are SIZE TIERS of
27
+ // the SAME `human` bundle, not separate personas (PLAN_SEED.md §3) — each
28
+ // holds only the facts that size adds beyond the previous one. Activated
29
+ // together via `tmct init --persona-size medium|large` (bin/tmct.mjs), which
30
+ // resolves them through this same registry.
17
31
  //
18
32
  // A `tmct.toml` may carry a top-level `[extensions]` table-of-tables
19
33
  // (`[extensions.tier2-aws]`, …): a RECOGNIZED name (one of the builtins above)
20
34
  // may override `active`/paths/etc; an UNRECOGNIZED name declares a brand new
21
- // host entry and MUST carry a `kind` (corpus | lexicon | templates | pack) — a
22
- // `pack` entry may combine any of corpus_path/lexicon_path/templates_path/
23
- // phrasebook_path under one `active` flag and one provenance name, the shape a
24
- // third-party vocabulary package hands tmct.
35
+ // host entry and MUST carry a `kind` (corpus | lexicon | templates | pack |
36
+ // ontology) — a `pack` entry may combine any of corpus_path/lexicon_path/
37
+ // templates_path/phrasebook_path under one `active` flag and one provenance
38
+ // name, the shape a third-party vocabulary package hands tmct. `ontology` is a
39
+ // DISTINCT, nameable kind for an ontology bundle (as opposed to a plain
40
+ // `corpus` bundle) — its `ontology_path` key is just an alias populating the
41
+ // SAME internal `corpusPath` field a corpus entry uses, so every downstream
42
+ // seeder/loader needs zero branching by kind name.
25
43
  //
26
44
  // A SEPARATE top-level `[bias]` table (flat: bundle-name → number) feeds
27
45
  // src/memory/bias.mjs's ranking — never nested under `[extensions.*]`.
@@ -45,7 +63,7 @@ import {
45
63
  toFacts,
46
64
  } from "./corpus/conceptnet.mjs";
47
65
 
48
- export const EXTENSION_KINDS = Object.freeze(["corpus", "lexicon", "templates", "pack"]);
66
+ export const EXTENSION_KINDS = Object.freeze(["corpus", "lexicon", "templates", "pack", "ontology"]);
49
67
 
50
68
  // The definitional-band-first predicate order chat.mjs's bootstrap has always
51
69
  // passed for the ConceptNet seed (SEED_PREFER) — re-declared here (not
@@ -57,15 +75,20 @@ const CONCEPTNET_PREFER = ["rdfs:subClassOf", "rdf:type", "mgx:usedFor", "mgx:pa
57
75
  * accidentally mutate a module-level singleton. */
58
76
  function builtinExtensions() {
59
77
  return {
78
+ // WAS active:true (the implicit code-domain default) — now opt-in.
79
+ // PLAN_SEED.md §2: re-activate explicitly (`tmct init --with-persona
80
+ // code`, or `[extensions.seon] active = true`) for the old behavior.
60
81
  seon: {
61
82
  kind: "corpus",
62
- active: true,
83
+ active: false,
63
84
  corpusPath: SEON_CONCEPTS_FILE,
64
85
  provenancePrefix: "corpus:seon",
65
86
  },
87
+ // WAS active:true — now opt-in too, not just seon (PLAN_SEED.md §2: the
88
+ // committed slice is itself tech-domain-filtered, equally biased).
66
89
  conceptnet: {
67
90
  kind: "corpus",
68
- active: true,
91
+ active: false,
69
92
  corpusPath: CONCEPTNET_SLICE_FILE,
70
93
  provenancePrefix: "corpus:conceptnet",
71
94
  // matches chat.mjs's seedBootstrapMemory exactly: uncapped, definitional
@@ -73,6 +96,40 @@ function builtinExtensions() {
73
96
  limit: undefined,
74
97
  prefer: CONCEPTNET_PREFER,
75
98
  },
99
+ // NEW — the default active bundle (PLAN_SEED.md). Everyday-world
100
+ // vocabulary: people, places, objects, nature, time/events, body/food,
101
+ // mind, plus the human-base/human-bridge scaffolding connecting WordNet's
102
+ // and Schema.org's independently-built taxonomies (PLAN_SEED.md §3, §8).
103
+ human: {
104
+ kind: "corpus",
105
+ active: true,
106
+ corpusPath: join(TIER2_DIR, "human.jsonl"),
107
+ provenancePrefix: "corpus:human",
108
+ },
109
+ // NEW — Medium/Large SIZE tiers of the SAME `human` bundle (PLAN_SEED.md
110
+ // §3), not separate personas: each file holds ONLY the facts that size
111
+ // adds beyond the previous one (Medium beyond Small, Large beyond
112
+ // Medium), so activating them is purely ADDITIVE alongside `human`
113
+ // (never a replacement for it). Both ship INACTIVE — Small stays the
114
+ // unconditional default — and are activated together via `tmct init
115
+ // --persona-size medium|large` (bin/tmct.mjs), which resolves them
116
+ // through this SAME BUILTIN_EXTENSIONS lookup and the ordinary
117
+ // `--corpus <id>` activation seam (activatePluggableInput). "large"
118
+ // activates BOTH human-medium and human-large (Large's facts are
119
+ // Medium's plus its own — both bundles must be active to reach the
120
+ // full ~13,600-fact total).
121
+ "human-medium": {
122
+ kind: "corpus",
123
+ active: false,
124
+ corpusPath: join(TIER2_DIR, "human-medium.jsonl"),
125
+ provenancePrefix: "corpus:human-medium",
126
+ },
127
+ "human-large": {
128
+ kind: "corpus",
129
+ active: false,
130
+ corpusPath: join(TIER2_DIR, "human-large.jsonl"),
131
+ provenancePrefix: "corpus:human-large",
132
+ },
76
133
  "tier2-aws": {
77
134
  kind: "corpus",
78
135
  active: false,
@@ -118,6 +175,9 @@ export function validateExtensionEntry(name, entry) {
118
175
  if (entry.kind === "corpus" && !entry.corpusPath) {
119
176
  throw new Error(`extension "${name}": a "corpus" entry needs corpus_path`);
120
177
  }
178
+ if (entry.kind === "ontology" && !entry.corpusPath) {
179
+ throw new Error(`extension "${name}": an "ontology" entry needs ontology_path`);
180
+ }
121
181
  if (entry.kind === "lexicon" && !entry.lexiconPath) {
122
182
  throw new Error(`extension "${name}": a "lexicon" entry needs lexicon_path`);
123
183
  }
@@ -154,6 +214,7 @@ function mergeExtensionEntry(name, builtin, override, repoRoot) {
154
214
  };
155
215
  const paths = [
156
216
  ["corpus_path", "corpusPath"],
217
+ ["ontology_path", "corpusPath"], // alias: an "ontology" entry's own path key, same internal field as "corpus"
157
218
  ["lexicon_path", "lexiconPath"],
158
219
  ["templates_path", "templatesPath"],
159
220
  ["phrasebook_path", "phrasebookPath"],
@@ -184,9 +245,13 @@ function mergeExtensionEntry(name, builtin, override, repoRoot) {
184
245
  * table (default {} — every bundle then ranks at bias 1, see bias.mjs).
185
246
  * No `tmct.toml` (or one with no `[extensions]`/`[bias]` tables) resolves to
186
247
  * exactly today's implicit seon+conceptnet default, byte-identical.
248
+ *
249
+ * `configFile` (optional): an explicit tmct.toml path override — `tmct extend
250
+ * --validate <dir> --config <path>` — read INSTEAD of `<repoRoot>/tmct.toml`;
251
+ * `repoRoot` still anchors every resource path (unchanged).
187
252
  */
188
- export async function resolveExtensions(repoRoot) {
189
- const raw = repoRoot ? await loadTomlConfig(repoRoot) : null;
253
+ export async function resolveExtensions(repoRoot, { configFile } = {}) {
254
+ const raw = repoRoot ? await loadTomlConfig(repoRoot, configFile ? { file: configFile } : {}) : null;
190
255
  const defs = builtinExtensions();
191
256
  const rawExtensions = (raw && raw.extensions && typeof raw.extensions === "object") ? raw.extensions : {};
192
257
  const rawBias = (raw && raw.bias && typeof raw.bias === "object") ? raw.bias : {};
@@ -216,12 +281,19 @@ export async function resolveExtensions(repoRoot) {
216
281
 
217
282
  // ---- Part 2: the unified corpus loader loop ---------------------------------
218
283
 
219
- /** Seed every ACTIVE `corpus`-kind entry (in the Map's own fixed order — seon,
284
+ /** Seed every ACTIVE `corpus`/`ontology`-kind entry, plus any ACTIVE `pack`-kind
285
+ * entry that declares a `corpusPath` (in the Map's own fixed order — seon,
220
286
  * conceptnet, then the rest sorted by name) into `repo`'s memory, ONE
221
287
  * seedMemory() call per bundle. Shared by chat.mjs's first-run bootstrap,
222
288
  * `tmct init`'s seed step and `tmct init --corpus <id>` — so all three read
223
289
  * the SAME loop instead of three independent hardcoded call sites.
224
290
  *
291
+ * BUGFIX (this batch): a `pack`-kind entry's `corpusPath` used to be silently
292
+ * skipped here despite this module's own docblock claiming pack entries
293
+ * combine corpus_path/lexicon_path/etc — a pack's corpus facts never made it
294
+ * into memory. Fixed by seeding any active pack entry that declares a
295
+ * corpusPath, alongside corpus/ontology entries.
296
+ *
225
297
  * FAILURE-TOLERANT per bundle (init.mjs's own doctrine: a missing/broken
226
298
  * corpus degrades to "not seeded", never a crash): one bad third-party pack's
227
299
  * seedMemory throw is CAUGHT and recorded as `perBundle[name].error` — logged
@@ -235,7 +307,16 @@ export async function seedActiveCorpusEntries(repo, entries) {
235
307
  let skipped = 0;
236
308
  let total = 0;
237
309
  for (const [name, entry] of entries instanceof Map ? entries : new Map()) {
238
- if (entry.kind !== "corpus" || !entry.active) continue;
310
+ // PLAN_SEED.md §2 bug fix: a "pack"-kind entry with its own corpusPath
311
+ // combines corpus/lexicon/templates under one active flag (this module's
312
+ // own docblock says so) but was previously never actually seeded here —
313
+ // only bare `kind: "corpus"` entries were. Broadened, not narrowed: every
314
+ // existing `kind: "corpus"`/`kind: "ontology"` entry (seon/conceptnet/
315
+ // human/tier2-*) behaves identically to before; only a pack entry that
316
+ // DOES carry a corpusPath newly qualifies.
317
+ if (!entry.active) continue;
318
+ const seedable = entry.kind === "corpus" || entry.kind === "ontology" || (entry.kind === "pack" && entry.corpusPath);
319
+ if (!seedable) continue;
239
320
  try {
240
321
  const res = await seedMemory(repo, {
241
322
  slicePath: entry.corpusPath,