@polycode-projects/the-mechanical-code-talker 0.3.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.
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
+ }
@@ -90,6 +90,40 @@ export function applyNegationFrames(text) {
90
90
  return text;
91
91
  }
92
92
 
93
+ // ---- §B1 negation — the SET-COMPLEMENT frame (Cycle 5, PLAN_CYCLE_4.md). Recognizes
94
+ // a BARE set-negation query — "which X do not <verb> Y", "X that don't <verb> Y",
95
+ // "modules not importing Y", "which X are not <qualifier>" — and returns a descriptor
96
+ // {entWord, predicate} that ask.mjs's compositional grammar turns into a bounded
97
+ // complement (allOfClass(kind) MINUS the positive result set). Deliberately SEPARATE
98
+ // from applyNegationFrames/NEGATION_FRAMES above: that table is a rhetorical
99
+ // double-negative rewriter that REMOVES negation ("there isn't anything calling it" ->
100
+ // "what calls it") and its docblock forbids scope parsing; this detector PRESERVES the
101
+ // negation as a set operation. Returns null when no set-negation marker is present, so
102
+ // every affirmative query passes through untouched (the active-voice regression guard).
103
+ // The entWord is validated against the entity vocabulary by the caller, which also
104
+ // enforces the bounded-universe refusal for the non-enumerable "changes" pseudo-type. ----
105
+ const NEGATION_SET_RE = new RegExp(
106
+ "^(?:which|what|who|list|show(?:\\s+me)?|find|give\\s+me)?\\s*(?:the\\s+|all\\s+)?" // optional frame + determiner
107
+ + "([a-z][a-z-]*)\\s+" // (1) the entity kind noun
108
+ + "(?:(?:that|which|who)\\s+)?" // optional relative pronoun
109
+ + "(?:(?:do|does|did|are|is|was|were|have|has)\\s+)?" // optional auxiliary
110
+ + "not\\s+(.+)$", // the negation marker + (2) the predicate
111
+ "i",
112
+ );
113
+
114
+ /** Recognize a bare set-negation query and return {entWord, predicate}, or null when
115
+ * no set-negation marker ("not") follows an entity noun. Pure text analysis — the
116
+ * caller (ask.mjs's parseNegation) validates the entity kind, refuses the
117
+ * non-enumerable "changes" universe, and builds the complement AST. */
118
+ export function matchNegationSet(text) {
119
+ const m = String(text || "").match(NEGATION_SET_RE);
120
+ if (!m) return null;
121
+ const entWord = m[1].toLowerCase();
122
+ const predicate = m[2].trim();
123
+ if (!predicate) return null;
124
+ return { entWord, predicate };
125
+ }
126
+
93
127
  // ---- shared text-prep helpers (used by the strategies, the compositional
94
128
  // grammar, and ask.mjs's relaxation cascade alike) ----
95
129
 
@@ -7,11 +7,20 @@
7
7
 
8
8
  import {
9
9
  VERB_TO_KIND, ENTITY_TO_TYPE, MODIFIER_TO_KIND,
10
- WHERE_MARKERS, MENTION_MARKERS,
10
+ WHERE_MARKERS, MENTION_MARKERS, PLACEHOLDER_NOUNS, PASSIVE_PARTICIPLE_TO_KIND,
11
11
  } from "../../ask-vocab.mjs";
12
12
  import { STOPWORDS } from "../normalize.mjs";
13
13
  import { VOCAB_WORDS, eligibleForCanon, fuzzyVocabWord } from "../fuzzy.mjs";
14
14
 
15
+ // Reversible-passive detection (Cycle 6, PLAN_CYCLE_4.md): the passive auxiliaries that,
16
+ // together with an agent-marking "by", flip the active reading, and the wh-words that
17
+ // mark a QUESTIONED agent ("by which classes" / stranded "who is X tested by"). Bare
18
+ // "do/does/did" are deliberately EXCLUDED — "which X do not <verb> Y" is a NEGATION, not
19
+ // a passive, and must be left for the compositional complement frame.
20
+ const PASSIVE_AUX = new Set(["is", "are", "was", "were", "be", "been", "being", "get", "gets", "got"]);
21
+ const WH_WORDS = new Set(["which", "what", "who", "whom", "whose"]);
22
+ const PLACEHOLDER_SET = new Set(PLACEHOLDER_NOUNS.map((w) => w.toLowerCase()));
23
+
15
24
  /** Find the longest phrase from `table`'s keys that appears as a contiguous
16
25
  * run of `words` (case already lowercased by the caller). Longest-match-first
17
26
  * (multi-word phrases before single words) so "co-changes with" isn't
@@ -107,6 +116,17 @@ export function parseKeywordSpot(text, nlp = null) {
107
116
  verbHit = findPhrase(fuzzyWords, VERB_TO_KIND);
108
117
  if (verbHit) canonWords = fuzzyWords;
109
118
  }
119
+ if (!verbHit && lcWords.includes("by")) {
120
+ // passive-participle rescue (Cycle 6): a participle whose kind is NOT a standalone
121
+ // active verb here ("defined" belongs to "is defined in"; bare "inherited" has no
122
+ // active key) still marks a passive when a passive auxiliary and an agent "by" are
123
+ // present. Consulted ONLY on this exact gate (aux + by), so the active grammar and
124
+ // the where-marker routing ("where is X defined") are never disturbed.
125
+ for (let i = 0; i < lcWords.length; i += 1) {
126
+ const k = PASSIVE_PARTICIPLE_TO_KIND[lcWords[i]];
127
+ if (k && lcWords.slice(0, i).some((w) => PASSIVE_AUX.has(w))) { verbHit = { kind: k, start: i, end: i + 1 }; break; }
128
+ }
129
+ }
110
130
  if (!verbHit) return null;
111
131
  // POS consumer (wink adapter, Node-side only): rescue the ONE decomposition this
112
132
  // strategy provably mis-parses — a relation word used as a NOUN in a "the
@@ -156,6 +176,42 @@ export function parseKeywordSpot(text, nlp = null) {
156
176
  if (objText) return { shape: "when", entityType: null, modifier: "direct", kind: "touches", object: objText };
157
177
  }
158
178
 
179
+ // reversible passive (Cycle 6, PLAN_CYCLE_4.md): "PATIENT is VERBed BY AGENT" — an
180
+ // agent-marking "by" plus a passive auxiliary flips the active reading, so the AGENT
181
+ // (after "by") is the edge SUBJECT and the PATIENT the edge OBJECT. Object-first
182
+ // phrasing is otherwise read subject-first and the edge traversed backwards. Fires
183
+ // ONLY on a genuine agent "by": a passive auxiliary before the verb AND a standalone
184
+ // "by" NOT already swallowed into a multi-word verb phrase ("touched by"/"modified
185
+ // by" are single touches verbs, so their "by" is consumed and never triggers this) —
186
+ // an active query whose object merely contains a "by" token is untouched (the
187
+ // regression guard). The single NAMED role term becomes the object; whether the AGENT
188
+ // is named ("by b.test.mjs" → forward from the agent) or QUESTIONED ("by which
189
+ // classes" / a stranded "…tested by" → reverse over the patient) picks the direction.
190
+ const byIdx = lcWords.indexOf("by");
191
+ const hasPassiveAux = lcWords.slice(0, verbHit.start).some((w) => PASSIVE_AUX.has(w));
192
+ if (byIdx >= 0 && !consumed.has(byIdx) && hasPassiveAux) {
193
+ const roleWords = [];
194
+ for (let i = 0; i < words.length; i += 1) {
195
+ const w = lcWords[i];
196
+ if (consumed.has(i) || STOPWORDS.has(w) || w === "by" || PASSIVE_AUX.has(w)
197
+ || WH_WORDS.has(w) || PLACEHOLDER_SET.has(w)) continue;
198
+ roleWords.push(words[i]);
199
+ }
200
+ const object = roleWords.join(" ").trim();
201
+ if (object) {
202
+ // the first meaningful token after "by" (skipping only articles) decides direction:
203
+ // a wh-word or nothing → the agent is questioned (reverse over the named patient);
204
+ // a named token → the agent is given (forward from it).
205
+ let nextAfterBy = null;
206
+ for (let i = byIdx + 1; i < lcWords.length; i += 1) {
207
+ if (lcWords[i] === "the" || lcWords[i] === "a" || lcWords[i] === "an") continue;
208
+ nextAfterBy = lcWords[i]; break;
209
+ }
210
+ const agentNamed = nextAfterBy != null && !WH_WORDS.has(nextAfterBy) && !ENTITY_TO_TYPE[nextAfterBy];
211
+ return { shape: agentNamed ? "forward" : "reverse", entityType, modifier, kind, object };
212
+ }
213
+ }
214
+
159
215
  if (beforeText && afterText) return { shape: "ask", entityType: null, modifier: "direct", kind, subject: beforeText, object: afterText };
160
216
  if (afterText) return { shape: "reverse", entityType, modifier, kind, object: afterText };
161
217
  // forward keeps the spotted entityType ("which modules did commit <sha> touch" is a
@@ -24,6 +24,15 @@
24
24
  import { mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
25
25
  import { join } from "node:path";
26
26
  import { splitIdentifierWords, tokenizeProse } from "../prose.mjs";
27
+ import { SOURCE_PRIOR } from "./trust.mjs";
28
+
29
+ // A block inherits the trust of the Source it was folded from: a session block
30
+ // is operator-chat (1.0), a corpus block its corpus Source (0.7). Retrieval
31
+ // weights relevance × trust via a BOUNDED factor (≈ 0.5 + trust → ~[0.5, 1.5]),
32
+ // a capped nudge so a weakly-trusted but perfectly-relevant block still surfaces.
33
+ const DEFAULT_BLOCK_SOURCE_TYPE = "operator";
34
+ const blockTrust = (sourceType) => SOURCE_PRIOR[sourceType] ?? SOURCE_PRIOR.operator;
35
+ const trustFactorOf = (trust) => 0.5 + (typeof trust === "number" ? trust : SOURCE_PRIOR.operator);
27
36
 
28
37
  export const BLOCKS_DIR_REL = join(".tmct", "memory", "blocks");
29
38
  const INDEX_NAME = "index.json";
@@ -134,14 +143,20 @@ function rerank(index) {
134
143
  * duplicated (fold.mjs's re-fold idempotency rests on this).
135
144
  * Returns the block's index entry { file, tokens, rank }.
136
145
  */
137
- export async function saveBlock(dir, { id, text }) {
146
+ export async function saveBlock(dir, { id, text, sourceType = DEFAULT_BLOCK_SOURCE_TYPE, createdAt = "" }) {
138
147
  if (!id) throw new Error("a block needs an id");
139
148
  const bdir = blocksDir(dir);
140
149
  await mkdir(bdir, { recursive: true });
141
150
  const file = `${safeName(id)}.txt`;
142
151
  await atomicWrite(join(bdir, file), String(text ?? ""));
143
152
  const index = await loadBlockIndex(dir);
144
- index.blocks[id] = { file, tokens: tokenizeBlock(text) };
153
+ const prior = index.blocks[id];
154
+ index.blocks[id] = {
155
+ file, tokens: tokenizeBlock(text),
156
+ // first-write-wins createdAt (step (a)) + the block's inherited source trust (step (d)).
157
+ createdAt: prior?.createdAt || createdAt || new Date().toISOString(),
158
+ sourceType, trust: blockTrust(sourceType),
159
+ };
145
160
  rerank(index);
146
161
  await atomicWrite(join(bdir, INDEX_NAME), JSON.stringify(index));
147
162
  return index.blocks[id];
@@ -189,7 +204,12 @@ export async function retrieveBlocks(dir, query, k = 3) {
189
204
  for (const t of qTokens) if (sets[i].has(t)) idfSum += idf.get(t);
190
205
  if (idfSum <= 0) continue;
191
206
  const rank = b.rank ?? 0;
192
- scored.push({ id, score: idfSum * (1 + rank), rank, file: b.file });
207
+ // relevance × connectivity × TRUST a bounded trustFactor (~[0.5, 1.5]) so a
208
+ // corroborated/operator block outranks a lone low-trust one on a relevance tie,
209
+ // yet a weakly-trusted but perfectly-relevant block still surfaces.
210
+ const trust = typeof b.trust === "number" ? b.trust : blockTrust(b.sourceType);
211
+ const trustFactor = trustFactorOf(trust);
212
+ scored.push({ id, score: idfSum * (1 + rank) * trustFactor, rank, trust, file: b.file });
193
213
  }
194
214
  scored.sort((a, b) => b.score - a.score || b.rank - a.rank || a.id.localeCompare(b.id));
195
215
  const top = scored.slice(0, Math.max(1, k));