@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.
- package/README.md +123 -14
- package/ROADMAP.md +233 -1392
- package/bin/tmct.mjs +479 -98
- package/corpus/README.md +3 -0
- package/corpus/generated/README.md +43 -0
- package/corpus/generated/ace-surface-variants.jsonl +17 -0
- package/corpus/generated/manifest.json +9 -0
- package/corpus/tier2/generate.mjs +14668 -0
- package/corpus/tier2/human-examples-large.jsonl +1928 -0
- package/corpus/tier2/human-examples-medium.jsonl +356 -0
- package/corpus/tier2/human-examples.jsonl +120 -0
- package/corpus/tier2/human-large.jsonl +12001 -0
- package/corpus/tier2/human-medium.jsonl +944 -0
- package/corpus/tier2/human.jsonl +664 -0
- package/corpus/tier2/manifest.json +42 -0
- package/package.json +14 -8
- package/src/answer-variants.json +47 -0
- package/src/answer-variants.mjs +67 -0
- package/src/ask-browser-entry.mjs +34 -0
- package/src/ask-browser.bundle.js +5095 -0
- package/src/ask-vocab.mjs +93 -8
- package/src/ask.mjs +451 -49
- package/src/chat.mjs +1391 -141
- package/src/cli-args.mjs +164 -0
- package/src/codegraph.mjs +170 -32
- package/src/completions/graph-adapter.mjs +118 -0
- package/src/extensions.mjs +100 -19
- package/src/grammar/ace.mjs +85 -3
- package/src/grammar/lexicon-core.json +9531 -63
- package/src/grammar/lexicon.mjs +58 -8
- package/src/graph-merge.mjs +114 -0
- package/src/index.mjs +14 -0
- package/src/init.mjs +40 -14
- package/src/interpret/normalize.mjs +88 -3
- package/src/interpret/strategies/grammar.mjs +10 -0
- package/src/interpret/strategies/keywords.mjs +20 -0
- package/src/interpret/strategies/noise-strip.mjs +73 -4
- package/src/memory/core.mjs +466 -8
- package/src/router/goal-reasoner.mjs +41 -7
- package/src/router/guardrail.mjs +37 -7
- package/src/router/resolver.mjs +50 -4
- package/src/sessions.mjs +5 -1
- package/src/source.mjs +54 -1
- package/src/syllogise.mjs +398 -27
- package/src/toml-config.mjs +13 -4
- package/src/viz.mjs +541 -0
package/src/cli-args.mjs
ADDED
|
@@ -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
|
-
|
|
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(
|
|
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
|
|
711
|
-
* fewest-arcs-first via a min-heap keyed (hop ASC,
|
|
712
|
-
* degree-quantile hub gate at each expansion step. Emits
|
|
713
|
-
* in pop order, scoring each seed-relative and bounded so
|
|
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
|
|
718
|
-
|
|
719
|
-
|
|
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
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
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
|
-
|
|
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
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
existing
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
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
|
|
783
|
-
// degree, keeping the lowest-degree ⌊q·n⌋ (drop the densest hubs), never
|
|
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
|
|
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
|
+
}
|