@polycode-projects/the-mechanical-code-talker 0.2.0 → 0.3.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/ROADMAP.md +5 -2
- package/bin/tmct.mjs +253 -12
- package/corpus/README.md +52 -0
- package/corpus/conceptnet/LICENSE-NOTICE +37 -0
- package/corpus/conceptnet/README.md +103 -0
- package/corpus/conceptnet/fetch-slice.mjs +136 -0
- package/corpus/conceptnet/filter-dump.mjs +89 -0
- package/corpus/conceptnet/slice.jsonl +14258 -0
- package/data/phrasebook/software-phrases.txt +231 -0
- package/data/templates/responses.jsonl +55 -0
- package/package.json +12 -3
- package/src/ask-nlp.mjs +14 -0
- package/src/ask-vocab.mjs +13 -1
- package/src/ask.mjs +92 -493
- package/src/chat.mjs +147 -45
- package/src/corpus/conceptnet-map.toml +251 -0
- package/src/corpus/conceptnet.mjs +155 -0
- package/src/corpus/templates.mjs +104 -0
- package/src/grammar/ace.mjs +341 -0
- package/src/grammar/assert.mjs +40 -0
- package/src/grammar/lexicon-core.json +287 -0
- package/src/grammar/lexicon.mjs +202 -0
- package/src/index.mjs +21 -5
- package/src/interpret/fuzzy.mjs +89 -0
- package/src/interpret/merge.mjs +148 -0
- package/src/interpret/normalize.mjs +117 -0
- package/src/interpret/pipeline.mjs +112 -0
- package/src/interpret/strategies/grammar.mjs +137 -0
- package/src/interpret/strategies/keywords.mjs +185 -0
- package/src/interpret/strategies/noise-strip.mjs +114 -0
- package/src/memory/blocks.mjs +201 -0
- package/src/memory/core.mjs +292 -0
- package/src/memory/fold.mjs +105 -0
- package/src/sessions.mjs +125 -3
- package/src/source.mjs +44 -5
- package/src/tui/app.mjs +173 -0
- package/bin/cli.mjs +0 -226
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
// interpret/strategies/keywords.mjs — strategy 2: keyword-spotting/decomposition,
|
|
2
|
+
// extracted MOVE-only from ask.mjs (item 13). ELIZA's own mechanism: find the
|
|
3
|
+
// keyword(s) anywhere in the text, decompose around them, tolerate reordering and
|
|
4
|
+
// casual phrasing. Position-independent (no `^...$` anchor), so it tolerates
|
|
5
|
+
// "what calls this" / "who invokes this" / "something executes this, where from"
|
|
6
|
+
// — real phrasings the anchored grammar's fixed shapes don't cover.
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
VERB_TO_KIND, ENTITY_TO_TYPE, MODIFIER_TO_KIND,
|
|
10
|
+
WHERE_MARKERS, MENTION_MARKERS,
|
|
11
|
+
} from "../../ask-vocab.mjs";
|
|
12
|
+
import { STOPWORDS } from "../normalize.mjs";
|
|
13
|
+
import { VOCAB_WORDS, eligibleForCanon, fuzzyVocabWord } from "../fuzzy.mjs";
|
|
14
|
+
|
|
15
|
+
/** Find the longest phrase from `table`'s keys that appears as a contiguous
|
|
16
|
+
* run of `words` (case already lowercased by the caller). Longest-match-first
|
|
17
|
+
* (multi-word phrases before single words) so "co-changes with" isn't
|
|
18
|
+
* shadowed by a shorter unrelated word. A span overlapping `consumed` indices
|
|
19
|
+
* is skipped: the verb and entity tables now share a surface form ("change"
|
|
20
|
+
* is both a touches verb and the Change entity noun), and a word already
|
|
21
|
+
* claimed by the verb pass must not double as the entity keyword. Returns
|
|
22
|
+
* {kind, start, end} (end exclusive) or null. */
|
|
23
|
+
export function findPhrase(lcWords, table, consumed = null) {
|
|
24
|
+
const phrases = Object.keys(table).sort((a, b) => b.split(" ").length - a.split(" ").length);
|
|
25
|
+
for (const p of phrases) {
|
|
26
|
+
const pWords = p.split(" ");
|
|
27
|
+
for (let i = 0; i <= lcWords.length - pWords.length; i += 1) {
|
|
28
|
+
if (consumed && pWords.some((_, j) => consumed.has(i + j))) continue;
|
|
29
|
+
if (pWords.every((w, j) => lcWords[i + j] === w)) return { kind: table[p], start: i, end: i + pWords.length };
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Strategy 2: scan (already-normalized) text for a verb keyword anywhere,
|
|
36
|
+
* plus optional entity/modifier keywords anywhere, then split whatever's
|
|
37
|
+
* left (after removing the matched spans + stopwords) into the words BEFORE
|
|
38
|
+
* and AFTER the verb. Which side(s) are non-empty decides the shape —
|
|
39
|
+
* mirrors the three anchored shapes but by decomposition instead of a fixed
|
|
40
|
+
* template, so it tolerates reordering/casual phrasing the anchored regexes
|
|
41
|
+
* don't: text on BOTH sides ("does X import Y") -> ask{subject:before,
|
|
42
|
+
* object:after}; only AFTER the verb ("what calls this") -> reverse{object:
|
|
43
|
+
* after}; only BEFORE it ("what does X import") -> forward{object:before}.
|
|
44
|
+
* A lone context pronoun ("this"/"it"/"that"/"here") ending up as a resolved
|
|
45
|
+
* term is left as plain text — resolveTermOrContext (traverse-time)
|
|
46
|
+
* recognizes it against an optional contextId, so no separate flag is
|
|
47
|
+
* needed here. A misparse here costs nothing beyond an honest object-miss
|
|
48
|
+
* downstream (resolveObject never guesses).
|
|
49
|
+
*
|
|
50
|
+
* Keyword matching is TIERED (two-level fuzzy work, 2026-07-02) — each lower
|
|
51
|
+
* tier fires ONLY when every tier above found no verb phrase at all, so an
|
|
52
|
+
* exact curated match can never be displaced:
|
|
53
|
+
* 1. exact — the words as typed (post-normalization, which already applied
|
|
54
|
+
* the curated CONTRACTIONS/MISSPELLINGS/WRONG_WORDS corrections);
|
|
55
|
+
* 2. lemma (only with the optional Node-side `nlp` adapter) — each eligible
|
|
56
|
+
* word is replaced by its wink lemma IF that lemma is itself a vocab word
|
|
57
|
+
* ("imported"/"importing" -> "import"), so inflections hit the curated
|
|
58
|
+
* phrases without enumerating them. Every verb family already stores its
|
|
59
|
+
* lemma form ("import", "call", "touch", "use", …), so a direct
|
|
60
|
+
* lemma-in-vocab check is the whole lookup — no reverse index needed;
|
|
61
|
+
* 3. fuzzy (adapter-free; works in the inlined viewer too) — a word ≥4 chars
|
|
62
|
+
* matching nothing exactly may rewrite to a UNIQUE verb/modifier
|
|
63
|
+
* constituent within the bounded edit distance (see fuzzyVocabWord; ties
|
|
64
|
+
* are refused, entity nouns are never fuzzy targets).
|
|
65
|
+
* The canonicalized words drive PHRASE FINDING only — sideText always reads the
|
|
66
|
+
* ORIGINAL words, so a correction can never corrupt an object/subject term. */
|
|
67
|
+
export function parseKeywordSpot(text, nlp = null) {
|
|
68
|
+
// Strip a trailing "?" (mirrors the anchored templates' own `\??$`) and turn commas into
|
|
69
|
+
// pauses/spaces — but NEVER strip a mid-word ".": object terms are routinely dotted file/module
|
|
70
|
+
// names ("a.py", "utils.mjs"), and the anchored strategy captures those raw, so keyword-spot
|
|
71
|
+
// must too or the two strategies would "disagree" over a period that was never part of the intent.
|
|
72
|
+
const words = text.replace(/\?+\s*$/, "").replace(/,/g, " ").split(/\s+/).filter(Boolean);
|
|
73
|
+
const lcWords = words.map((w) => w.toLowerCase());
|
|
74
|
+
// where/mentions shapes (2026-07-02 query families): "where is X [defined]" and
|
|
75
|
+
// "where is X mentioned" carry NO relation verb, so the verb-driven decomposition
|
|
76
|
+
// below can never reach them. Routed here by the "where" question word + marker —
|
|
77
|
+
// but ONLY when no relation verb exists anywhere in the sentence: "something
|
|
78
|
+
// executes this, where from" (an existing worked phrasing) has a verb, and its
|
|
79
|
+
// "where" is decorative, not a location question.
|
|
80
|
+
if (lcWords.includes("where") && !findPhrase(lcWords, VERB_TO_KIND)) {
|
|
81
|
+
const mention = lcWords.some((w) => MENTION_MARKERS.includes(w));
|
|
82
|
+
const markers = new Set([...WHERE_MARKERS, ...MENTION_MARKERS]);
|
|
83
|
+
const objText = words.filter((w, i) => !STOPWORDS.has(lcWords[i]) && !markers.has(lcWords[i])).join(" ").trim();
|
|
84
|
+
if (objText) {
|
|
85
|
+
const kind = mention ? "mentions" : "where";
|
|
86
|
+
return { shape: kind, entityType: null, modifier: "direct", kind, object: objText };
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
let canonWords = lcWords;
|
|
90
|
+
let verbHit = findPhrase(lcWords, VERB_TO_KIND);
|
|
91
|
+
if (!verbHit && nlp) {
|
|
92
|
+
// tier 2: lemma (see the tier doc above) — replace only when the lemma is
|
|
93
|
+
// itself vocabulary, so unknown words ("myfile") pass through untouched.
|
|
94
|
+
const lemmaWords = lcWords.map((w) => {
|
|
95
|
+
if (!eligibleForCanon(w)) return w;
|
|
96
|
+
const l = nlp.lemma(w);
|
|
97
|
+
return VOCAB_WORDS.has(l) ? l : w;
|
|
98
|
+
});
|
|
99
|
+
verbHit = findPhrase(lemmaWords, VERB_TO_KIND);
|
|
100
|
+
if (verbHit) canonWords = lemmaWords;
|
|
101
|
+
}
|
|
102
|
+
if (!verbHit) {
|
|
103
|
+
// tier 3: bounded-edit-distance rewrite toward verb/modifier keywords only
|
|
104
|
+
// ("impotr" -> "import"); ≥4-char words only — below that the bound covers
|
|
105
|
+
// half of English (and "and" is 1 edit from the "land in" constituent).
|
|
106
|
+
const fuzzyWords = lcWords.map((w) => (w.length >= 4 && eligibleForCanon(w) ? fuzzyVocabWord(w) || w : w));
|
|
107
|
+
verbHit = findPhrase(fuzzyWords, VERB_TO_KIND);
|
|
108
|
+
if (verbHit) canonWords = fuzzyWords;
|
|
109
|
+
}
|
|
110
|
+
if (!verbHit) return null;
|
|
111
|
+
// POS consumer (wink adapter, Node-side only): rescue the ONE decomposition this
|
|
112
|
+
// strategy provably mis-parses — a relation word used as a NOUN in a "the
|
|
113
|
+
// <imports> of <term>" nominal ("show the imports of walk.mjs" otherwise
|
|
114
|
+
// decomposes to ask{subject:"show"}; bare "the imports of walk.mjs" to the
|
|
115
|
+
// reverse shape, both wrong). The wink probe showed "import" is tagged NOUN even
|
|
116
|
+
// in genuine verb use ("which modules import walk.mjs"), so the POS signal is
|
|
117
|
+
// deliberately NOT a general verb veto — it only fires inside this exact
|
|
118
|
+
// det+NOUN+"of" frame, where the nominal reading is grammatically forced.
|
|
119
|
+
if (nlp && verbHit.end - verbHit.start === 1) {
|
|
120
|
+
const i = verbHit.start;
|
|
121
|
+
const det = lcWords[i - 1];
|
|
122
|
+
if ((det === "the" || det === "these" || det === "those") && lcWords[i + 1] === "of") {
|
|
123
|
+
const tags = nlp.posTags(words);
|
|
124
|
+
if (tags[i] === "NOUN") {
|
|
125
|
+
const objText = words.slice(i + 2).filter((w, j) => !STOPWORDS.has(lcWords[i + 2 + j])).join(" ").trim();
|
|
126
|
+
if (objText) return { shape: "forward", entityType: null, modifier: "direct", kind: verbHit.kind, object: objText };
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
const consumed = new Set();
|
|
131
|
+
const mark = (hit) => { if (hit) for (let i = hit.start; i < hit.end; i += 1) consumed.add(i); };
|
|
132
|
+
mark(verbHit);
|
|
133
|
+
const entityHit = findPhrase(canonWords, ENTITY_TO_TYPE, consumed);
|
|
134
|
+
mark(entityHit);
|
|
135
|
+
const modifierHit = findPhrase(canonWords, MODIFIER_TO_KIND, consumed);
|
|
136
|
+
mark(modifierHit);
|
|
137
|
+
const sideText = (from, to) => words
|
|
138
|
+
.slice(from, to)
|
|
139
|
+
.filter((_, j) => !consumed.has(from + j) && !STOPWORDS.has(lcWords[from + j]))
|
|
140
|
+
.join(" ")
|
|
141
|
+
.trim();
|
|
142
|
+
const beforeText = sideText(0, verbHit.start);
|
|
143
|
+
const afterText = sideText(verbHit.end, words.length);
|
|
144
|
+
const kind = verbHit.kind;
|
|
145
|
+
// slices read canonWords, not lcWords: the entity/modifier spans were matched
|
|
146
|
+
// against the canonicalized array, whose word IS the table key.
|
|
147
|
+
const entityType = entityHit ? ENTITY_TO_TYPE[canonWords.slice(entityHit.start, entityHit.end).join(" ")] : null;
|
|
148
|
+
const modifier = modifierHit ? MODIFIER_TO_KIND[canonWords.slice(modifierHit.start, modifierHit.end).join(" ")] : "direct";
|
|
149
|
+
|
|
150
|
+
// when shape (2026-07-02 query families): "when did X change" / "when was X last
|
|
151
|
+
// touched" — the "when" question word turns a touches decomposition temporal.
|
|
152
|
+
// Only touches carries commit dates to answer with; a "when" next to any other
|
|
153
|
+
// relation verb falls through to the ordinary shapes (and their honest answers).
|
|
154
|
+
if (kind === "touches" && lcWords.includes("when")) {
|
|
155
|
+
const objText = beforeText || afterText;
|
|
156
|
+
if (objText) return { shape: "when", entityType: null, modifier: "direct", kind: "touches", object: objText };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (beforeText && afterText) return { shape: "ask", entityType: null, modifier: "direct", kind, subject: beforeText, object: afterText };
|
|
160
|
+
if (afterText) return { shape: "reverse", entityType, modifier, kind, object: afterText };
|
|
161
|
+
// forward keeps the spotted entityType ("which modules did commit <sha> touch" is a
|
|
162
|
+
// forward decomposition — subject before the verb — whose asked grain would otherwise
|
|
163
|
+
// be lost); traverse() only consults it for the commit-as-subject grain selection,
|
|
164
|
+
// so plain forwards behave exactly as before. Modifier stays hardcoded: no forward
|
|
165
|
+
// closure traversal exists (see ask.mjs's modifierIsWired).
|
|
166
|
+
if (beforeText) return { shape: "forward", entityType, modifier: "direct", kind, object: beforeText };
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Pipeline registration (interpret/pipeline.mjs): keyword-spotting as a
|
|
171
|
+
* strategy. Class "graph-query" — shared with the anchored grammar, so the two
|
|
172
|
+
* merge (agree/disagree) exactly as the legacy two-way merge did. Confidence
|
|
173
|
+
* 0.7: a decomposition is looser evidence than a full-template match. The
|
|
174
|
+
* lemma/POS adapter arrives via ctx.nlp (the pipeline's default is the same
|
|
175
|
+
* Node-only wink adapter ask.mjs picks up). */
|
|
176
|
+
export const keywordSpotStrategy = {
|
|
177
|
+
id: "keyword-spot",
|
|
178
|
+
class: "graph-query",
|
|
179
|
+
run(text, ctx = {}) {
|
|
180
|
+
const parsed = parseKeywordSpot(text, ctx.nlp || null);
|
|
181
|
+
return parsed
|
|
182
|
+
? { strategyId: "keyword-spot", class: "graph-query", candidates: [{ parsed, confidence: 0.7 }] }
|
|
183
|
+
: null;
|
|
184
|
+
},
|
|
185
|
+
};
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// interpret/strategies/noise-strip.mjs — the item-10 noise-tolerant fallback
|
|
2
|
+
// strategy: strip filler/noise/stop words the closed grammar gives no meaning to,
|
|
3
|
+
// then RE-RUN THE PARSE over what's left (the anchored grammar first, then the
|
|
4
|
+
// keyword-spot decomposition — see the discipline notes). Rationale: the keyword-spot
|
|
5
|
+
// strategy decomposes around the verb and a leading vocative/adverb lands in the
|
|
6
|
+
// SUBJECT slot ("hey man which modules import X" -> ask{subject:"man"}), an
|
|
7
|
+
// unresolvable term the relaxation cascade can only rescue when the true answer
|
|
8
|
+
// is positive (it refuses to relax into a miss) — so a noise-wrapped question
|
|
9
|
+
// whose honest answer is negative/empty dies as "couldn't resolve one of the
|
|
10
|
+
// terms". Stripping the noise FIRST recovers the template parse and the same
|
|
11
|
+
// honest answer the clean phrasing gets.
|
|
12
|
+
//
|
|
13
|
+
// Discipline (no behavior change to anything that already parses):
|
|
14
|
+
// · fires ONLY when the anchored grammar MISSES the text as-given — if a
|
|
15
|
+
// template already matches, the grammar owns the sentence, noise and all;
|
|
16
|
+
// · strips ONLY all-lowercase alphabetic tokens (a Capitalized/dotted/digit
|
|
17
|
+
// token names something) that are curated noise (FILLER_WORDS + the
|
|
18
|
+
// cascade's CASCADE_NOISE) or wink-flagged English stop words (ctx.nlp's
|
|
19
|
+
// isStopWord — the optional Node-only tier), and NEVER a word in KEEP: the
|
|
20
|
+
// grammar's own vocabulary, question scaffolding, and context pronouns;
|
|
21
|
+
// · returns a candidate ONLY when the stripped text then parses — first against
|
|
22
|
+
// the anchored TEMPLATES (the strictest parser), then (cycle-2 robustness,
|
|
23
|
+
// CHATBENCH_001 L1) against the keyword-spot decomposition over the SAME
|
|
24
|
+
// stripped text. The second tier exists because the clean phrasing of half
|
|
25
|
+
// the worked questions ("what calls fnAlpha") is itself a keyword-spot
|
|
26
|
+
// parse, not a template — so a noise-wrapped variant ("i was wondering what
|
|
27
|
+
// calls fnAlpha", "hey tmct, what calls fnAlpha thanks") could never be
|
|
28
|
+
// rescued by the template re-parse alone and died as "couldn't resolve one
|
|
29
|
+
// of the terms". The keyword-spot re-parse also swallows auxiliary-verb
|
|
30
|
+
// residue for free ("was what calls fnAlpha" → reverse{fnAlpha}): its
|
|
31
|
+
// decomposition filters STOPWORDS out of the subject/object sides, which is
|
|
32
|
+
// exactly where a stripped frame's "was"/"is" residue lands. Same cost
|
|
33
|
+
// bound as before: only curated-noise/stop-word tokens were removed, and a
|
|
34
|
+
// wrongly-stripped word can at worst cost an honest object-miss downstream
|
|
35
|
+
// (resolveObject never guesses), never manufacture an entity.
|
|
36
|
+
//
|
|
37
|
+
// Registered with its own class ("noise-stripped") at confidence 0.75: above
|
|
38
|
+
// keyword-spot (0.7 — over noisy text its decomposition has provably swallowed
|
|
39
|
+
// the noise into a term) and below the anchored grammar (0.9 — which anyway
|
|
40
|
+
// gates this strategy off whenever it fires). A distinct class, so disagreement
|
|
41
|
+
// with keyword-spot is a ranked winner + "if you mean X then …" alternate,
|
|
42
|
+
// never a forced same-class ambiguity over a garbage subject.
|
|
43
|
+
|
|
44
|
+
import {
|
|
45
|
+
VERB_TO_KIND, ENTITY_TO_TYPE, MODIFIER_TO_KIND, META_MEANING_VERBS,
|
|
46
|
+
WHERE_MARKERS, MENTION_MARKERS, RELATIVE_PRONOUNS, PLACEHOLDER_NOUNS,
|
|
47
|
+
BOOLEAN_CONNECTIVES, QUALIFIERS, AGGREGATE_TRIGGERS, LIST_TRIGGERS,
|
|
48
|
+
SUPERLATIVE_EXTREMES, EDGE_NOUN_TO_METRIC, ANAPHORA_TRIGGERS,
|
|
49
|
+
CONTEXT_PRONOUNS, CASCADE_NOISE, CASCADE_SYNONYMS, FILLER_WORDS,
|
|
50
|
+
} from "../../ask-vocab.mjs";
|
|
51
|
+
import { STOPWORDS, splitWords, wordsOf } from "../normalize.mjs";
|
|
52
|
+
import { parseAnchored } from "./grammar.mjs";
|
|
53
|
+
import { parseKeywordSpot } from "./keywords.mjs";
|
|
54
|
+
|
|
55
|
+
/** Words this strategy may NEVER strip: everything the closed grammar gives
|
|
56
|
+
* query meaning to, plus the question/auxiliary scaffolding and the context
|
|
57
|
+
* pronouns. Mirrors the cascade's CONTENT_VOCAB ∪ STRUCTURAL_WORDS union —
|
|
58
|
+
* wink flags "which"/"what"/"does" as stop words, and stripping those would
|
|
59
|
+
* destroy the very templates this strategy re-runs. */
|
|
60
|
+
const KEEP = new Set([
|
|
61
|
+
...STOPWORDS, ...wordsOf(CONTEXT_PRONOUNS),
|
|
62
|
+
...wordsOf(Object.keys(VERB_TO_KIND)), ...wordsOf(Object.keys(ENTITY_TO_TYPE)),
|
|
63
|
+
...wordsOf(Object.keys(MODIFIER_TO_KIND)), ...wordsOf(Object.keys(QUALIFIERS)),
|
|
64
|
+
...wordsOf(AGGREGATE_TRIGGERS), ...wordsOf(LIST_TRIGGERS),
|
|
65
|
+
...wordsOf(Object.keys(SUPERLATIVE_EXTREMES)), ...wordsOf(Object.keys(EDGE_NOUN_TO_METRIC)),
|
|
66
|
+
...wordsOf(Object.keys(BOOLEAN_CONNECTIVES)), ...wordsOf(PLACEHOLDER_NOUNS),
|
|
67
|
+
...wordsOf(ANAPHORA_TRIGGERS), ...wordsOf(META_MEANING_VERBS),
|
|
68
|
+
...wordsOf(WHERE_MARKERS), ...wordsOf(MENTION_MARKERS), ...wordsOf(RELATIVE_PRONOUNS),
|
|
69
|
+
...wordsOf(Object.keys(CASCADE_SYNONYMS)),
|
|
70
|
+
]);
|
|
71
|
+
|
|
72
|
+
/** The curated noise tier — FILLER_WORDS (normally consumed by normalizeQuery;
|
|
73
|
+
* carried here too so the strategy stands alone) + the cascade's noise list. */
|
|
74
|
+
const CURATED_NOISE = new Set([...wordsOf(FILLER_WORDS), ...wordsOf(CASCADE_NOISE)]);
|
|
75
|
+
|
|
76
|
+
/** Strip the strippable tokens (see the file doc). Returns {text, dropped}. */
|
|
77
|
+
export function stripNoise(text, nlp = null) {
|
|
78
|
+
const kept = [];
|
|
79
|
+
const dropped = [];
|
|
80
|
+
for (const w of splitWords(text)) {
|
|
81
|
+
const lc = w.toLowerCase();
|
|
82
|
+
const strippable = /^[a-z]+$/.test(w) && !KEEP.has(lc)
|
|
83
|
+
&& (CURATED_NOISE.has(lc)
|
|
84
|
+
|| (nlp && typeof nlp.isStopWord === "function" && nlp.isStopWord(lc)));
|
|
85
|
+
if (strippable) dropped.push(w);
|
|
86
|
+
else kept.push(w);
|
|
87
|
+
}
|
|
88
|
+
return { text: kept.join(" "), dropped };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Pipeline registration (interpret/pipeline.mjs). */
|
|
92
|
+
export const noiseStripStrategy = {
|
|
93
|
+
id: "noise-strip",
|
|
94
|
+
class: "noise-stripped",
|
|
95
|
+
run(text, ctx = {}) {
|
|
96
|
+
if (parseAnchored(text)) return null; // the grammar owns the text as-given
|
|
97
|
+
const { text: stripped, dropped } = stripNoise(text, ctx.nlp || null);
|
|
98
|
+
if (!dropped.length || !stripped) return null;
|
|
99
|
+
// tier 1: the anchored templates over the stripped text — the strictest
|
|
100
|
+
// re-parse, tried first so a template shape is never displaced by a looser
|
|
101
|
+
// decomposition of the same words. tier 2 (cycle-2, CHATBENCH_001 L1): the
|
|
102
|
+
// keyword-spot decomposition over the SAME stripped text — the parser the
|
|
103
|
+
// clean phrasing of non-template questions ("what calls fnAlpha") actually
|
|
104
|
+
// uses, so their noise-wrapped variants recover the identical honest answer
|
|
105
|
+
// (see the file doc for the discipline/cost argument).
|
|
106
|
+
const parsed = parseAnchored(stripped) || parseKeywordSpot(stripped, ctx.nlp || null);
|
|
107
|
+
if (!parsed) return null;
|
|
108
|
+
return {
|
|
109
|
+
strategyId: "noise-strip",
|
|
110
|
+
class: "noise-stripped",
|
|
111
|
+
candidates: [{ parsed, confidence: 0.75, note: `noise-stripped to "${stripped}"` }],
|
|
112
|
+
};
|
|
113
|
+
},
|
|
114
|
+
};
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
// memory/blocks.mjs — text blocks + the relevance index (ROADMAP item 9).
|
|
2
|
+
//
|
|
3
|
+
// The memory graph (core.mjs) holds STRUCTURE; this module holds TEXT: cleaned
|
|
4
|
+
// session transcripts (fold.mjs writes one block per session) and, later,
|
|
5
|
+
// corpus documents. Storage under <repo>/.tmct/memory/blocks/:
|
|
6
|
+
// <block-id>.txt — the plain block text
|
|
7
|
+
// index.json — per-block prose tokens (prose.mjs tokenizer) + a static rank
|
|
8
|
+
//
|
|
9
|
+
// Ranking is two signals, combined at query time:
|
|
10
|
+
// 1. STATIC rank — a genuine iterative PageRank over the block-similarity
|
|
11
|
+
// graph (an undirected edge wherever two blocks share ≥ OVERLAP_MIN
|
|
12
|
+
// tokens; damping 0.85, 20 iterations — cheap at this scale). A block that
|
|
13
|
+
// shares vocabulary with many other blocks is "well-connected": it covers
|
|
14
|
+
// ground the corpus keeps returning to, so it wins ties.
|
|
15
|
+
// 2. QUERY-time IDF match — each query token is weighted by rarity across
|
|
16
|
+
// blocks (idf = log(1 + N/(1+df)), the codegraph.mjs locate discipline),
|
|
17
|
+
// so a whole-question query is not dominated by its ubiquitous words.
|
|
18
|
+
// retrieveBlocks() scores idf-sum × (1 + rank): the IDF match decides topic,
|
|
19
|
+
// the static rank breaks ties toward well-connected blocks.
|
|
20
|
+
//
|
|
21
|
+
// All writes are temp+rename atomic; saveBlock is an upsert (same id replaces —
|
|
22
|
+
// what makes fold.mjs's re-fold idempotent).
|
|
23
|
+
|
|
24
|
+
import { mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
|
|
25
|
+
import { join } from "node:path";
|
|
26
|
+
import { splitIdentifierWords, tokenizeProse } from "../prose.mjs";
|
|
27
|
+
|
|
28
|
+
export const BLOCKS_DIR_REL = join(".tmct", "memory", "blocks");
|
|
29
|
+
const INDEX_NAME = "index.json";
|
|
30
|
+
|
|
31
|
+
export const PAGERANK_DAMPING = 0.85;
|
|
32
|
+
export const PAGERANK_ITERATIONS = 20;
|
|
33
|
+
const OVERLAP_MIN = 2; // shared tokens for a similarity edge
|
|
34
|
+
const MAX_TOKENS_PER_BLOCK = 800; // beyond tokenizeProse's per-doc cap: union over lines
|
|
35
|
+
|
|
36
|
+
const blocksDir = (dir) => join(dir, BLOCKS_DIR_REL);
|
|
37
|
+
|
|
38
|
+
/** Only safe, filesystem-friendly block file names (ids are session uuids or
|
|
39
|
+
* corpus slugs; anything else is normalized, never trusted into a path). */
|
|
40
|
+
const safeName = (id) => String(id).replace(/[^A-Za-z0-9._-]+/g, "_").slice(0, 120) || "_";
|
|
41
|
+
|
|
42
|
+
/** Tokenize a whole block: prose tokens + identifier decomposition, unioned per
|
|
43
|
+
* line so tokenizeProse's 120-token-per-doc cap can't starve a long transcript;
|
|
44
|
+
* bounded overall. Sorted + deduped — the index stays deterministic. */
|
|
45
|
+
export function tokenizeBlock(text) {
|
|
46
|
+
const set = new Set();
|
|
47
|
+
for (const line of String(text || "").split("\n")) {
|
|
48
|
+
for (const t of tokenizeProse(line)) set.add(t);
|
|
49
|
+
for (const t of splitIdentifierWords(line)) set.add(t);
|
|
50
|
+
if (set.size >= MAX_TOKENS_PER_BLOCK) break;
|
|
51
|
+
}
|
|
52
|
+
return [...set].sort().slice(0, MAX_TOKENS_PER_BLOCK);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function atomicWrite(file, text) {
|
|
56
|
+
const tmp = `${file}.tmp-${process.pid}-${Math.random().toString(36).slice(2, 10)}`;
|
|
57
|
+
await writeFile(tmp, text);
|
|
58
|
+
await rename(tmp, file);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Load the block index ({ blocks: { id: { file, tokens[], rank } } }); a
|
|
62
|
+
* missing index is the empty bootstrap. */
|
|
63
|
+
export async function loadBlockIndex(dir) {
|
|
64
|
+
try {
|
|
65
|
+
return JSON.parse(await readFile(join(blocksDir(dir), INDEX_NAME), "utf8"));
|
|
66
|
+
} catch (e) {
|
|
67
|
+
if (e?.code === "ENOENT") return { blocks: {} };
|
|
68
|
+
throw e;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Iterative PageRank over the block-similarity graph. `tokensById` is a plain
|
|
74
|
+
* { id: tokens[] } map; an undirected edge joins two blocks sharing at least
|
|
75
|
+
* `overlapMin` tokens. Standard damped iteration (d=0.85, 20 rounds), dangling
|
|
76
|
+
* mass redistributed evenly, ranks summing to ~1. Pure — returns { id: rank }.
|
|
77
|
+
*/
|
|
78
|
+
export function rankBlocks(tokensById, {
|
|
79
|
+
damping = PAGERANK_DAMPING, iterations = PAGERANK_ITERATIONS, overlapMin = OVERLAP_MIN,
|
|
80
|
+
} = {}) {
|
|
81
|
+
const ids = Object.keys(tokensById || {});
|
|
82
|
+
const N = ids.length;
|
|
83
|
+
if (!N) return {};
|
|
84
|
+
const sets = ids.map((id) => new Set(tokensById[id] || []));
|
|
85
|
+
|
|
86
|
+
// similarity edges: shared-token count ≥ overlapMin (undirected → both directions)
|
|
87
|
+
const neighbours = ids.map(() => []);
|
|
88
|
+
for (let i = 0; i < N; i += 1) {
|
|
89
|
+
for (let j = i + 1; j < N; j += 1) {
|
|
90
|
+
const [small, big] = sets[i].size <= sets[j].size ? [sets[i], sets[j]] : [sets[j], sets[i]];
|
|
91
|
+
let shared = 0;
|
|
92
|
+
for (const t of small) {
|
|
93
|
+
if (big.has(t) && (shared += 1) >= overlapMin) break;
|
|
94
|
+
}
|
|
95
|
+
if (shared >= overlapMin) {
|
|
96
|
+
neighbours[i].push(j);
|
|
97
|
+
neighbours[j].push(i);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
let rank = new Array(N).fill(1 / N);
|
|
103
|
+
for (let round = 0; round < iterations; round += 1) {
|
|
104
|
+
const next = new Array(N).fill((1 - damping) / N);
|
|
105
|
+
let dangling = 0;
|
|
106
|
+
for (let i = 0; i < N; i += 1) {
|
|
107
|
+
const out = neighbours[i].length;
|
|
108
|
+
if (!out) { dangling += rank[i]; continue; }
|
|
109
|
+
const share = (damping * rank[i]) / out;
|
|
110
|
+
for (const j of neighbours[i]) next[j] += share;
|
|
111
|
+
}
|
|
112
|
+
const danglingShare = (damping * dangling) / N; // dangling mass spread evenly
|
|
113
|
+
for (let i = 0; i < N; i += 1) next[i] += danglingShare;
|
|
114
|
+
rank = next;
|
|
115
|
+
}
|
|
116
|
+
const out = {};
|
|
117
|
+
for (let i = 0; i < N; i += 1) out[ids[i]] = rank[i];
|
|
118
|
+
return out;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Re-tokenize nothing, re-rank everything: recompute every block's static rank
|
|
122
|
+
* from the tokens already in the index (called by saveBlock/removeBlock). */
|
|
123
|
+
function rerank(index) {
|
|
124
|
+
const tokensById = {};
|
|
125
|
+
for (const [id, b] of Object.entries(index.blocks)) tokensById[id] = b.tokens || [];
|
|
126
|
+
const ranks = rankBlocks(tokensById);
|
|
127
|
+
for (const [id, b] of Object.entries(index.blocks)) b.rank = ranks[id] ?? 0;
|
|
128
|
+
return index;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Upsert one text block: write <id>.txt (atomic), tokenize it, update
|
|
133
|
+
* index.json and recompute the static ranks. Same id → replaced, never
|
|
134
|
+
* duplicated (fold.mjs's re-fold idempotency rests on this).
|
|
135
|
+
* Returns the block's index entry { file, tokens, rank }.
|
|
136
|
+
*/
|
|
137
|
+
export async function saveBlock(dir, { id, text }) {
|
|
138
|
+
if (!id) throw new Error("a block needs an id");
|
|
139
|
+
const bdir = blocksDir(dir);
|
|
140
|
+
await mkdir(bdir, { recursive: true });
|
|
141
|
+
const file = `${safeName(id)}.txt`;
|
|
142
|
+
await atomicWrite(join(bdir, file), String(text ?? ""));
|
|
143
|
+
const index = await loadBlockIndex(dir);
|
|
144
|
+
index.blocks[id] = { file, tokens: tokenizeBlock(text) };
|
|
145
|
+
rerank(index);
|
|
146
|
+
await atomicWrite(join(bdir, INDEX_NAME), JSON.stringify(index));
|
|
147
|
+
return index.blocks[id];
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Remove a block (id unknown → no-op) and re-rank the survivors. */
|
|
151
|
+
export async function removeBlock(dir, id) {
|
|
152
|
+
const index = await loadBlockIndex(dir);
|
|
153
|
+
const entry = index.blocks[id];
|
|
154
|
+
if (!entry) return false;
|
|
155
|
+
delete index.blocks[id];
|
|
156
|
+
rerank(index);
|
|
157
|
+
await atomicWrite(join(blocksDir(dir), INDEX_NAME), JSON.stringify(index));
|
|
158
|
+
await rm(join(blocksDir(dir), entry.file), { force: true });
|
|
159
|
+
return true;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* The top-k blocks a question "touches": IDF-weighted token match (rarity-
|
|
164
|
+
* weighted, so common words can't dominate) combined with the static PageRank
|
|
165
|
+
* (score × (1 + rank) — on an IDF tie the better-connected block wins).
|
|
166
|
+
* Returns [{ id, score, rank, file, text }], best first; [] when nothing
|
|
167
|
+
* matches (never a guessed block).
|
|
168
|
+
*/
|
|
169
|
+
export async function retrieveBlocks(dir, query, k = 3) {
|
|
170
|
+
const index = await loadBlockIndex(dir);
|
|
171
|
+
const entries = Object.entries(index.blocks);
|
|
172
|
+
const N = entries.length;
|
|
173
|
+
if (!N) return [];
|
|
174
|
+
const qTokens = [...new Set([...tokenizeProse(query), ...splitIdentifierWords(query)])];
|
|
175
|
+
if (!qTokens.length) return [];
|
|
176
|
+
|
|
177
|
+
const sets = entries.map(([, b]) => new Set(b.tokens || []));
|
|
178
|
+
// idf per query token, computed once: log(1 + N/(1+df)) — the codegraph.mjs
|
|
179
|
+
// locate weighting (~0 for ubiquitous tokens, large for rare ones).
|
|
180
|
+
const idf = new Map(qTokens.map((t) => {
|
|
181
|
+
let df = 0;
|
|
182
|
+
for (const s of sets) if (s.has(t)) df += 1;
|
|
183
|
+
return [t, Math.log(1 + N / (1 + df))];
|
|
184
|
+
}));
|
|
185
|
+
const scored = [];
|
|
186
|
+
for (let i = 0; i < N; i += 1) {
|
|
187
|
+
const [id, b] = entries[i];
|
|
188
|
+
let idfSum = 0;
|
|
189
|
+
for (const t of qTokens) if (sets[i].has(t)) idfSum += idf.get(t);
|
|
190
|
+
if (idfSum <= 0) continue;
|
|
191
|
+
const rank = b.rank ?? 0;
|
|
192
|
+
scored.push({ id, score: idfSum * (1 + rank), rank, file: b.file });
|
|
193
|
+
}
|
|
194
|
+
scored.sort((a, b) => b.score - a.score || b.rank - a.rank || a.id.localeCompare(b.id));
|
|
195
|
+
const top = scored.slice(0, Math.max(1, k));
|
|
196
|
+
for (const hit of top) {
|
|
197
|
+
try { hit.text = await readFile(join(blocksDir(dir), hit.file), "utf8"); }
|
|
198
|
+
catch { hit.text = ""; } // index/file drift — honest empty, never a crash
|
|
199
|
+
}
|
|
200
|
+
return top;
|
|
201
|
+
}
|