@polycode-projects/the-mechanical-code-talker 1.3.2 → 1.4.0
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 +141 -7
- package/bin/tmct.mjs +128 -18
- package/package.json +1 -1
- package/src/chat.mjs +119 -72
- package/src/codegraph.mjs +73 -4
- package/src/config.mjs +7 -2
- package/src/conformance.mjs +59 -15
- package/src/corpus/templates.mjs +38 -0
- package/src/extensions.mjs +348 -0
- package/src/init.mjs +92 -7
- package/src/memory/bias.mjs +77 -0
- package/src/memory/blocks.mjs +57 -18
- package/src/memory/core.mjs +237 -20
- package/src/memory/fold.mjs +0 -0
- package/src/memory/trust.mjs +94 -6
- package/src/providers/bootstrap.mjs +5 -3
- package/src/providers/fixture.mjs +7 -3
- package/src/providers/graph-service.mjs +205 -28
- package/src/repository-interface.mjs +21 -7
- package/src/server.mjs +39 -29
- package/src/source-slice.mjs +68 -0
- package/src/telemetry.mjs +5 -2
- package/src/toml-config.mjs +17 -0
package/src/conformance.mjs
CHANGED
|
@@ -17,7 +17,6 @@ import {
|
|
|
17
17
|
MISS_REASONS,
|
|
18
18
|
EDGE_KINDS,
|
|
19
19
|
SERVICES,
|
|
20
|
-
SOURCE_SERVICES,
|
|
21
20
|
isHit,
|
|
22
21
|
isMiss,
|
|
23
22
|
} from "./repository-interface.mjs";
|
|
@@ -68,9 +67,12 @@ export function runConformance(name, makeProvider) {
|
|
|
68
67
|
}
|
|
69
68
|
});
|
|
70
69
|
|
|
71
|
-
test(`[${name}] every service returns a well-formed Result (or an honest empty)`, () => {
|
|
70
|
+
test(`[${name}] every service returns a well-formed Result (or an honest empty)`, async () => {
|
|
72
71
|
const svc = makeProvider();
|
|
73
72
|
// Resolution-family with a term that certainly does not exist → a well-formed result.
|
|
73
|
+
// Awaiting every call is safe regardless of sync/async: snippet/context are ASYNC (real fs
|
|
74
|
+
// reads are inherently async — see repository-interface.mjs's notes on both), everything
|
|
75
|
+
// else is a plain sync value; `await` on a non-Promise value is a documented no-op.
|
|
74
76
|
for (const [service, args] of [
|
|
75
77
|
["resolve", ["definitely-not-a-symbol-xyz"]],
|
|
76
78
|
["describe", ["no:such:id"]],
|
|
@@ -88,7 +90,7 @@ export function runConformance(name, makeProvider) {
|
|
|
88
90
|
["search", ["", {}]],
|
|
89
91
|
["ask", ["what is here"]],
|
|
90
92
|
]) {
|
|
91
|
-
const r = svc[service](...args);
|
|
93
|
+
const r = await svc[service](...args);
|
|
92
94
|
assertResult(r, `${name}.${service}`);
|
|
93
95
|
}
|
|
94
96
|
});
|
|
@@ -112,20 +114,62 @@ export function runConformance(name, makeProvider) {
|
|
|
112
114
|
assert.throws(() => svc.edges("no:such:id", "not-a-real-kind"), TypeError);
|
|
113
115
|
});
|
|
114
116
|
|
|
115
|
-
|
|
117
|
+
// snippet: UNCHANGED by INTERFACE_VERSION 1.1.0 — it has nothing useful without fs, so it
|
|
118
|
+
// still honestly misses NO_SOURCE (or UNRESOLVED_TERM on an empty graph) with no working tree.
|
|
119
|
+
test(`[${name}] snippet answers NO_SOURCE (not a throw) when no working tree`, async () => {
|
|
116
120
|
const svc = makeProvider();
|
|
117
|
-
if (svc.sourceAccess) return; //
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
121
|
+
if (svc.sourceAccess) return; // covered by the source-capable branch below instead
|
|
122
|
+
// Use whatever the provider resolves; on empty graphs this is UNRESOLVED_TERM, on
|
|
123
|
+
// data-bearing graphs NO_SOURCE — both are valid closed-set misses.
|
|
124
|
+
const r = await svc.snippet("no:such:id");
|
|
125
|
+
assert.ok(isMiss(r), "snippet misses without a working tree");
|
|
126
|
+
assert.ok(
|
|
127
|
+
[MISS_REASONS.NO_SOURCE, MISS_REASONS.UNRESOLVED_TERM].includes(r.miss.reason),
|
|
128
|
+
`snippet miss reason is NO_SOURCE or UNRESOLVED_TERM (got ${r.miss.reason})`,
|
|
129
|
+
);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// context: NARROWED by INTERFACE_VERSION 1.1.0 — contextPlan/sizeBundle/renderGraphOnlyBundle
|
|
133
|
+
// are pure graph queries, so a graph-only provider (no working tree) now returns a REAL HIT
|
|
134
|
+
// for any resolvable symbol; only an unresolvable symbol still misses (UNRESOLVED_TERM). See
|
|
135
|
+
// repository-interface.mjs's context service entry for the full rationale.
|
|
136
|
+
test(`[${name}] context returns a graph-only HIT for a resolvable symbol, even with no working tree`, async () => {
|
|
137
|
+
const svc = makeProvider();
|
|
138
|
+
if (svc.sourceAccess) return; // covered by the source-capable branch below instead
|
|
139
|
+
const missR = await svc.context("definitely-not-a-symbol-xyz");
|
|
140
|
+
assert.ok(isMiss(missR) && missR.miss.reason === MISS_REASONS.UNRESOLVED_TERM, "an unresolvable symbol still misses UNRESOLVED_TERM");
|
|
141
|
+
// A real hit needs a REAL resolvable symbol — untested() is a required, provider-agnostic
|
|
142
|
+
// service that already returns real Module individuals when the graph carries any (empty on
|
|
143
|
+
// a bootstrap-shaped graph, so this degrades gracefully rather than assuming data exists).
|
|
144
|
+
const modules = svc.untested().value.modules;
|
|
145
|
+
if (!modules.length) return;
|
|
146
|
+
const r = await svc.context(modules[0].label);
|
|
147
|
+
assert.ok(isHit(r), `context(${modules[0].label}) is a graph-only hit, not NO_SOURCE (INTERFACE_VERSION 1.1.0)`);
|
|
148
|
+
assert.equal(typeof r.value.text, "string");
|
|
149
|
+
assert.equal(typeof r.value.tier, "string");
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
// 2f: the source-capable branch — dead code until a provider actually sets sourceAccess:true
|
|
153
|
+
// (test/repository-interface.test.mjs's third runConformance call, against a source-capable
|
|
154
|
+
// fixture provider, is what makes this execute at all).
|
|
155
|
+
test(`[${name}] source-capable: snippet/context return real body text for a resolvable spanned symbol`, async () => {
|
|
156
|
+
const svc = makeProvider();
|
|
157
|
+
if (!svc.sourceAccess) return; // only the source-capable branch reaches this
|
|
158
|
+
// Provider-agnostic: use the provider's OWN search() to find any real function/class/method
|
|
159
|
+
// — no fixture-specific symbol names hardcoded here.
|
|
160
|
+
let symbol = null;
|
|
161
|
+
for (const kind of ["function", "class", "method"]) {
|
|
162
|
+
const found = svc.search("", { kind, limit: 1 }).value.results[0];
|
|
163
|
+
if (found) { symbol = found; break; }
|
|
128
164
|
}
|
|
165
|
+
if (!symbol) return; // this provider's graph carries no spanned symbol to prove the branch against
|
|
166
|
+
const snip = await svc.snippet(symbol.id);
|
|
167
|
+
assert.ok(isHit(snip), `snippet(${symbol.id}) is a real hit when source-capable`);
|
|
168
|
+
assert.equal(typeof snip.value.body, "string");
|
|
169
|
+
assert.ok(snip.value.body.length > 0, "a real (non-empty) source body, not null");
|
|
170
|
+
const ctx = await svc.context(symbol.label);
|
|
171
|
+
assert.ok(isHit(ctx), `context(${symbol.label}) is a hit when source-capable`);
|
|
172
|
+
assert.equal(typeof ctx.value.text, "string");
|
|
129
173
|
});
|
|
130
174
|
|
|
131
175
|
test(`[${name}] stats / untested / architecture never miss — empty is a hit`, () => {
|
package/src/corpus/templates.mjs
CHANGED
|
@@ -152,6 +152,44 @@ export async function loadTemplates(path = TEMPLATES_FILE) {
|
|
|
152
152
|
return byId;
|
|
153
153
|
}
|
|
154
154
|
|
|
155
|
+
/** Load + merge several EXTENSION-PACK template files (each independently
|
|
156
|
+
* validated by loadTemplates() — same loud-on-malformed-row guarantee) into
|
|
157
|
+
* one Map<id,row>. Every extension-pack template id MUST be namespaced
|
|
158
|
+
* "<packname>:<id>" — enforced here as a validation rule (also the check
|
|
159
|
+
* Part 4's validateExtensionPack runs on a single candidate file), rather
|
|
160
|
+
* than inventing a same-id collision-precedence policy: a bare, unnamespaced
|
|
161
|
+
* id, or the SAME id appearing under two different paths, both throw loudly
|
|
162
|
+
* naming the offending path/id.
|
|
163
|
+
*
|
|
164
|
+
* loadTemplates(path) mutates the module's own render()-serving `cache` as a
|
|
165
|
+
* side effect; this function restores it to whatever it was before the merge
|
|
166
|
+
* ran, so calling loadTemplatesMerged() never clobbers the "current" default
|
|
167
|
+
* templates map for an unrelated caller (e.g. a concurrent render() call
|
|
168
|
+
* elsewhere in the same process). The returned map is NOT installed as the
|
|
169
|
+
* render() default — a caller that wants render() to serve the merged set
|
|
170
|
+
* passes it explicitly: render(id, slots, mergedMap). */
|
|
171
|
+
export async function loadTemplatesMerged(paths = []) {
|
|
172
|
+
const savedCache = cache;
|
|
173
|
+
const merged = new Map();
|
|
174
|
+
try {
|
|
175
|
+
for (const path of paths) {
|
|
176
|
+
const rows = await loadTemplates(path);
|
|
177
|
+
for (const [id, row] of rows) {
|
|
178
|
+
if (!id.includes(":")) {
|
|
179
|
+
throw new Error(`${path}: extension-pack template id "${id}" is not namespaced ("<packname>:<id>")`);
|
|
180
|
+
}
|
|
181
|
+
if (merged.has(id)) {
|
|
182
|
+
throw new Error(`${path}: duplicate template id "${id}" already loaded from an earlier path`);
|
|
183
|
+
}
|
|
184
|
+
merged.set(id, row);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
} finally {
|
|
188
|
+
cache = savedCache;
|
|
189
|
+
}
|
|
190
|
+
return merged;
|
|
191
|
+
}
|
|
192
|
+
|
|
155
193
|
/** Fill template `id` with `slots` — strict: unknown id throws; ANY missing
|
|
156
194
|
* slot throws (named), so a response is complete or not emitted at all.
|
|
157
195
|
* Extra slots are ignored. Uses the map from loadTemplates() (pass
|
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
// extensions.mjs — the extension-pack seam: one place a host repo (or a
|
|
2
|
+
// third-party package such as seonix/marginalia) declares which corpus/
|
|
3
|
+
// lexicon/templates bundles feed tmct, and how much each bundle's facts are
|
|
4
|
+
// trusted relative to the others.
|
|
5
|
+
//
|
|
6
|
+
// resolveExtensions(repoRoot) → { entries: Map<name, ResolvedEntry>, biasByBundle }
|
|
7
|
+
//
|
|
8
|
+
// BUILTIN_EXTENSIONS ships the exact two bundles chat.mjs's bootstrap has
|
|
9
|
+
// always seeded — `seon` and `conceptnet`, both active — plus three shipped-
|
|
10
|
+
// but-INACTIVE tier-2 bundles (`tier2-aws` / `tier2-python` / `tier2-java`).
|
|
11
|
+
// Activating one is a config-only edit (`tmct init --corpus aws`, or a
|
|
12
|
+
// `[extensions.tier2-aws] active = true` in tmct.toml) — zero code change.
|
|
13
|
+
//
|
|
14
|
+
// A `tmct.toml` may carry a top-level `[extensions]` table-of-tables
|
|
15
|
+
// (`[extensions.tier2-aws]`, …): a RECOGNIZED name (one of the builtins above)
|
|
16
|
+
// may override `active`/paths/etc; an UNRECOGNIZED name declares a brand new
|
|
17
|
+
// host entry and MUST carry a `kind` (corpus | lexicon | templates | pack) — a
|
|
18
|
+
// `pack` entry may combine any of corpus_path/lexicon_path/templates_path/
|
|
19
|
+
// phrasebook_path under one `active` flag and one provenance name, the shape a
|
|
20
|
+
// third-party vocabulary package hands tmct.
|
|
21
|
+
//
|
|
22
|
+
// A SEPARATE top-level `[bias]` table (flat: bundle-name → number) feeds
|
|
23
|
+
// src/memory/bias.mjs's ranking — never nested under `[extensions.*]`.
|
|
24
|
+
//
|
|
25
|
+
// Entries are returned in a FIXED, deterministic order — `seon` first, then
|
|
26
|
+
// `conceptnet`, then every other entry sorted by name — mirroring the
|
|
27
|
+
// seon-before-conceptnet idempotency-ordering precedent chat.mjs's
|
|
28
|
+
// seedBootstrapMemory already establishes (seon's curated facts should win the
|
|
29
|
+
// content-hash idempotency race over general ConceptNet noise).
|
|
30
|
+
|
|
31
|
+
import { isAbsolute, join, resolve } from "node:path";
|
|
32
|
+
import { readFile } from "node:fs/promises";
|
|
33
|
+
import { loadTomlConfig } from "./toml-config.mjs";
|
|
34
|
+
import {
|
|
35
|
+
SEON_CONCEPTS_FILE,
|
|
36
|
+
SLICE_FILE as CONCEPTNET_SLICE_FILE,
|
|
37
|
+
MAP_FILE as CONCEPTNET_MAP_FILE,
|
|
38
|
+
TIER2_DIR,
|
|
39
|
+
loadSlice,
|
|
40
|
+
loadMap,
|
|
41
|
+
toFacts,
|
|
42
|
+
} from "./corpus/conceptnet.mjs";
|
|
43
|
+
|
|
44
|
+
export const EXTENSION_KINDS = Object.freeze(["corpus", "lexicon", "templates", "pack"]);
|
|
45
|
+
|
|
46
|
+
// The definitional-band-first predicate order chat.mjs's bootstrap has always
|
|
47
|
+
// passed for the ConceptNet seed (SEED_PREFER) — re-declared here (not
|
|
48
|
+
// imported from chat.mjs) to keep this module off chat.mjs's heavy graph, the
|
|
49
|
+
// same "re-declare, don't import" discipline init.mjs's own SEED_PREFER uses.
|
|
50
|
+
const CONCEPTNET_PREFER = ["rdfs:subClassOf", "rdf:type", "mgx:usedFor", "mgx:partOf", "mgx:capableOf"];
|
|
51
|
+
|
|
52
|
+
/** The shipped defaults — a FRESH object per call, so a caller can never
|
|
53
|
+
* accidentally mutate a module-level singleton. */
|
|
54
|
+
function builtinExtensions() {
|
|
55
|
+
return {
|
|
56
|
+
seon: {
|
|
57
|
+
kind: "corpus",
|
|
58
|
+
active: true,
|
|
59
|
+
corpusPath: SEON_CONCEPTS_FILE,
|
|
60
|
+
provenancePrefix: "corpus:seon",
|
|
61
|
+
},
|
|
62
|
+
conceptnet: {
|
|
63
|
+
kind: "corpus",
|
|
64
|
+
active: true,
|
|
65
|
+
corpusPath: CONCEPTNET_SLICE_FILE,
|
|
66
|
+
provenancePrefix: "corpus:conceptnet",
|
|
67
|
+
// matches chat.mjs's seedBootstrapMemory exactly: uncapped, definitional
|
|
68
|
+
// band first.
|
|
69
|
+
limit: undefined,
|
|
70
|
+
prefer: CONCEPTNET_PREFER,
|
|
71
|
+
},
|
|
72
|
+
"tier2-aws": {
|
|
73
|
+
kind: "corpus",
|
|
74
|
+
active: false,
|
|
75
|
+
corpusPath: join(TIER2_DIR, "aws.jsonl"),
|
|
76
|
+
provenancePrefix: "corpus:tier2-aws",
|
|
77
|
+
},
|
|
78
|
+
"tier2-python": {
|
|
79
|
+
kind: "corpus",
|
|
80
|
+
active: false,
|
|
81
|
+
corpusPath: join(TIER2_DIR, "python.jsonl"),
|
|
82
|
+
provenancePrefix: "corpus:tier2-python",
|
|
83
|
+
},
|
|
84
|
+
"tier2-java": {
|
|
85
|
+
kind: "corpus",
|
|
86
|
+
active: false,
|
|
87
|
+
corpusPath: join(TIER2_DIR, "java.jsonl"),
|
|
88
|
+
provenancePrefix: "corpus:tier2-java",
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export const BUILTIN_EXTENSIONS = Object.freeze(builtinExtensions());
|
|
94
|
+
|
|
95
|
+
/** Validate one RESOLVED extension entry — throws a clear, specific error
|
|
96
|
+
* naming the offending key. Shared by resolveExtensions (every entry, always
|
|
97
|
+
* on) and Part 4's validateExtensionPack (a candidate pack directory). */
|
|
98
|
+
export function validateExtensionEntry(name, entry) {
|
|
99
|
+
if (!entry || typeof entry !== "object") {
|
|
100
|
+
throw new Error(`extension "${name}": entry must be an object`);
|
|
101
|
+
}
|
|
102
|
+
if (!EXTENSION_KINDS.includes(entry.kind)) {
|
|
103
|
+
throw new Error(`extension "${name}": unknown kind ${JSON.stringify(entry.kind)} (must be one of ${EXTENSION_KINDS.join(", ")})`);
|
|
104
|
+
}
|
|
105
|
+
if (entry.active !== undefined && typeof entry.active !== "boolean") {
|
|
106
|
+
throw new Error(`extension "${name}": "active" must be a boolean`);
|
|
107
|
+
}
|
|
108
|
+
if (entry.kind === "corpus" && !entry.corpusPath) {
|
|
109
|
+
throw new Error(`extension "${name}": a "corpus" entry needs corpus_path`);
|
|
110
|
+
}
|
|
111
|
+
if (entry.kind === "lexicon" && !entry.lexiconPath) {
|
|
112
|
+
throw new Error(`extension "${name}": a "lexicon" entry needs lexicon_path`);
|
|
113
|
+
}
|
|
114
|
+
if (entry.kind === "templates" && !entry.templatesPath) {
|
|
115
|
+
throw new Error(`extension "${name}": a "templates" entry needs templates_path`);
|
|
116
|
+
}
|
|
117
|
+
if (entry.kind === "pack" && !entry.corpusPath && !entry.lexiconPath && !entry.templatesPath && !entry.phrasebookPath) {
|
|
118
|
+
throw new Error(`extension "${name}": a "pack" entry needs at least one of corpus_path/lexicon_path/templates_path/phrasebook_path`);
|
|
119
|
+
}
|
|
120
|
+
if (entry.limit !== undefined && !Number.isFinite(entry.limit)) {
|
|
121
|
+
throw new Error(`extension "${name}": "limit" must be a finite number`);
|
|
122
|
+
}
|
|
123
|
+
if (entry.prefer !== undefined && !Array.isArray(entry.prefer)) {
|
|
124
|
+
throw new Error(`extension "${name}": "prefer" must be an array of predicate URIs`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const resolvePathMaybe = (repoRoot, p) => {
|
|
129
|
+
if (p === undefined || p === null) return undefined;
|
|
130
|
+
const s = String(p);
|
|
131
|
+
return isAbsolute(s) ? s : resolve(repoRoot, s);
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
/** Merge a builtin default (or null, for a host-declared entry) with a raw
|
|
135
|
+
* `[extensions.<name>]` TOML override into one RESOLVED entry (camelCase
|
|
136
|
+
* fields, paths resolved against repoRoot). */
|
|
137
|
+
function mergeExtensionEntry(name, builtin, override, repoRoot) {
|
|
138
|
+
if (!builtin && override.kind === undefined) {
|
|
139
|
+
throw new Error(`extension "${name}": an unrecognized extension needs a "kind" (one of ${EXTENSION_KINDS.join(", ")})`);
|
|
140
|
+
}
|
|
141
|
+
const entry = {
|
|
142
|
+
kind: override.kind !== undefined ? override.kind : builtin?.kind,
|
|
143
|
+
active: override.active !== undefined ? Boolean(override.active) : Boolean(builtin?.active),
|
|
144
|
+
};
|
|
145
|
+
const paths = [
|
|
146
|
+
["corpus_path", "corpusPath"],
|
|
147
|
+
["lexicon_path", "lexiconPath"],
|
|
148
|
+
["templates_path", "templatesPath"],
|
|
149
|
+
["phrasebook_path", "phrasebookPath"],
|
|
150
|
+
["map_path", "mapPath"],
|
|
151
|
+
];
|
|
152
|
+
for (const [rawKey, key] of paths) {
|
|
153
|
+
if (override[rawKey] !== undefined) entry[key] = resolvePathMaybe(repoRoot, override[rawKey]);
|
|
154
|
+
else if (builtin?.[key] !== undefined) entry[key] = builtin[key];
|
|
155
|
+
}
|
|
156
|
+
entry.provenancePrefix = override.provenance_prefix !== undefined
|
|
157
|
+
? String(override.provenance_prefix)
|
|
158
|
+
: (builtin?.provenancePrefix ?? `corpus:${name}`);
|
|
159
|
+
if (override.limit !== undefined) entry.limit = Number(override.limit);
|
|
160
|
+
else if (builtin?.limit !== undefined) entry.limit = builtin.limit;
|
|
161
|
+
if (override.prefer !== undefined) entry.prefer = override.prefer;
|
|
162
|
+
else if (builtin?.prefer !== undefined) entry.prefer = builtin.prefer;
|
|
163
|
+
return entry;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Resolve every extension entry a repo carries — the shipped builtins plus
|
|
168
|
+
* whatever `tmct.toml`'s `[extensions]`/`[bias]` tables add or override.
|
|
169
|
+
* Returns `{ entries, biasByBundle }`:
|
|
170
|
+
* - `entries`: Map<name, ResolvedEntry> in FIXED order (seon, conceptnet,
|
|
171
|
+
* then the rest sorted by name). EVERY entry is present (active or not) —
|
|
172
|
+
* callers filter by `.active` themselves (Part 2's corpus loader loop).
|
|
173
|
+
* - `biasByBundle`: { bundleName: number } from the flat top-level `[bias]`
|
|
174
|
+
* table (default {} — every bundle then ranks at bias 1, see bias.mjs).
|
|
175
|
+
* No `tmct.toml` (or one with no `[extensions]`/`[bias]` tables) resolves to
|
|
176
|
+
* exactly today's implicit seon+conceptnet default, byte-identical.
|
|
177
|
+
*/
|
|
178
|
+
export async function resolveExtensions(repoRoot) {
|
|
179
|
+
const raw = repoRoot ? await loadTomlConfig(repoRoot) : null;
|
|
180
|
+
const defs = builtinExtensions();
|
|
181
|
+
const rawExtensions = (raw && raw.extensions && typeof raw.extensions === "object") ? raw.extensions : {};
|
|
182
|
+
const rawBias = (raw && raw.bias && typeof raw.bias === "object") ? raw.bias : {};
|
|
183
|
+
|
|
184
|
+
const names = new Set([...Object.keys(defs), ...Object.keys(rawExtensions)]);
|
|
185
|
+
const resolved = new Map();
|
|
186
|
+
for (const name of names) {
|
|
187
|
+
const entry = mergeExtensionEntry(name, defs[name] || null, rawExtensions[name] || {}, repoRoot || process.cwd());
|
|
188
|
+
validateExtensionEntry(name, entry);
|
|
189
|
+
resolved.set(name, entry);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const biasByBundle = {};
|
|
193
|
+
for (const [name, value] of Object.entries(rawBias)) {
|
|
194
|
+
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
195
|
+
throw new Error(`[bias] "${name}": bias must be a finite number, got ${JSON.stringify(value)}`);
|
|
196
|
+
}
|
|
197
|
+
biasByBundle[name] = value;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const rest = [...resolved.keys()].filter((n) => n !== "seon" && n !== "conceptnet").sort();
|
|
201
|
+
const orderedNames = ["seon", "conceptnet", ...rest].filter((n) => resolved.has(n));
|
|
202
|
+
const entries = new Map(orderedNames.map((n) => [n, resolved.get(n)]));
|
|
203
|
+
|
|
204
|
+
return { entries, biasByBundle };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// ---- Part 2: the unified corpus loader loop ---------------------------------
|
|
208
|
+
|
|
209
|
+
/** Seed every ACTIVE `corpus`-kind entry (in the Map's own fixed order — seon,
|
|
210
|
+
* conceptnet, then the rest sorted by name) into `repo`'s memory, ONE
|
|
211
|
+
* seedMemory() call per bundle. Shared by chat.mjs's first-run bootstrap,
|
|
212
|
+
* `tmct init`'s seed step and `tmct init --corpus <id>` — so all three read
|
|
213
|
+
* the SAME loop instead of three independent hardcoded call sites.
|
|
214
|
+
*
|
|
215
|
+
* FAILURE-TOLERANT per bundle (init.mjs's own doctrine: a missing/broken
|
|
216
|
+
* corpus degrades to "not seeded", never a crash): one bad third-party pack's
|
|
217
|
+
* seedMemory throw is CAUGHT and recorded as `perBundle[name].error` — logged
|
|
218
|
+
* in the structured result rather than silently swallowed — while every
|
|
219
|
+
* OTHER bundle still seeds normally. Returns
|
|
220
|
+
* `{ appended, skipped, total, perBundle: { name: {appended,skipped,total,error?} } }`. */
|
|
221
|
+
export async function seedActiveCorpusEntries(repo, entries) {
|
|
222
|
+
const { seedMemory } = await import("./corpus/conceptnet.mjs");
|
|
223
|
+
const perBundle = {};
|
|
224
|
+
let appended = 0;
|
|
225
|
+
let skipped = 0;
|
|
226
|
+
let total = 0;
|
|
227
|
+
for (const [name, entry] of entries instanceof Map ? entries : new Map()) {
|
|
228
|
+
if (entry.kind !== "corpus" || !entry.active) continue;
|
|
229
|
+
try {
|
|
230
|
+
const res = await seedMemory(repo, {
|
|
231
|
+
slicePath: entry.corpusPath,
|
|
232
|
+
mapPath: entry.mapPath,
|
|
233
|
+
provenancePrefix: entry.provenancePrefix,
|
|
234
|
+
limit: entry.limit,
|
|
235
|
+
prefer: entry.prefer,
|
|
236
|
+
});
|
|
237
|
+
perBundle[name] = { appended: res.appended, skipped: res.skipped, total: res.total };
|
|
238
|
+
appended += res.appended;
|
|
239
|
+
skipped += res.skipped;
|
|
240
|
+
total += res.total;
|
|
241
|
+
} catch (err) {
|
|
242
|
+
// Logged (in the structured result), never silently swallowed — but this
|
|
243
|
+
// ONE bundle's failure never aborts the others.
|
|
244
|
+
perBundle[name] = { appended: 0, skipped: 0, total: 0, error: err && err.message ? err.message : String(err) };
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
return { appended, skipped, total, perBundle };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// ---- Part 3: lexicon-bundle merge -------------------------------------------
|
|
251
|
+
|
|
252
|
+
/** Merge every ACTIVE `lexicon`/`pack` entry's declared lexicon file into one
|
|
253
|
+
* `{nouns, verbs, adjectives, properNames}` object — the exact shape
|
|
254
|
+
* grammar/lexicon.mjs's `loadLexicon(extra)` already accepts. Bundles merge
|
|
255
|
+
* in ASCENDING bias order (lowest first) so `loadLexicon`'s existing "extra
|
|
256
|
+
* entries win on conflict" last-write-wins semantics resolve a same-lemma
|
|
257
|
+
* collision by BIAS, deterministically, rather than by arbitrary load order —
|
|
258
|
+
* a higher-bias bundle's entry always wins. Ties (equal/absent bias) keep the
|
|
259
|
+
* entries' `entries` Map iteration order (itself the fixed seon/conceptnet/
|
|
260
|
+
* sorted-rest order). Entries with no lexiconPath are skipped. Returns `null`
|
|
261
|
+
* when nothing merges (so a caller can pass `undefined` through to
|
|
262
|
+
* `loadLexicon` unchanged — the byte-identical no-extension default). */
|
|
263
|
+
export async function mergedLexiconExtra(entries, biasByBundle = {}) {
|
|
264
|
+
const candidates = [];
|
|
265
|
+
for (const [name, entry] of entries instanceof Map ? entries : new Map()) {
|
|
266
|
+
if (!entry.active) continue;
|
|
267
|
+
if (entry.kind !== "lexicon" && entry.kind !== "pack") continue;
|
|
268
|
+
if (!entry.lexiconPath) continue;
|
|
269
|
+
candidates.push({ name, path: entry.lexiconPath, bias: biasByBundle[name] ?? 1 });
|
|
270
|
+
}
|
|
271
|
+
if (!candidates.length) return null;
|
|
272
|
+
// stable ascending-bias sort: ties keep the Map's own (fixed) iteration order.
|
|
273
|
+
candidates.sort((a, b) => a.bias - b.bias);
|
|
274
|
+
const merged = { nouns: {}, verbs: {}, adjectives: {}, properNames: [] };
|
|
275
|
+
for (const c of candidates) {
|
|
276
|
+
let raw;
|
|
277
|
+
try {
|
|
278
|
+
raw = JSON.parse(await readFile(c.path, "utf8"));
|
|
279
|
+
} catch (e) {
|
|
280
|
+
throw new Error(`extension "${c.name}": lexicon file ${c.path} — ${e && e.message ? e.message : e}`);
|
|
281
|
+
}
|
|
282
|
+
Object.assign(merged.nouns, raw.nouns || {});
|
|
283
|
+
Object.assign(merged.verbs, raw.verbs || {});
|
|
284
|
+
Object.assign(merged.adjectives, raw.adjectives || {});
|
|
285
|
+
if (Array.isArray(raw.properNames)) {
|
|
286
|
+
for (const n of raw.properNames) if (!merged.properNames.includes(n)) merged.properNames.push(n);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
return merged;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// ---- Part 4: `tmct extend --validate <dir>` ---------------------------------
|
|
293
|
+
|
|
294
|
+
/** Validate one CANDIDATE extension pack entry against a directory — reuses
|
|
295
|
+
* the existing throw-loudly primitives (loadSlice/loadMap/toFacts,
|
|
296
|
+
* loadLexicon, loadTemplates) rather than inventing new shape-checking logic.
|
|
297
|
+
* `candidate` is a resolved-shape entry (see mergeExtensionEntry) whose paths
|
|
298
|
+
* are absolute or resolved against `dir`. Returns
|
|
299
|
+
* `{ ok, results: [{kind, path, ok, error?, counts?}] }` — never throws;
|
|
300
|
+
* every failure is CAUGHT and reported as one `results[]` row. */
|
|
301
|
+
export async function validateExtensionPack(dir, candidate) {
|
|
302
|
+
const results = [];
|
|
303
|
+
const abs = (p) => (p ? (isAbsolute(p) ? p : resolve(dir, p)) : p);
|
|
304
|
+
|
|
305
|
+
if (candidate.corpusPath) {
|
|
306
|
+
const path = abs(candidate.corpusPath);
|
|
307
|
+
try {
|
|
308
|
+
const assertions = await loadSlice(path);
|
|
309
|
+
const map = await loadMap(abs(candidate.mapPath) || CONCEPTNET_MAP_FILE);
|
|
310
|
+
const facts = toFacts(assertions, map, candidate.provenancePrefix || "corpus:pack");
|
|
311
|
+
results.push({ kind: "corpus", path, ok: true, counts: { assertions: assertions.length, facts: facts.length } });
|
|
312
|
+
} catch (e) {
|
|
313
|
+
results.push({ kind: "corpus", path, ok: false, error: e && e.message ? e.message : String(e) });
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
if (candidate.lexiconPath) {
|
|
318
|
+
const path = abs(candidate.lexiconPath);
|
|
319
|
+
try {
|
|
320
|
+
const { loadLexicon } = await import("./grammar/lexicon.mjs");
|
|
321
|
+
const raw = JSON.parse(await readFile(path, "utf8"));
|
|
322
|
+
const lex = loadLexicon(raw);
|
|
323
|
+
results.push({
|
|
324
|
+
kind: "lexicon", path, ok: true,
|
|
325
|
+
counts: { nouns: lex.nouns.size, verbs: lex.verbs.size, adjectives: lex.adjectives.size, properNames: lex.properNames.size },
|
|
326
|
+
});
|
|
327
|
+
} catch (e) {
|
|
328
|
+
results.push({ kind: "lexicon", path, ok: false, error: e && e.message ? e.message : String(e) });
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
if (candidate.templatesPath) {
|
|
333
|
+
const path = abs(candidate.templatesPath);
|
|
334
|
+
try {
|
|
335
|
+
const { loadTemplates } = await import("./corpus/templates.mjs");
|
|
336
|
+
const templates = await loadTemplates(path);
|
|
337
|
+
const unnamespaced = [...templates.keys()].filter((id) => !id.includes(":"));
|
|
338
|
+
if (unnamespaced.length) {
|
|
339
|
+
throw new Error(`template id${unnamespaced.length > 1 ? "s" : ""} not namespaced "<packname>:<id>": ${unnamespaced.join(", ")}`);
|
|
340
|
+
}
|
|
341
|
+
results.push({ kind: "templates", path, ok: true, counts: { templates: templates.size } });
|
|
342
|
+
} catch (e) {
|
|
343
|
+
results.push({ kind: "templates", path, ok: false, error: e && e.message ? e.message : String(e) });
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
return { ok: results.length > 0 && results.every((r) => r.ok), results };
|
|
348
|
+
}
|