@polycode-projects/the-mechanical-code-talker 1.3.2 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/init.mjs CHANGED
@@ -28,6 +28,7 @@
28
28
  import { mkdir, readFile, writeFile, stat } from "node:fs/promises";
29
29
  import { dirname, join, resolve } from "node:path";
30
30
  import { fileURLToPath } from "node:url";
31
+ import { stringify as stringifyToml } from "smol-toml";
31
32
 
32
33
  export const CONFIG_FILE = "tmct.toml";
33
34
  export const PROVENANCE_REL = join(".tmct", "init.json");
@@ -55,6 +56,19 @@ export function defaultConfig() {
55
56
  };
56
57
  }
57
58
 
59
+ /** `tmct init --with-persona <name>` presets (Part 7 of the extension-pack
60
+ * batch): a named bundle of `extensions`/`bias` overrides, written into
61
+ * tmct.toml alongside the plain defaults. `code` is TODAY'S IMPLICIT
62
+ * DEFAULT made explicit — an empty `extensions` override (seon + conceptnet
63
+ * are already the shipped builtin defaults; nothing to add) plus an
64
+ * EXPLICIT bias table naming them both at neutral weight, so a repo that
65
+ * chose the `code` persona has a self-documenting tmct.toml rather than
66
+ * relying on an unstated implicit default. Kept minimal on purpose — this
67
+ * batch's job is the persona SEAM, not a curated library of presets. */
68
+ export const PERSONA_PRESETS = Object.freeze({
69
+ code: { extensions: {}, bias: { seon: 1.0, conceptnet: 1.0 } },
70
+ });
71
+
58
72
  /** Read this package's version (best-effort, for provenance). */
59
73
  async function tmctVersion() {
60
74
  try {
@@ -83,7 +97,7 @@ export function renderTomlConfig(config = defaultConfig()) {
83
97
  const c = { ...defaultConfig(), ...config };
84
98
  const corpus = { ...defaultConfig().corpus, ...(config.corpus || {}) };
85
99
  const seed = { ...defaultConfig().seed, ...(config.seed || {}) };
86
- return `# tmct.toml — the mechanical code talker, project configuration.
100
+ const base = `# tmct.toml — the mechanical code talker, project configuration.
87
101
  # Written by \`tmct init\`. An ABSENT file means shipped defaults (this file
88
102
  # just makes them explicit and editable). Documented in the repository-interface
89
103
  # onboarding surface (ROADMAP Phase 8, "Distribution: tmct init").
@@ -109,6 +123,23 @@ enabled = ${seed.enabled ? "true" : "false"}
109
123
  # To cap it, uncomment and set a number (definitional band first):
110
124
  ${seed.limit != null ? `limit = ${Number(seed.limit)}` : "# limit = 500"}
111
125
  `;
126
+ // Extension-pack / bias sections (src/extensions.mjs) — ONLY emitted when a
127
+ // caller actually supplies them (an explicit `--with-persona`, or a manual
128
+ // override); the plain zero-flag `tmct init` output stays BYTE-IDENTICAL to
129
+ // before this feature existed. Rendered via smol-toml's own stringify (not
130
+ // hand-written prose like the base file above) — a plain, uncommented
131
+ // config fragment is honest about being machine-written/round-tripped.
132
+ const extras = {};
133
+ if (config.extensions !== undefined) extras.extensions = config.extensions;
134
+ if (config.bias !== undefined) extras.bias = config.bias;
135
+ if (!Object.keys(extras).length) return base;
136
+ return `${base}
137
+ # Extension packs + bias (src/extensions.mjs) — written by \`tmct init --with-persona\`
138
+ # or a manual edit. Recognized names (seon, conceptnet, tier2-aws, tier2-python,
139
+ # tier2-java) override the shipped defaults; any other name declares a new
140
+ # host-supplied bundle (needs its own "kind"). [bias] is a flat bundle-name ->
141
+ # weight table consumed by src/memory/bias.mjs's ranking.
142
+ ${stringifyToml(extras)}`;
112
143
  }
113
144
 
114
145
  /** Should the seed run? Explicit `opts.seed` wins; otherwise the config's
@@ -132,13 +163,20 @@ function seedRequested({ optSeed, configEnabled, env }) {
132
163
  * `seed.enabled` (TMCT_NO_SEED still vetoes).
133
164
  * @param {object} [opts.env] environment (for TMCT_NO_SEED); defaults to
134
165
  * process.env.
166
+ * @param {object} [opts.persona] a resolved PERSONA_PRESETS entry
167
+ * ({extensions?, bias?}) to merge into the FRESH config before it's written
168
+ * (Part 7, `tmct init --with-persona <name>`). Name -> preset resolution
169
+ * and unknown-name validation are the CALLER'S job (bin/tmct.mjs) — this
170
+ * only ever sees an already-resolved preset object (or nothing). Has no
171
+ * effect when tmct.toml already exists and `force` isn't set (the existing
172
+ * "preserve a user's tmct.toml" rule wins, same as `seed`/`corpus.tier`).
135
173
  * @returns {Promise<{
136
174
  * created: string[], config: object, seeded: boolean,
137
175
  * alreadyInitialized: boolean, seedResult: (object|null), message: string
138
176
  * }>} `created` lists the ABSOLUTE paths this call brought into being (empty on a
139
177
  * benign no-op re-init). Never throws on a benign re-init or a corpus failure.
140
178
  */
141
- export async function initRepo(dir, { force = false, seed, env = process.env } = {}) {
179
+ export async function initRepo(dir, { force = false, seed, env = process.env, persona = null } = {}) {
142
180
  const root = resolve(dir);
143
181
  const created = [];
144
182
  const paths = {
@@ -162,6 +200,15 @@ export async function initRepo(dir, { force = false, seed, env = process.env } =
162
200
 
163
201
  // ---- 2. The externalised config (preserve an existing file unless force) ----
164
202
  let config = defaultConfig();
203
+ // Persona overrides (Part 7) apply ONLY to a FRESH write — an empty preset
204
+ // field (e.g. `code`'s `extensions: {}`) is a genuine no-op, never an
205
+ // explicit-empty-section write (renderTomlConfig only emits [extensions]/
206
+ // [bias] when the merged config actually carries a non-empty one — the
207
+ // plain zero-flag `tmct init` output stays byte-identical either way).
208
+ if (persona) {
209
+ if (persona.extensions && Object.keys(persona.extensions).length) config.extensions = persona.extensions;
210
+ if (persona.bias && Object.keys(persona.bias).length) config.bias = persona.bias;
211
+ }
165
212
  const tomlPresent = await exists(paths.toml);
166
213
  if (!tomlPresent || force) {
167
214
  await writeFile(paths.toml, renderTomlConfig(config));
@@ -172,7 +219,16 @@ export async function initRepo(dir, { force = false, seed, env = process.env } =
172
219
  config = await readWrittenConfig(paths.toml, config);
173
220
  }
174
221
 
175
- // ---- 3. Seed the tier-1 committed corpus (offline, failure-tolerant) ----
222
+ // ---- 3. Seed the committed corpus (offline, failure-tolerant) ----
223
+ // DELIBERATE BUG FIX (this batch): `tmct init`'s zero-flag seed step used to
224
+ // seed ONLY the ConceptNet band, never the curated SEON ontology — unlike
225
+ // chat.mjs's own first-run bootstrap (seedBootstrapMemory), which has always
226
+ // seeded BOTH. Both now go through the SAME unified loop
227
+ // (src/extensions.mjs's resolveExtensions + seedActiveCorpusEntries), so
228
+ // `tmct init`'s seed matches chat's bootstrap exactly — this changes
229
+ // `initRepo`'s seeded fact COUNT (test/init.test.mjs's seed-count assertions
230
+ // were updated for the larger post-fix totals, deliberately, in the same
231
+ // commit as this fix).
176
232
  let seeded = false;
177
233
  let seedResult = null;
178
234
  let seedNote = "";
@@ -185,18 +241,42 @@ export async function initRepo(dir, { force = false, seed, env = process.env } =
185
241
  seedNote = "seed skipped (already seeded — marker present)";
186
242
  } else {
187
243
  try {
188
- const { seedMemory } = await import("./corpus/conceptnet.mjs");
189
- const limit = config.seed?.limit != null ? Number(config.seed.limit) : SEED_LIMIT;
190
- seedResult = await seedMemory(root, { limit, prefer: SEED_PREFER });
244
+ const { resolveExtensions, seedActiveCorpusEntries } = await import("./extensions.mjs");
245
+ const { entries } = await resolveExtensions(root);
246
+ // `tmct.toml`'s `[seed] limit` knob is documented as capping the tier-1
247
+ // ConceptNet band specifically (the curated SEON ontology is small and
248
+ // always seeds whole) — so it overrides ONLY the resolved "conceptnet"
249
+ // entry's limit, exactly like the pre-fix single-corpus seed did.
250
+ if (config.seed?.limit != null && entries.has("conceptnet")) {
251
+ entries.set("conceptnet", { ...entries.get("conceptnet"), limit: Number(config.seed.limit) });
252
+ }
253
+ const { appended, skipped, total, perBundle } = await seedActiveCorpusEntries(root, entries);
254
+ // seedActiveCorpusEntries is failure-tolerant PER BUNDLE (a bad third-party
255
+ // pack never aborts the others) — but initRepo's own "FAILURE-TOLERANT
256
+ // SEED" contract is about the SEED AS A WHOLE degrading honestly. If every
257
+ // active bundle failed (e.g. the memory graph file itself is unwritable —
258
+ // see test/init.test.mjs "seed failure degrades"), re-throw the first
259
+ // bundle's error so the SAME outer catch below reports the familiar "seed
260
+ // skipped (corpus unavailable: …)" note, rather than claiming success with
261
+ // zero facts actually written.
262
+ const bundleNames = Object.keys(perBundle);
263
+ const allFailed = bundleNames.length > 0 && bundleNames.every((n) => perBundle[n].error);
264
+ if (allFailed) throw new Error(perBundle[bundleNames[0]].error);
265
+ seedResult = {
266
+ appended, skipped, total, perBundle,
267
+ seon: perBundle.seon?.appended || 0,
268
+ conceptnet: perBundle.conceptnet?.appended || 0,
269
+ };
191
270
  const markerNew = !(await exists(paths.marker));
192
271
  await mkdir(dirname(paths.marker), { recursive: true });
193
272
  await writeFile(
194
273
  paths.marker,
195
274
  JSON.stringify({
196
275
  seededAt: new Date().toISOString(),
197
- limit,
276
+ limit: config.seed?.limit != null ? Number(config.seed.limit) : SEED_LIMIT,
198
277
  appended: seedResult.appended,
199
278
  skipped: seedResult.skipped,
279
+ perBundle,
200
280
  }) + "\n",
201
281
  );
202
282
  if (markerNew) created.push(paths.marker);
@@ -245,6 +325,11 @@ async function readWrittenConfig(tomlPath, base) {
245
325
  if (raw.seed.enabled !== undefined) cfg.seed.enabled = Boolean(raw.seed.enabled);
246
326
  if (raw.seed.limit !== undefined) cfg.seed.limit = Number(raw.seed.limit);
247
327
  }
328
+ // Sparse pass-through (src/extensions.mjs validates; this layer just carries
329
+ // the raw tables through unmodified, same discipline as toml-config.mjs's
330
+ // normalizeConfig).
331
+ if (raw.extensions !== undefined) cfg.extensions = raw.extensions;
332
+ if (raw.bias !== undefined) cfg.bias = raw.bias;
248
333
  return cfg;
249
334
  } catch {
250
335
  return base;
@@ -0,0 +1,77 @@
1
+ // memory/bias.mjs — bias-weighted fact ranking (extension-pack batch, Part 6).
2
+ //
3
+ // A small, PURE module, deliberately SEPARATE from trust.mjs's existing closed
4
+ // 3-input computeTrust contract (many tests pin computeTrust's exact
5
+ // signature; this module never touches it). Trust answers "how much do I
6
+ // believe this fact"; bias answers a DIFFERENT question an operator asks
7
+ // explicitly — "when two facts disagree, which BUNDLE do I prefer to hear
8
+ // from first" (the worked example: is a class part of code, or part of a
9
+ // school — a `[bias]` table lets an operator say "for MY repo, weight the
10
+ // code-vocabulary bundle over the general-English one").
11
+ //
12
+ // biasForSourceId(sourceId, biasByBundle) "src:corpus:<name>" -> weight (default 1)
13
+ // biasForRow(row, biasByBundle) max bias across row.sourceIds
14
+ // rankByBiasThenTrust(rows, biasByBundle) stable: bias desc, trust desc, original order
15
+ //
16
+ // biasByBundle is the flat `{ bundleName: number }` table src/extensions.mjs's
17
+ // resolveExtensions() reads out of tmct.toml's `[bias]` table — resolved ONCE
18
+ // per chat session and threaded through, never re-read per turn.
19
+ //
20
+ // CRITICAL CONTRACT (the operator's own "disclosed, never dropped"
21
+ // requirement): bias only REORDERS a hit list. It must NEVER drop, hide, or
22
+ // silently prefer a lower-biased fact over a higher-trust one within the same
23
+ // bias tier — every hit rankByBiasThenTrust is given still comes back out,
24
+ // same length, same members, just reordered.
25
+
26
+ /** A Fact's statedBy Source id is shaped "src:corpus:<bundleName>" for every
27
+ * corpus-kind Source (memory/core.mjs's own sourceIdFor — see its `corpus`
28
+ * case). Any other shape (operator/teach/provider/web/entailed Source ids,
29
+ * or a malformed/absent id) is NOT a corpus bundle and always ranks at the
30
+ * neutral bias of 1 — bias is a corpus-bundle-only concept, never a proxy
31
+ * for a different trust dimension. */
32
+ const CORPUS_SOURCE_RE = /^src:corpus:(.+)$/;
33
+
34
+ /** The bias weight of one Source id, resolved against `biasByBundle`. Default
35
+ * 1 (neutral) for a non-corpus source id, an unconfigured bundle name, or a
36
+ * non-numeric configured value (never throws — a malformed `[bias]` entry is
37
+ * caught earlier, at resolveExtensions() load time). */
38
+ export function biasForSourceId(sourceId, biasByBundle = {}) {
39
+ const m = CORPUS_SOURCE_RE.exec(String(sourceId || ""));
40
+ if (!m) return 1;
41
+ const v = biasByBundle?.[m[1]];
42
+ return typeof v === "number" && Number.isFinite(v) ? v : 1;
43
+ }
44
+
45
+ /** The bias weight of one fact ROW (readFactRows' shape: `{..., sourceIds}`)
46
+ * — the MAX bias across its (possibly several, corroborating) Sources, so a
47
+ * fact corroborated by both a neutral and a high-bias bundle ranks at the
48
+ * higher of the two, never averaged down. A row with no sourceIds (or an
49
+ * empty array) ranks at the neutral 1. */
50
+ export function biasForRow(row, biasByBundle = {}) {
51
+ const ids = Array.isArray(row?.sourceIds) ? row.sourceIds : [];
52
+ if (!ids.length) return 1;
53
+ return Math.max(...ids.map((id) => biasForSourceId(id, biasByBundle)));
54
+ }
55
+
56
+ /**
57
+ * Rank fact rows by bias (desc), then trust (desc), then original relative
58
+ * order (STABLE — Array.prototype.sort is stable in Node, reinforced here
59
+ * with an explicit index tiebreak so the guarantee never depends on engine
60
+ * internals). Every row that goes in comes back out — same length, same
61
+ * members — this ONLY reorders, it never filters or drops a hit, honouring
62
+ * the "disclosed, never dropped" contract every caller in chat.mjs relies on.
63
+ *
64
+ * With an empty/absent `biasByBundle` (the default, unconfigured case) every
65
+ * row's bias is 1 — a true no-op tier, so the sort degrades to trust-desc,
66
+ * ties broken by original order: BYTE-IDENTICAL to today's behaviour for any
67
+ * caller that previously sorted by trust alone (or didn't sort at all and
68
+ * every row happened to share the same trust, the common single-session
69
+ * operator-taught case).
70
+ */
71
+ export function rankByBiasThenTrust(rows, biasByBundle = {}) {
72
+ const list = Array.isArray(rows) ? rows : [];
73
+ return list
74
+ .map((row, index) => ({ row, index, bias: biasForRow(row, biasByBundle) }))
75
+ .sort((a, b) => (b.bias - a.bias) || ((b.row?.trust ?? 0) - (a.row?.trust ?? 0)) || (a.index - b.index))
76
+ .map((x) => x.row);
77
+ }
@@ -15,8 +15,12 @@
15
15
  // 2. QUERY-time IDF match — each query token is weighted by rarity across
16
16
  // blocks (idf = log(1 + N/(1+df)), the codegraph.mjs locate discipline),
17
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.
18
+ // retrieveBlocks() scores idf-sum × (1 + rank) ÷ sqrt(1 + degree): the IDF
19
+ // match decides topic, the static rank breaks ties toward well-connected
20
+ // blocks, and the degree divisor (hub dampening, ported from codegraph.mjs's
21
+ // spiralExpand degree-quantile gate; on by default here) stops a block that
22
+ // merely shares vocabulary with disproportionately many others — a generic
23
+ // "boilerplate" hub — from winning on inflated rank alone.
20
24
  //
21
25
  // All writes are temp+rename atomic; saveBlock is an upsert (same id replaces —
22
26
  // what makes fold.mjs's re-fold idempotent).
@@ -79,20 +83,15 @@ export async function loadBlockIndex(dir) {
79
83
  }
80
84
 
81
85
  /**
82
- * Iterative PageRank over the block-similarity graph. `tokensById` is a plain
83
- * { id: tokens[] } map; an undirected edge joins two blocks sharing at least
84
- * `overlapMin` tokens. Standard damped iteration (d=0.85, 20 rounds), dangling
85
- * mass redistributed evenly, ranks summing to ~1. Pure — returns { id: rank }.
86
+ * Build the block-similarity adjacency shared by rankBlocks and degreeOf:
87
+ * `tokensById` is a plain { id: tokens[] } map; an undirected edge joins two
88
+ * blocks sharing at least `overlapMin` tokens. Returns { ids, neighbours }
89
+ * (neighbours[i] is an array of adjacent indices into ids).
86
90
  */
87
- export function rankBlocks(tokensById, {
88
- damping = PAGERANK_DAMPING, iterations = PAGERANK_ITERATIONS, overlapMin = OVERLAP_MIN,
89
- } = {}) {
91
+ function buildNeighbours(tokensById, overlapMin) {
90
92
  const ids = Object.keys(tokensById || {});
91
93
  const N = ids.length;
92
- if (!N) return {};
93
94
  const sets = ids.map((id) => new Set(tokensById[id] || []));
94
-
95
- // similarity edges: shared-token count ≥ overlapMin (undirected → both directions)
96
95
  const neighbours = ids.map(() => []);
97
96
  for (let i = 0; i < N; i += 1) {
98
97
  for (let j = i + 1; j < N; j += 1) {
@@ -107,6 +106,35 @@ export function rankBlocks(tokensById, {
107
106
  }
108
107
  }
109
108
  }
109
+ return { ids, neighbours };
110
+ }
111
+
112
+ /**
113
+ * Each block's degree (neighbour count) in the same block-similarity graph
114
+ * rankBlocks runs PageRank over. Pure — returns { id: degree }. Used to
115
+ * dampen retrieveBlocks' hub bias (a block linked to many others shouldn't
116
+ * win purely by being well-connected — codegraph.mjs's spiralExpand applies
117
+ * the same degree-quantile idea to module search).
118
+ */
119
+ export function degreeOf(tokensById, { overlapMin = OVERLAP_MIN } = {}) {
120
+ const { ids, neighbours } = buildNeighbours(tokensById, overlapMin);
121
+ const out = {};
122
+ for (let i = 0; i < ids.length; i += 1) out[ids[i]] = neighbours[i].length;
123
+ return out;
124
+ }
125
+
126
+ /**
127
+ * Iterative PageRank over the block-similarity graph. `tokensById` is a plain
128
+ * { id: tokens[] } map; an undirected edge joins two blocks sharing at least
129
+ * `overlapMin` tokens. Standard damped iteration (d=0.85, 20 rounds), dangling
130
+ * mass redistributed evenly, ranks summing to ~1. Pure — returns { id: rank }.
131
+ */
132
+ export function rankBlocks(tokensById, {
133
+ damping = PAGERANK_DAMPING, iterations = PAGERANK_ITERATIONS, overlapMin = OVERLAP_MIN,
134
+ } = {}) {
135
+ const { ids, neighbours } = buildNeighbours(tokensById, overlapMin);
136
+ const N = ids.length;
137
+ if (!N) return {};
110
138
 
111
139
  let rank = new Array(N).fill(1 / N);
112
140
  for (let round = 0; round < iterations; round += 1) {
@@ -133,7 +161,11 @@ function rerank(index) {
133
161
  const tokensById = {};
134
162
  for (const [id, b] of Object.entries(index.blocks)) tokensById[id] = b.tokens || [];
135
163
  const ranks = rankBlocks(tokensById);
136
- for (const [id, b] of Object.entries(index.blocks)) b.rank = ranks[id] ?? 0;
164
+ const degrees = degreeOf(tokensById);
165
+ for (const [id, b] of Object.entries(index.blocks)) {
166
+ b.rank = ranks[id] ?? 0;
167
+ b.degree = degrees[id] ?? 0;
168
+ }
137
169
  return index;
138
170
  }
139
171
 
@@ -177,9 +209,10 @@ export async function removeBlock(dir, id) {
177
209
  /**
178
210
  * The top-k blocks a question "touches": IDF-weighted token match (rarity-
179
211
  * weighted, so common words can't dominate) combined with the static PageRank
180
- * (score × (1 + rank) on an IDF tie the better-connected block wins).
181
- * Returns [{ id, score, rank, file, text }], best first; [] when nothing
182
- * matches (never a guessed block).
212
+ * (score × (1 + rank), divided by sqrt(1 + degree) to dampen hubs — a block
213
+ * that shares vocabulary with disproportionately many others doesn't win on
214
+ * inflated rank alone). Returns [{ id, score, rank, file, text }], best
215
+ * first; [] when nothing matches (never a guessed block).
183
216
  */
184
217
  export async function retrieveBlocks(dir, query, k = 3) {
185
218
  const index = await loadBlockIndex(dir);
@@ -204,12 +237,18 @@ export async function retrieveBlocks(dir, query, k = 3) {
204
237
  for (const t of qTokens) if (sets[i].has(t)) idfSum += idf.get(t);
205
238
  if (idfSum <= 0) continue;
206
239
  const rank = b.rank ?? 0;
240
+ const degree = b.degree ?? 0;
207
241
  // relevance × connectivity × TRUST — a bounded trustFactor (~[0.5, 1.5]) so a
208
242
  // corroborated/operator block outranks a lone low-trust one on a relevance tie,
209
- // yet a weakly-trusted but perfectly-relevant block still surfaces.
243
+ // yet a weakly-trusted but perfectly-relevant block still surfaces. Divided by
244
+ // sqrt(1 + degree) — hub dampening (the codegraph.mjs spiralExpand idea, ported
245
+ // here): a block that shares vocabulary with disproportionately many others
246
+ // shouldn't win purely by being well-connected.
210
247
  const trust = typeof b.trust === "number" ? b.trust : blockTrust(b.sourceType);
211
248
  const trustFactor = trustFactorOf(trust);
212
- scored.push({ id, score: idfSum * (1 + rank) * trustFactor, rank, trust, file: b.file });
249
+ scored.push({
250
+ id, score: (idfSum * (1 + rank) * trustFactor) / Math.sqrt(1 + degree), rank, trust, file: b.file,
251
+ });
213
252
  }
214
253
  scored.sort((a, b) => b.score - a.score || b.rank - a.rank || a.id.localeCompare(b.id));
215
254
  const top = scored.slice(0, Math.max(1, k));