@polycode-projects/the-mechanical-code-talker 0.2.0 → 0.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.
Files changed (53) hide show
  1. package/README.md +77 -3
  2. package/ROADMAP.md +416 -3
  3. package/bin/tmct.mjs +308 -12
  4. package/corpus/README.md +52 -0
  5. package/corpus/conceptnet/LICENSE-NOTICE +37 -0
  6. package/corpus/conceptnet/README.md +103 -0
  7. package/corpus/conceptnet/fetch-slice.mjs +136 -0
  8. package/corpus/conceptnet/filter-dump.mjs +89 -0
  9. package/corpus/conceptnet/slice.jsonl +14258 -0
  10. package/data/phrasebook/software-phrases.txt +231 -0
  11. package/data/templates/grammar-rules.toml +89 -0
  12. package/data/templates/responses.jsonl +68 -0
  13. package/package.json +40 -3
  14. package/src/ask-nlp.mjs +22 -10
  15. package/src/ask-vocab.mjs +35 -1
  16. package/src/ask.mjs +171 -494
  17. package/src/chat.mjs +709 -81
  18. package/src/corpus/conceptnet-map.toml +251 -0
  19. package/src/corpus/conceptnet.mjs +167 -0
  20. package/src/corpus/templates.mjs +188 -0
  21. package/src/finish.mjs +443 -0
  22. package/src/grammar/ace.mjs +341 -0
  23. package/src/grammar/assert.mjs +40 -0
  24. package/src/grammar/lexicon-core.json +287 -0
  25. package/src/grammar/lexicon.mjs +202 -0
  26. package/src/hash.mjs +32 -0
  27. package/src/index.mjs +21 -5
  28. package/src/init.mjs +264 -0
  29. package/src/interpret/fuzzy.mjs +89 -0
  30. package/src/interpret/merge.mjs +148 -0
  31. package/src/interpret/normalize.mjs +151 -0
  32. package/src/interpret/pipeline.mjs +112 -0
  33. package/src/interpret/strategies/grammar.mjs +137 -0
  34. package/src/interpret/strategies/keywords.mjs +241 -0
  35. package/src/interpret/strategies/noise-strip.mjs +114 -0
  36. package/src/memory/blocks.mjs +221 -0
  37. package/src/memory/core.mjs +533 -0
  38. package/src/memory/fold.mjs +0 -0
  39. package/src/memory/inspect.mjs +141 -0
  40. package/src/memory/trust.mjs +113 -0
  41. package/src/prose-nlp.mjs +14 -16
  42. package/src/providers/bootstrap.mjs +24 -0
  43. package/src/providers/fixture.mjs +118 -0
  44. package/src/providers/graph-service.mjs +312 -0
  45. package/src/repository-interface.mjs +318 -0
  46. package/src/server.mjs +44 -28
  47. package/src/sessions.mjs +137 -4
  48. package/src/source.mjs +44 -5
  49. package/src/syllogise.mjs +0 -0
  50. package/src/toml-config.mjs +14 -0
  51. package/src/tui/app.mjs +173 -0
  52. package/src/wink-model.mjs +74 -0
  53. package/bin/cli.mjs +0 -226
@@ -0,0 +1,202 @@
1
+ // grammar/lexicon.mjs — the declared lexicon behind tmct's ACE-OWL sub-fragment
2
+ // (ROADMAP Phase 2, item 2; docs/references/schemas/ace-owl-fragment.md).
3
+ //
4
+ // The lexicon is LOAD-BEARING: the grammar (grammar/ace.mjs) is only
5
+ // deterministic because every noun, verb (with any preposition), adjective
6
+ // (with its declared type) and proper name is DECLARED — tmct never guesses a
7
+ // word's category. Undeclared words route a sentence out of the grammar
8
+ // strategy (a miss is a feature: the interpretation pipeline falls through to
9
+ // the tolerant strategies).
10
+ //
11
+ // Data lives in lexicon-core.json (plain, diffable — the item-4/7 format
12
+ // discipline), a starter software-domain vocabulary. Callers extend it via
13
+ // loadLexicon(extra) with the same JSON shape; user entries win on conflict.
14
+ //
15
+ // Morphology is deliberately tiny and deterministic (no NLP dependency): a
16
+ // suffix-fold for plurals/3rd-person-singular ("repositories"→repository,
17
+ // "relies"→rely, "classes"→class, "uses"→use) plus an optional declared
18
+ // irregular `plural` ("indices"). Anything the fold can't reach is simply not
19
+ // in the lexicon — honest, not clever.
20
+
21
+ import { readFileSync } from "node:fs";
22
+ import { fileURLToPath } from "node:url";
23
+ import { dirname, join } from "node:path";
24
+
25
+ const CORE_FILE = join(dirname(fileURLToPath(import.meta.url)), "lexicon-core.json");
26
+
27
+ /** Determiner tokens the grammar consumes (pattern table's every/a/no…). */
28
+ export const DETERMINERS = Object.freeze({
29
+ every: "universal",
30
+ a: "indefinite",
31
+ an: "indefinite",
32
+ the: "definite",
33
+ no: "negative",
34
+ });
35
+
36
+ /** The cardinality quantifier phrases (pattern 5) → the OWL term they select. */
37
+ export const QUANTIFIERS = Object.freeze({
38
+ "at least": "owl:minCardinality",
39
+ "at most": "owl:maxCardinality",
40
+ exactly: "owl:cardinality",
41
+ });
42
+
43
+ const NUMBER_WORDS = Object.freeze({
44
+ one: 1, two: 2, three: 3, four: 4, five: 5,
45
+ six: 6, seven: 7, eight: 8, nine: 9, ten: 10,
46
+ });
47
+
48
+ /** Parse a cardinality count token: a digit run or a small number word. */
49
+ export function numberOf(word) {
50
+ const w = String(word ?? "").trim().toLowerCase();
51
+ if (/^\d+$/.test(w)) return Number(w);
52
+ return NUMBER_WORDS[w] ?? null;
53
+ }
54
+
55
+ /** 3rd-person-singular surface form of a verb lemma — the predicate spelling
56
+ * ("import"→imports, "rely"→relies, "catch"→catches, "have"→has), matching
57
+ * the code graph's 3sg relation-kind convention (ask.mjs §verbs). */
58
+ export function thirdPerson(base) {
59
+ const b = String(base);
60
+ if (b === "have") return "has";
61
+ if (/[^aeiou]y$/.test(b)) return `${b.slice(0, -1)}ies`;
62
+ if (/(s|x|z|ch|sh)$/.test(b)) return `${b}es`;
63
+ return `${b}s`;
64
+ }
65
+
66
+ /** The URI-style predicate a verb entry emits: a declared override, or
67
+ * tmct:<3sg lemma> with any preposition camel-appended ("depend on"→tmct:dependsOn). */
68
+ export function predicateOf(verbEntry) {
69
+ if (verbEntry.predicate) return verbEntry.predicate;
70
+ const prep = verbEntry.prep ? verbEntry.prep[0].toUpperCase() + verbEntry.prep.slice(1) : "";
71
+ return `tmct:${thirdPerson(verbEntry.lemma)}${prep}`;
72
+ }
73
+
74
+ /** Deterministic singular/base-form candidates for a surface word, most
75
+ * specific first: as-is, -ies→y, -(s|x|z|ch|sh)es→stem, -s→stem. The FIRST
76
+ * candidate found in the relevant map wins ("classes"→class before "classe";
77
+ * "uses"→"us" misses, "use" hits). */
78
+ function foldCandidates(word) {
79
+ const w = String(word);
80
+ const out = [w];
81
+ if (w.length > 4 && /[a-z]ies$/.test(w)) out.push(`${w.slice(0, -3)}y`);
82
+ if (/(ses|xes|zes|ches|shes)$/.test(w)) out.push(w.slice(0, -2));
83
+ if (/[a-z]s$/.test(w) && !/ss$/.test(w)) out.push(w.slice(0, -1));
84
+ if (w === "has") out.push("have");
85
+ return out;
86
+ }
87
+
88
+ const NOUN_PROPERTY_TYPES = new Set(["data", "object"]);
89
+ const ADJECTIVE_TYPES = new Set(["subclass", "data"]);
90
+
91
+ /** Merge one raw lexicon block ({nouns, verbs, adjectives, properNames}) into
92
+ * the lookup maps, validating the declared typings (bad declarations throw —
93
+ * a lexicon that lies would make the grammar guess). */
94
+ function ingest(lex, raw = {}) {
95
+ for (const [lemma, e] of Object.entries(raw.nouns || {})) {
96
+ const entry = { lemma, ...(e || {}) };
97
+ if (entry.property && !NOUN_PROPERTY_TYPES.has(entry.property)) {
98
+ throw new Error(`lexicon noun "${lemma}": property must be "data" or "object", got ${JSON.stringify(entry.property)}`);
99
+ }
100
+ lex.nouns.set(lemma, entry);
101
+ if (entry.plural) lex.nounPlurals.set(entry.plural, lemma);
102
+ }
103
+ for (const [lemma, e] of Object.entries(raw.verbs || {})) {
104
+ lex.verbs.set(lemma, { lemma, ...(e || {}) });
105
+ }
106
+ for (const [lemma, e] of Object.entries(raw.adjectives || {})) {
107
+ const entry = { lemma, ...(e || {}) };
108
+ if (!ADJECTIVE_TYPES.has(entry.type)) {
109
+ throw new Error(`lexicon adjective "${lemma}": type must be "subclass" or "data", got ${JSON.stringify(entry.type)}`);
110
+ }
111
+ lex.adjectives.set(lemma, entry);
112
+ }
113
+ for (const name of raw.properNames || []) {
114
+ lex.properNames.set(String(name).toLowerCase(), String(name));
115
+ }
116
+ }
117
+
118
+ let coreCache = null;
119
+
120
+ /** Load the lexicon: the committed core vocabulary, optionally merged with a
121
+ * caller-supplied `extra` block of the same JSON shape (extra entries win).
122
+ * The no-extra result is cached (the JSON is committed, immutable at runtime). */
123
+ export function loadLexicon(extra) {
124
+ if (!extra && coreCache) return coreCache;
125
+ const raw = JSON.parse(readFileSync(CORE_FILE, "utf8"));
126
+ const lex = {
127
+ nouns: new Map(),
128
+ nounPlurals: new Map(),
129
+ verbs: new Map(),
130
+ adjectives: new Map(),
131
+ properNames: new Map(), // lowercased → canonical spelling
132
+ };
133
+ ingest(lex, raw);
134
+ if (extra) {
135
+ ingest(lex, extra);
136
+ return lex;
137
+ }
138
+ coreCache = lex;
139
+ return lex;
140
+ }
141
+
142
+ /** Noun lookup with plural folding; returns the entry ({lemma, property?}) or null. */
143
+ export function lookupNoun(lexicon, word) {
144
+ const w = String(word ?? "").toLowerCase();
145
+ const irregular = lexicon.nounPlurals.get(w);
146
+ if (irregular) return lexicon.nouns.get(irregular) ?? null;
147
+ for (const cand of foldCandidates(w)) {
148
+ const hit = lexicon.nouns.get(cand);
149
+ if (hit) return hit;
150
+ }
151
+ return null;
152
+ }
153
+
154
+ /** Verb lookup with 3sg folding; returns the entry ({lemma, prep?, predicate?}) or null. */
155
+ export function lookupVerb(lexicon, word) {
156
+ const w = String(word ?? "").toLowerCase();
157
+ for (const cand of foldCandidates(w)) {
158
+ const hit = lexicon.verbs.get(cand);
159
+ if (hit) return hit;
160
+ }
161
+ return null;
162
+ }
163
+
164
+ /** Adjective lookup (exact lemma); returns {lemma, type, property?, value?} or null. */
165
+ export function lookupAdjective(lexicon, word) {
166
+ return lexicon.adjectives.get(String(word ?? "").toLowerCase()) ?? null;
167
+ }
168
+
169
+ /** Proper-name lookup, case-insensitive; returns the CANONICAL spelling or null. */
170
+ export function lookupProperName(lexicon, word) {
171
+ return lexicon.properNames.get(String(word ?? "").toLowerCase()) ?? null;
172
+ }
173
+
174
+ /** Classify one word (or a two-word quantifier phrase) against the lexicon.
175
+ * Returns {pos, type?, …} or null for an undeclared word. Priority when a
176
+ * word is declared in several categories (e.g. "test" noun+verb): closed-class
177
+ * tokens, then properName > noun > verb > adjective — the grammar itself
178
+ * disambiguates by position, this is the standalone answer. */
179
+ export function classify(word, lexicon = loadLexicon()) {
180
+ const w = String(word ?? "").trim();
181
+ if (!w) return null;
182
+ const lower = w.toLowerCase();
183
+ if (DETERMINERS[lower]) return { pos: "determiner", type: DETERMINERS[lower] };
184
+ if (QUANTIFIERS[lower]) return { pos: "quantifier", type: QUANTIFIERS[lower] };
185
+ const n = numberOf(lower);
186
+ if (n != null) return { pos: "number", type: "cardinal", value: n };
187
+ const proper = lookupProperName(lexicon, w);
188
+ if (proper) return { pos: "properName", type: "individual", canonical: proper };
189
+ const noun = lookupNoun(lexicon, lower);
190
+ if (noun) {
191
+ return noun.property
192
+ ? { pos: "noun", type: `${noun.property}-property`, lemma: noun.lemma, property: noun.property }
193
+ : { pos: "noun", type: "class", lemma: noun.lemma };
194
+ }
195
+ const verb = lookupVerb(lexicon, lower);
196
+ if (verb) {
197
+ return { pos: "verb", type: "objectProperty", lemma: verb.lemma, predicate: predicateOf(verb), ...(verb.prep ? { prep: verb.prep } : {}) };
198
+ }
199
+ const adj = lookupAdjective(lexicon, lower);
200
+ if (adj) return { pos: "adjective", type: adj.type, lemma: adj.lemma };
201
+ return null;
202
+ }
package/src/hash.mjs ADDED
@@ -0,0 +1,32 @@
1
+ // hash.mjs — the single home for tmct's content-address hash.
2
+ //
3
+ // FNV-1a 32-bit is deliberately home-grown (see PLAN_DEPENDENCY_STRATEGY.md): it
4
+ // must be synchronous, browser-safe, dependency-free, and — critically —
5
+ // CROSS-VERSION STABLE, because fact ids are content-addressed by it and a fact's
6
+ // id is its identity across the whole memory graph. Every library candidate fails
7
+ // at least one of those; this eight-line function fails none. It lives here, once,
8
+ // so the fact-id contract has exactly one definition.
9
+ //
10
+ // Two historical copies are reconciled here without changing a single output byte:
11
+ // - src/memory/core.mjs used the hex form for fact ids;
12
+ // - chatbench/graded.mjs used the integer form as a PRNG seed, with a redundant
13
+ // mid-loop `>>> 0`. That `>>> 0` was always a no-op: `^` and Math.imul both
14
+ // apply ToInt32 to their operands, so the 32-bit pattern is invariant between
15
+ // iterations whether the accumulator is stored signed or unsigned. The final
16
+ // `h >>> 0` therefore yields the same value either way — proven, not assumed.
17
+
18
+ /** FNV-1a 32-bit. Returns the unsigned 32-bit integer (0 … 2^32−1). */
19
+ export function fnv1a32(str) {
20
+ let h = 0x811c9dc5;
21
+ for (let i = 0; i < str.length; i += 1) {
22
+ h ^= str.charCodeAt(i);
23
+ h = Math.imul(h, 0x01000193);
24
+ }
25
+ return h >>> 0;
26
+ }
27
+
28
+ /** FNV-1a 32-bit as a zero-padded 8-char hex string — the stable content-address
29
+ * used for fact ids (`fact:<hex>`). Same (s,p,o) → same id → upsert, never a dup. */
30
+ export function fnv1aHex(str) {
31
+ return fnv1a32(str).toString(16).padStart(8, "0");
32
+ }
package/src/index.mjs CHANGED
@@ -5,9 +5,10 @@
5
5
  // shape and its green test suite; the branding throughout is now `tmct`.
6
6
  //
7
7
  // This entry re-exports the adapter primitives a library consumer needs. The
8
- // clean chat/primitives split pulling the movable grammar out of ask.mjs away
9
- // from the core primitives it currently shares a file with — is deferred to
10
- // ROADMAP.md.
8
+ // clean chat/primitives split (ROADMAP item 13) is done: the movable
9
+ // conversational grammar lives in src/interpret/ (normalization pre-pass, the
10
+ // registered parsing strategies, the merge rule), while ask.mjs keeps the core
11
+ // primitives (resolveObject, traverse, render) and the ask() orchestration.
11
12
 
12
13
  // Chat surface (also reachable as the `./chat` subpath export).
13
14
  export { runChat, COMMANDS, answerCount, renderStats } from "./chat.mjs";
@@ -15,11 +16,26 @@ export { runChat, COMMANDS, answerCount, renderStats } from "./chat.mjs";
15
16
  // Grammar / NL-over-graph primitives.
16
17
  export { ask, resolveObject } from "./ask.mjs";
17
18
 
19
+ // The interpretation pipeline (ROADMAP item 8): normalize once, run every
20
+ // registered strategy (grammar, keyword-spot, …) over the text, merge same-class
21
+ // results, surround distinct-class results — no graph access; pair it with ask()
22
+ // or the primitives to answer. `interpret(text, ctx)` returns the full record
23
+ // ({raw, normalized, normalizationChanged, results, parsed, class, alternates}).
24
+ export { interpret } from "./interpret/pipeline.mjs";
25
+
18
26
  // Graph traversal primitives.
19
27
  export { relationKind, impactClosure } from "./codegraph.mjs";
20
28
 
21
29
  // Tool dispatch (slash-commands and CLI tool calls route through here).
22
30
  export { dispatchTool } from "./server.mjs";
23
31
 
24
- // The single graph-load choke pointthe adapter's data-provider seam.
25
- export { fetchEntities } from "./source.mjs";
32
+ // Conversational memory (ROADMAP item 9)tmct's OWN OWL-labelled graph under
33
+ // .tmct/memory/, distinct from any provider-supplied code graph.
34
+ export { loadMemory, appendUtterance, appendFact } from "./memory/core.mjs";
35
+ export { retrieveBlocks, saveBlock, rankBlocks } from "./memory/blocks.mjs";
36
+ export { foldSessionLogs } from "./memory/fold.mjs";
37
+
38
+ // The single graph-load choke point — the adapter's data-provider seam
39
+ // (docs/adapter-contract.md): registerProvider() plugs a producer in;
40
+ // fetchEntities() is the one read path.
41
+ export { fetchEntities, registerProvider } from "./source.mjs";
package/src/init.mjs ADDED
@@ -0,0 +1,264 @@
1
+ // init.mjs — `tmct init`: the interface's onboarding surface.
2
+ //
3
+ // One command takes a bare directory (a user's repo, or a host package such as
4
+ // seonix) to a WORKING tmct install: it creates the `.tmct/` artifact tree,
5
+ // writes an externalised `tmct.toml` (the seonix.toml documented-config pattern),
6
+ // seeds the committed tier-1 corpus into memory, and records provenance of what
7
+ // it did. See ROADMAP Phase 8 ("Distribution: tmct init") and the Phase-4
8
+ // corpus-tiering policy.
9
+ //
10
+ // initRepo(dir, { force?, seed?, env? }) → { created, config, seeded, ... }
11
+ //
12
+ // DESIGN RULES (load-bearing):
13
+ // - OFFLINE, DETERMINISTIC, $0. The seed is the tier-1 committed ConceptNet
14
+ // slice already in the tarball — no network, ever. The $0-offline default is
15
+ // inviolable (ROADMAP Phase 4); tiers 2-3 are additive config, never run here.
16
+ // - IDEMPOTENT and NON-DESTRUCTIVE. Safe to re-run. A benign re-init NEVER
17
+ // throws — it returns an honest result whose `message` says nothing changed.
18
+ // Existing `tmct.toml` and an existing seed are preserved unless `force`.
19
+ // - FAILURE-TOLERANT SEED. A missing/broken corpus degrades to an unseeded (but
20
+ // still initialised) repo — the directory scaffold and config always land.
21
+ //
22
+ // The seed marker + limit + prefer mirror src/chat.mjs's W3 bootstrap
23
+ // (SEED_MARKER_REL / SEED_LIMIT / SEED_PREFER) ON PURPOSE: both write the same
24
+ // `.tmct/memory/corpus-seed.json`, so whichever of `tmct init` and first-run
25
+ // bootstrap happens first wins and the other short-circuits. They are re-declared
26
+ // here (not imported) to keep init off chat.mjs's heavy module graph.
27
+
28
+ import { mkdir, readFile, writeFile, stat } from "node:fs/promises";
29
+ import { dirname, join, resolve } from "node:path";
30
+ import { fileURLToPath } from "node:url";
31
+
32
+ export const CONFIG_FILE = "tmct.toml";
33
+ export const PROVENANCE_REL = join(".tmct", "init.json");
34
+ export const MEMORY_DIR_REL = join(".tmct", "memory");
35
+ export const SESSIONS_DIR_REL = join(".tmct", "sessions");
36
+ export const SEED_MARKER_REL = join(".tmct", "memory", "corpus-seed.json");
37
+
38
+ /** How many corpus facts the seed writes — matches chat.mjs SEED_LIMIT so an
39
+ * init-seeded repo and a bootstrap-seeded repo carry the identical slice. */
40
+ export const SEED_LIMIT = 500;
41
+
42
+ /** Predicate preference for the capped seed (definitional band first) — matches
43
+ * chat.mjs SEED_PREFER so "what is a cache?" answers land in the first 500. */
44
+ export const SEED_PREFER = ["rdfs:subClassOf", "rdf:type", "mgx:usedFor", "mgx:partOf", "mgx:capableOf"];
45
+
46
+ /** The shipped default config — the exact shape written into `tmct.toml` and
47
+ * echoed back in the result's `config`. Absent file ⇒ these values apply. */
48
+ export function defaultConfig() {
49
+ return {
50
+ graphFile: join(".tmct", "graph.json"),
51
+ corpus: { tier: "tier1" },
52
+ seed: { enabled: true, limit: SEED_LIMIT },
53
+ };
54
+ }
55
+
56
+ /** Read this package's version (best-effort, for provenance). */
57
+ async function tmctVersion() {
58
+ try {
59
+ const pkgPath = join(dirname(fileURLToPath(import.meta.url)), "..", "package.json");
60
+ const pkg = JSON.parse(await readFile(pkgPath, "utf8"));
61
+ return pkg.version || null;
62
+ } catch {
63
+ return null;
64
+ }
65
+ }
66
+
67
+ async function exists(p) {
68
+ try {
69
+ await stat(p);
70
+ return true;
71
+ } catch {
72
+ return false;
73
+ }
74
+ }
75
+
76
+ /** Render the documented, commented `tmct.toml` for a config object. Hand-written
77
+ * (not smol-toml stringify) so every key ships with the prose that makes the file
78
+ * a self-explaining config surface — the seonix.toml pattern. The output parses
79
+ * back cleanly through toml-config.mjs's loadTomlConfig. */
80
+ export function renderTomlConfig(config = defaultConfig()) {
81
+ const c = { ...defaultConfig(), ...config };
82
+ const corpus = { ...defaultConfig().corpus, ...(config.corpus || {}) };
83
+ const seed = { ...defaultConfig().seed, ...(config.seed || {}) };
84
+ return `# tmct.toml — the mechanical code talker, project configuration.
85
+ # Written by \`tmct init\`. An ABSENT file means shipped defaults (this file
86
+ # just makes them explicit and editable). Documented in the repository-interface
87
+ # onboarding surface (ROADMAP Phase 8, "Distribution: tmct init").
88
+
89
+ # Where the code-graph JSON artifact lives, relative to this file. The
90
+ # TMCT_GRAPH_FILE environment variable overrides it at runtime.
91
+ graph_file = ${JSON.stringify(c.graphFile)}
92
+
93
+ [corpus]
94
+ # Corpus-tiering policy (ROADMAP Phase 4). The $0-offline default is inviolable;
95
+ # higher tiers are ADDITIVE and never required to answer.
96
+ # "tier1" — committed slice only. Offline, $0. The default.
97
+ # "tier2" — also fetch growable corpora at seed time (network, once, cached).
98
+ # "tier3" — also consult live sources at question time (network, per-query, opt-in).
99
+ tier = ${JSON.stringify(corpus.tier)}
100
+
101
+ [seed]
102
+ # Seed the committed tier-1 ConceptNet slice into .tmct/memory during init.
103
+ # Offline and deterministic. Set false, or export TMCT_NO_SEED=1, to opt out —
104
+ # the repo still initialises, just empty of corpus facts.
105
+ enabled = ${seed.enabled ? "true" : "false"}
106
+ # How many facts the seed writes (definitional band first).
107
+ limit = ${Number(seed.limit)}
108
+ `;
109
+ }
110
+
111
+ /** Should the seed run? Explicit `opts.seed` wins; otherwise the config's
112
+ * `seed.enabled`; and TMCT_NO_SEED=<non-empty> is a hard veto over both (the
113
+ * documented environment opt-out, honoured even when config says enabled). */
114
+ function seedRequested({ optSeed, configEnabled, env }) {
115
+ const noSeed = env && String(env.TMCT_NO_SEED || "").trim();
116
+ if (noSeed) return false;
117
+ if (optSeed !== undefined) return Boolean(optSeed);
118
+ return Boolean(configEnabled);
119
+ }
120
+
121
+ /**
122
+ * Initialise `dir` for tmct. Idempotent, non-destructive, offline.
123
+ *
124
+ * @param {string} dir target directory (a repo root, or a host package root).
125
+ * @param {object} [opts]
126
+ * @param {boolean} [opts.force] re-write tmct.toml + re-record provenance even
127
+ * when the repo is already initialised (never deletes memory/seed data).
128
+ * @param {boolean} [opts.seed] force seeding on/off, overriding tmct.toml's
129
+ * `seed.enabled` (TMCT_NO_SEED still vetoes).
130
+ * @param {object} [opts.env] environment (for TMCT_NO_SEED); defaults to
131
+ * process.env.
132
+ * @returns {Promise<{
133
+ * created: string[], config: object, seeded: boolean,
134
+ * alreadyInitialized: boolean, seedResult: (object|null), message: string
135
+ * }>} `created` lists the ABSOLUTE paths this call brought into being (empty on a
136
+ * benign no-op re-init). Never throws on a benign re-init or a corpus failure.
137
+ */
138
+ export async function initRepo(dir, { force = false, seed, env = process.env } = {}) {
139
+ const root = resolve(dir);
140
+ const created = [];
141
+ const paths = {
142
+ tmct: join(root, ".tmct"),
143
+ memory: join(root, MEMORY_DIR_REL),
144
+ sessions: join(root, SESSIONS_DIR_REL),
145
+ toml: join(root, CONFIG_FILE),
146
+ provenance: join(root, PROVENANCE_REL),
147
+ marker: join(root, SEED_MARKER_REL),
148
+ };
149
+
150
+ const wasInitialized = await exists(paths.provenance);
151
+
152
+ // ---- 1. The artifact directory scaffold (always idempotent) ----
153
+ for (const d of [paths.tmct, paths.memory, paths.sessions]) {
154
+ if (!(await exists(d))) {
155
+ await mkdir(d, { recursive: true });
156
+ created.push(d);
157
+ }
158
+ }
159
+
160
+ // ---- 2. The externalised config (preserve an existing file unless force) ----
161
+ let config = defaultConfig();
162
+ const tomlPresent = await exists(paths.toml);
163
+ if (!tomlPresent || force) {
164
+ await writeFile(paths.toml, renderTomlConfig(config));
165
+ if (!tomlPresent) created.push(paths.toml);
166
+ } else {
167
+ // Honour the user's committed tmct.toml — read its knobs back so the returned
168
+ // config (and the seed decision) reflect what's actually on disk.
169
+ config = await readWrittenConfig(paths.toml, config);
170
+ }
171
+
172
+ // ---- 3. Seed the tier-1 committed corpus (offline, failure-tolerant) ----
173
+ let seeded = false;
174
+ let seedResult = null;
175
+ let seedNote = "";
176
+ const wantSeed = seedRequested({ optSeed: seed, configEnabled: config.seed?.enabled, env });
177
+ if (!wantSeed) {
178
+ seedNote = env && String(env.TMCT_NO_SEED || "").trim()
179
+ ? "seed skipped (TMCT_NO_SEED set)"
180
+ : "seed skipped (disabled)";
181
+ } else if ((await exists(paths.marker)) && !force) {
182
+ seedNote = "seed skipped (already seeded — marker present)";
183
+ } else {
184
+ try {
185
+ const { seedMemory } = await import("./corpus/conceptnet.mjs");
186
+ const limit = Number(config.seed?.limit) || SEED_LIMIT;
187
+ seedResult = await seedMemory(root, { limit, prefer: SEED_PREFER });
188
+ const markerNew = !(await exists(paths.marker));
189
+ await mkdir(dirname(paths.marker), { recursive: true });
190
+ await writeFile(
191
+ paths.marker,
192
+ JSON.stringify({
193
+ seededAt: new Date().toISOString(),
194
+ limit,
195
+ appended: seedResult.appended,
196
+ skipped: seedResult.skipped,
197
+ }) + "\n",
198
+ );
199
+ if (markerNew) created.push(paths.marker);
200
+ seeded = true;
201
+ } catch (err) {
202
+ // Corpus unavailable/broken → an initialised-but-unseeded repo, not a crash.
203
+ seedNote = `seed skipped (corpus unavailable: ${err && err.message ? err.message : err})`;
204
+ }
205
+ }
206
+
207
+ // ---- 4. Record provenance (what was created, when, by which version) ----
208
+ const provenanceNew = !(await exists(paths.provenance));
209
+ const provenance = {
210
+ tool: "tmct init",
211
+ tmctVersion: await tmctVersion(),
212
+ initializedAt: new Date().toISOString(),
213
+ dir: root,
214
+ config,
215
+ seeded,
216
+ seedResult,
217
+ created,
218
+ };
219
+ await writeFile(paths.provenance, JSON.stringify(provenance, null, 2) + "\n");
220
+ if (provenanceNew) created.push(paths.provenance);
221
+
222
+ const alreadyInitialized = wasInitialized && !force;
223
+ const message = buildMessage({ alreadyInitialized, force, created, seeded, seedNote, seedResult });
224
+
225
+ return { created, config, seeded, alreadyInitialized, seedResult, message };
226
+ }
227
+
228
+ /** Read a present tmct.toml back into the canonical config shape (so a re-init
229
+ * respects the on-disk file). Falls back to `base` on any read/parse trouble —
230
+ * init must never crash on a malformed user file; the runtime loader
231
+ * (toml-config.mjs) is where a bad file surfaces its error. */
232
+ async function readWrittenConfig(tomlPath, base) {
233
+ try {
234
+ const { loadTomlConfig } = await import("./toml-config.mjs");
235
+ const raw = await loadTomlConfig(dirname(tomlPath));
236
+ if (!raw) return base;
237
+ const cfg = { ...base };
238
+ if (raw.graph_file !== undefined) cfg.graphFile = String(raw.graph_file);
239
+ if (raw.corpus && raw.corpus.tier !== undefined) cfg.corpus = { ...cfg.corpus, tier: raw.corpus.tier };
240
+ if (raw.seed) {
241
+ cfg.seed = { ...cfg.seed };
242
+ if (raw.seed.enabled !== undefined) cfg.seed.enabled = Boolean(raw.seed.enabled);
243
+ if (raw.seed.limit !== undefined) cfg.seed.limit = Number(raw.seed.limit);
244
+ }
245
+ return cfg;
246
+ } catch {
247
+ return base;
248
+ }
249
+ }
250
+
251
+ function buildMessage({ alreadyInitialized, force, created, seeded, seedNote, seedResult }) {
252
+ if (alreadyInitialized && created.length === 0) {
253
+ return `Already initialized — nothing to do (re-run with force to rewrite tmct.toml). ${seedNote || ""}`.trim();
254
+ }
255
+ const parts = [];
256
+ parts.push(force && alreadyInitialized ? "Re-initialized" : "Initialized");
257
+ parts.push(`tmct here (${created.length} path${created.length === 1 ? "" : "s"} created).`);
258
+ if (seeded && seedResult) {
259
+ parts.push(`Seeded ${seedResult.appended} corpus fact${seedResult.appended === 1 ? "" : "s"} into memory.`);
260
+ } else if (seedNote) {
261
+ parts.push(seedNote + ".");
262
+ }
263
+ return parts.join(" ");
264
+ }
@@ -0,0 +1,89 @@
1
+ // interpret/fuzzy.mjs — the bounded-edit-distance fuzzy tier (two-level fuzzy,
2
+ // 2026-07-02), extracted MOVE-only from ask.mjs (item 13). A reusable SERVICE the
3
+ // strategies call, not a strategy itself: the keyword-spotting strategy's tier-3
4
+ // vocabulary rewrite and resolveObject's tier-5 label pass both read editDistance/
5
+ // fuzzyBound from here, and the "assuming you meant …" announcement discipline
6
+ // (a unique within-bound hit is announced, a tie is refused or surfaced as
7
+ // ambiguity, never a silently-broken guess) is enforced by the callers off these
8
+ // primitives. Deliberately coupled to the curated vocab tables via explicit
9
+ // imports — the fuzzy TARGETS are a closed, curated set, same ethos as the
10
+ // tables themselves. Pure JS, no deps.
11
+
12
+ import { VERB_TO_KIND, ENTITY_TO_TYPE, MODIFIER_TO_KIND } from "../ask-vocab.mjs";
13
+ import { STOPWORDS } from "./normalize.mjs";
14
+
15
+ // ---- bounded edit distance — hand-rolled Damerau-Levenshtein (optimal string
16
+ // alignment: substitution/insertion/deletion + adjacent transposition), bounded
17
+ // with an early row-minimum exit. Fires only after every exact/curated tier
18
+ // missed, and a distance TIE is refused (keyword) or surfaced as ambiguity
19
+ // (object), never broken by a guess. ----
20
+
21
+ /** Distance between a and b, or max+1 as soon as it provably exceeds `max`. */
22
+ export function editDistance(a, b, max) {
23
+ if (a === b) return 0;
24
+ if (Math.abs(a.length - b.length) > max) return max + 1;
25
+ let prev2 = null;
26
+ let prev = Array.from({ length: b.length + 1 }, (_, j) => j);
27
+ for (let i = 1; i <= a.length; i += 1) {
28
+ const cur = [i];
29
+ let rowMin = i;
30
+ for (let j = 1; j <= b.length; j += 1) {
31
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
32
+ let v = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + cost);
33
+ if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) v = Math.min(v, prev2[j - 2] + cost);
34
+ cur[j] = v;
35
+ if (v < rowMin) rowMin = v;
36
+ }
37
+ if (rowMin > max) return max + 1;
38
+ prev2 = prev;
39
+ prev = cur;
40
+ }
41
+ return prev[b.length];
42
+ }
43
+
44
+ /** The curated distance budget: 1 edit for short tokens, 2 for longer ones. */
45
+ export const fuzzyBound = (s) => (s.length <= 5 ? 1 : 2);
46
+
47
+ /** Every single word appearing in the three parse tables — the "is this word
48
+ * already vocabulary?" gate for the lemma/fuzzy canonicalization passes (an
49
+ * exact vocab word is NEVER rewritten: exact curated match always wins). */
50
+ export const VOCAB_WORDS = new Set(
51
+ [...Object.keys(VERB_TO_KIND), ...Object.keys(ENTITY_TO_TYPE), ...Object.keys(MODIFIER_TO_KIND)]
52
+ .flatMap((p) => p.split(" ")),
53
+ );
54
+
55
+ /** Fuzzy-correction TARGETS: verb-phrase and modifier constituents only, length ≥4.
56
+ * Entity nouns are deliberately excluded — real identifiers collide with them at
57
+ * distance ≤2 far too easily ("myfile" is 2 edits from "file", "caller" 2 from
58
+ * "calls"-family words), and entity-noun typos are already owned by the curated
59
+ * MISSPELLINGS table where such calls are made deliberately. Short constituents
60
+ * ("of", "to", "in", "on") are excluded for the same reason: at bound 1 half of
61
+ * English is adjacent to them. */
62
+ const FUZZY_TARGET_WORDS = [...new Set(
63
+ [...Object.keys(VERB_TO_KIND), ...Object.keys(MODIFIER_TO_KIND)]
64
+ .flatMap((p) => p.split(" "))
65
+ .filter((w) => w.length >= 4),
66
+ )];
67
+
68
+ /** A query word may be canonicalized only if it is plain alphabetic, not a
69
+ * stopword, and not already vocabulary. Dotted/digit terms (file names, shas)
70
+ * are never touched. */
71
+ export function eligibleForCanon(w) {
72
+ return /^[a-z]+$/.test(w) && !STOPWORDS.has(w) && !VOCAB_WORDS.has(w);
73
+ }
74
+
75
+ /** UNIQUE within-bound fuzzy vocab keyword for `w`, or null — a tie between two
76
+ * distinct target words at the same distance is refused outright (the honest-miss
77
+ * discipline at the vocabulary level; cf. MISSPELLINGS' curated "calss" decision). */
78
+ export function fuzzyVocabWord(w) {
79
+ const bound = fuzzyBound(w);
80
+ let best = bound + 1;
81
+ let hit = null;
82
+ let tied = false;
83
+ for (const target of FUZZY_TARGET_WORDS) {
84
+ const d = editDistance(w, target, Math.min(best, bound));
85
+ if (d < best) { best = d; hit = target; tied = false; }
86
+ else if (d === best && d <= bound && target !== hit) tied = true;
87
+ }
88
+ return best <= bound && !tied ? hit : null;
89
+ }