@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.
@@ -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
+ }