@polycode-projects/the-mechanical-code-talker 1.5.4 → 1.8.3

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 (46) 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 +1391 -141
  24. package/src/cli-args.mjs +164 -0
  25. package/src/codegraph.mjs +170 -32
  26. package/src/completions/graph-adapter.mjs +118 -0
  27. package/src/extensions.mjs +100 -19
  28. package/src/grammar/ace.mjs +85 -3
  29. package/src/grammar/lexicon-core.json +9531 -63
  30. package/src/grammar/lexicon.mjs +58 -8
  31. package/src/graph-merge.mjs +114 -0
  32. package/src/index.mjs +14 -0
  33. package/src/init.mjs +40 -14
  34. package/src/interpret/normalize.mjs +88 -3
  35. package/src/interpret/strategies/grammar.mjs +10 -0
  36. package/src/interpret/strategies/keywords.mjs +20 -0
  37. package/src/interpret/strategies/noise-strip.mjs +73 -4
  38. package/src/memory/core.mjs +466 -8
  39. package/src/router/goal-reasoner.mjs +41 -7
  40. package/src/router/guardrail.mjs +37 -7
  41. package/src/router/resolver.mjs +50 -4
  42. package/src/sessions.mjs +5 -1
  43. package/src/source.mjs +54 -1
  44. package/src/syllogise.mjs +398 -27
  45. package/src/toml-config.mjs +13 -4
  46. 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. */
@@ -0,0 +1,118 @@
1
+ // completions/graph-adapter.mjs — HANDOVER.md item 1: the graphService-shaped adapter
2
+ // src/completions/search.mjs's broadSearch() was always built to accept — its own
3
+ // docblock names createGraphService(graph) (src/providers/graph-service.mjs) as the
4
+ // reference shape — but until now nothing in live chat ever constructed and passed one
5
+ // through (src/chat.mjs's completionsRescueAnswer called generateCompletion() with no
6
+ // `graphService` at all). Without it, broadSearch could only ever see memory BLOCKS
7
+ // saved via an explicit saveBlock() call — never the already-loaded code graph, and
8
+ // never a taught Fact — so "give me a detailed summary of how X works" declined for any
9
+ // subject on its first real mention in a session, no matter how much the graph or
10
+ // taught Facts actually knew about it.
11
+ //
12
+ // createCompletionsGraphAdapter(graph, memory) wraps TWO already-loaded stores (never
13
+ // re-loads either from disk — both are handed in by the caller, exactly as loaded for
14
+ // this turn):
15
+ //
16
+ // - .search(q, {limit}) delegates straight to createGraphService(graph).search() —
17
+ // the same ranked lexical module/symbol search every other Repository-Interface
18
+ // consumer uses (src/codegraph.mjs's searchModulesRanked/scoreSymbolsRanked under
19
+ // the hood). No new search machinery.
20
+ //
21
+ // - .ask(q) does NOT delegate to createGraphService(graph).ask() (src/ask.mjs) —
22
+ // that engine is a mechanical NATURAL-LANGUAGE QUESTION grammar ("which functions
23
+ // call X", "what does X import"), and broadSearch always calls .ask() with the
24
+ // bare SUBJECT TERM itself ("TaskController"), not a question. Tried live: that
25
+ // produces an honest but useless "couldn't parse this as a graph question"
26
+ // rephrase-hint every time — real text, but not about the subject, and it would
27
+ // pollute the completion with noise. Instead .ask() here builds real sentences
28
+ // from two sources that a bare term CAN resolve against directly:
29
+ // 1. resolveSymbol + renderDescribe (src/codegraph.mjs) — the SAME graph-only
30
+ // renderer src/server.mjs's own tmct_describe tool uses: real facts (defining
31
+ // module, contains, inherits, calls, tests, attributes, …), never invented.
32
+ // 2. readFactRows(memory) (src/memory/core.mjs) — any TAUGHT Fact whose subject
33
+ // or object mentions the term. This is the one source the pipeline had NO
34
+ // path to before at all: Stage 3 (inferRelations) only ever augments groups
35
+ // that already exist from Stage 1's hits, so a subject with real taught Facts
36
+ // but zero blocks/code-graph hits still surfaced nothing.
37
+ // svc.ask() is still tried last, but its content is kept ONLY when it genuinely
38
+ // parsed (tmct_ask.miss === false) — e.g. the rare case where the bare term happens
39
+ // to also be a real registered question shape — never its own rephrase-hint noise.
40
+ //
41
+ // Every sentence this adapter returns traces to a real graph edge/attribute or a real
42
+ // taught Fact — never invented, matching src/completions/'s extractive-only discipline
43
+ // (see complete.mjs's own file header).
44
+
45
+ import { createGraphService } from "../providers/graph-service.mjs";
46
+ import { resolveSymbol, renderDescribe } from "../codegraph.mjs";
47
+ import { readFactRows } from "../memory/core.mjs";
48
+
49
+ /** renderDescribe() renders one LINE per fact (label header, each attribute, each edge
50
+ * group) with no terminal punctuation of its own — fine for its own "compact plain-text
51
+ * description for an agent consumer" purpose, but src/completions/rank.mjs's
52
+ * splitSentences() treats each line as its own candidate sentence, and complete.mjs
53
+ * joins kept sentences with a single space — so two adjacent kept lines without a
54
+ * period between them would otherwise read as one run-on clause. Ensuring every line
55
+ * ends in terminal punctuation here (never rewording/reordering the line itself) is
56
+ * the cheapest fix that stays entirely inside this adapter, touching neither
57
+ * renderDescribe() (server.mjs's tmct_describe tool relies on its current line shape)
58
+ * nor rank.mjs/complete.mjs's own join logic. */
59
+ function withTerminalPunctuation(text) {
60
+ return String(text || "")
61
+ .split("\n")
62
+ .map((line) => line.trim())
63
+ .filter(Boolean)
64
+ .map((line) => (/[.!?]$/.test(line) ? line : `${line}.`))
65
+ .join("\n");
66
+ }
67
+
68
+ /**
69
+ * @param {object|null} graph a parseEntities() result (src/codegraph.mjs), or null
70
+ * when no code graph is loaded — search()/the describe-half of ask() then honestly
71
+ * contribute nothing, rather than throwing.
72
+ * @param {object|null} [memory=null] a loadMemory() payload (src/memory/core.mjs), or
73
+ * null when there's no Fact store to search — the Fact-half of ask() then honestly
74
+ * contributes nothing.
75
+ * @returns {{search: Function, ask: Function}} a Repository-Interface-shaped
76
+ * graphService satisfying src/completions/search.mjs's broadSearch() contract.
77
+ */
78
+ export function createCompletionsGraphAdapter(graph, memory = null) {
79
+ const svc = graph ? createGraphService(graph) : null;
80
+
81
+ return {
82
+ search(q, { limit } = {}) {
83
+ if (!svc) return { ok: true, value: { results: [] } };
84
+ return svc.search(q, { limit });
85
+ },
86
+
87
+ ask(q) {
88
+ const term = String(q || "").trim();
89
+ if (!term) return { ok: true, value: { content: "" } };
90
+ const sentences = [];
91
+
92
+ if (svc) {
93
+ const { match, candidates } = resolveSymbol(graph, term);
94
+ if (match) sentences.push(withTerminalPunctuation(renderDescribe(graph, match, { candidates })));
95
+ }
96
+
97
+ if (memory) {
98
+ const needle = term.toLowerCase();
99
+ for (const row of readFactRows(memory)) {
100
+ if (!row.subject || !row.predicate || !row.object) continue;
101
+ const haystack = `${row.subject} ${row.object}`.toLowerCase();
102
+ if (!haystack.includes(needle)) continue;
103
+ sentences.push(`${row.subject} ${row.predicate} ${row.object}.`.trim());
104
+ }
105
+ }
106
+
107
+ if (svc) {
108
+ const res = svc.ask(term);
109
+ if (res?.ok && res.value?.tmct_ask?.miss === false && res.value.content) {
110
+ sentences.push(res.value.content);
111
+ }
112
+ }
113
+
114
+ if (!sentences.length) return { ok: true, value: { content: "" } };
115
+ return { ok: true, value: { content: sentences.join(" ") } };
116
+ },
117
+ };
118
+ }