@polycode-projects/the-mechanical-code-talker 1.8.20 → 1.9.1

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
@@ -148,6 +148,27 @@ enabled = ${seed.enabled ? "true" : "false"}
148
148
  # To cap it, uncomment and set a number (definitional band first):
149
149
  ${seed.limit != null ? `limit = ${Number(seed.limit)}` : "# limit = 500"}
150
150
  `;
151
+ // [memory] backend — ONLY emitted when a caller actually supplies it (an
152
+ // explicit `tmct init --memory-backend <...>`, or a manual override); the
153
+ // plain zero-flag `tmct init` output stays BYTE-IDENTICAL to before this
154
+ // knob existed, same discipline as the extras block below. "default" is
155
+ // written out explicitly rather than omitted, so `--memory-backend default`
156
+ // leaves a self-documenting trace of the choice (mirrors --with-persona's
157
+ // own "make the default explicit" behaviour).
158
+ let out = base;
159
+ if (config.memory && config.memory.backend !== undefined) {
160
+ out += `
161
+ [memory]
162
+ # Storage backend for taught facts + the memory graph (PLAN_SEED.md §6).
163
+ # Precedence: --memory-backend flag > TMCT_MEMORY_BACKEND env > this file >
164
+ # "default" (the built-in fallback).
165
+ # "default" — the flat OWL-labelled JSON file under .tmct/memory/. The default.
166
+ # "memory" — in-process only; nothing written to disk (a library caller's option).
167
+ # "sqlite" — a local SQLite file at .tmct/memory/graph.sqlite.
168
+ backend = ${JSON.stringify(config.memory.backend)}
169
+ `;
170
+ }
171
+
151
172
  // Extension-pack / bias sections (src/extensions.mjs) — ONLY emitted when a
152
173
  // caller actually supplies them (an explicit `--with-persona`, or a manual
153
174
  // override); the plain zero-flag `tmct init` output stays BYTE-IDENTICAL to
@@ -157,8 +178,8 @@ ${seed.limit != null ? `limit = ${Number(seed.limit)}` : "# limit = 500"}
157
178
  const extras = {};
158
179
  if (config.extensions !== undefined) extras.extensions = config.extensions;
159
180
  if (config.bias !== undefined) extras.bias = config.bias;
160
- if (!Object.keys(extras).length) return base;
161
- return `${base}
181
+ if (!Object.keys(extras).length) return out;
182
+ return `${out}
162
183
  # Extension packs + bias (src/extensions.mjs) — written by \`tmct init --with-persona\`
163
184
  # or a manual edit. Recognized names (human, seon, conceptnet, tier2-aws,
164
185
  # tier2-python, tier2-java, tier2-general) override the shipped defaults; any
@@ -196,13 +217,21 @@ function seedRequested({ optSeed, configEnabled, env }) {
196
217
  * only ever sees an already-resolved preset object (or nothing). Has no
197
218
  * effect when tmct.toml already exists and `force` isn't set (the existing
198
219
  * "preserve a user's tmct.toml" rule wins, same as `seed`/`corpus.tier`).
220
+ * @param {string} [opts.memoryBackend] "default" | "memory" | "sqlite" — merged
221
+ * into the FRESH config's `[memory] backend` before it's written (same
222
+ * "fresh write only" rule as `persona`, above; `tmct init --memory-backend
223
+ * <...>` on an ALREADY-initialized repo is bin/tmct.mjs's own job, mirroring
224
+ * how `--graph` amends an existing tmct.toml post-hoc). Also selects which
225
+ * backend the corpus SEED below (step 3) writes into — src/memory/core.mjs's
226
+ * `openMemoryBackend`, the same resolver chat.mjs's createSession uses, so a
227
+ * seeded fact and a later chat-taught fact always land in the same store.
199
228
  * @returns {Promise<{
200
229
  * created: string[], config: object, seeded: boolean,
201
230
  * alreadyInitialized: boolean, seedResult: (object|null), message: string
202
231
  * }>} `created` lists the ABSOLUTE paths this call brought into being (empty on a
203
232
  * benign no-op re-init). Never throws on a benign re-init or a corpus failure.
204
233
  */
205
- export async function initRepo(dir, { force = false, seed, env = process.env, persona = null } = {}) {
234
+ export async function initRepo(dir, { force = false, seed, env = process.env, persona = null, memoryBackend = null } = {}) {
206
235
  const root = resolve(dir);
207
236
  const created = [];
208
237
  const paths = {
@@ -235,6 +264,7 @@ export async function initRepo(dir, { force = false, seed, env = process.env, pe
235
264
  if (persona.extensions && Object.keys(persona.extensions).length) config.extensions = persona.extensions;
236
265
  if (persona.bias && Object.keys(persona.bias).length) config.bias = persona.bias;
237
266
  }
267
+ if (memoryBackend) config.memory = { ...(config.memory || {}), backend: memoryBackend };
238
268
  const tomlPresent = await exists(paths.toml);
239
269
  if (!tomlPresent || force) {
240
270
  await writeFile(paths.toml, renderTomlConfig(config));
@@ -266,50 +296,70 @@ export async function initRepo(dir, { force = false, seed, env = process.env, pe
266
296
  } else if ((await exists(paths.marker)) && !force) {
267
297
  seedNote = "seed skipped (already seeded — marker present)";
268
298
  } else {
269
- try {
270
- const { resolveExtensions, seedActiveCorpusEntries } = await import("./extensions.mjs");
271
- const { entries } = await resolveExtensions(root);
272
- // `tmct.toml`'s `[seed] limit` knob is documented as capping the tier-1
273
- // ConceptNet band specifically (the curated SEON ontology is small and
274
- // always seeds whole)so it overrides ONLY the resolved "conceptnet"
275
- // entry's limit, exactly like the pre-fix single-corpus seed did.
276
- if (config.seed?.limit != null && entries.has("conceptnet")) {
277
- entries.set("conceptnet", { ...entries.get("conceptnet"), limit: Number(config.seed.limit) });
299
+ // BUG FIX (found in review): this step used to call seedActiveCorpusEntries
300
+ // with the plain `root` string ALWAYS — Backend A only — regardless of
301
+ // `config.memory.backend`. A `tmct init --memory-backend sqlite` repo ended
302
+ // up with its corpus facts trapped in an inert .tmct/memory/graph.json that
303
+ // a sqlite-backend chat session (createSession, which IS backend-aware)
304
+ // createSession resolves itsrc/memory/core.mjs's openMemoryBackend so
305
+ // the seed lands in whichever backend `config.memory.backend` actually
306
+ // names. "memory" is skipped outright: it's an in-process-only store that
307
+ // vanishes the moment this one-shot init process exits, so seeding it is
308
+ // pure wasted work — a later `tmct chat --memory-backend memory` opens a
309
+ // brand new, unrelated in-memory store anyway.
310
+ const backendChoice = String(config.memory?.backend || "").trim().toLowerCase();
311
+ if (backendChoice === "memory") {
312
+ seedNote = "seed skipped (memory backend is in-process only — nothing would persist past this command)";
313
+ } else {
314
+ const { openMemoryBackend } = await import("./memory/core.mjs");
315
+ const { dir: memoryDir, close: closeMemoryStore } = await openMemoryBackend(root, backendChoice);
316
+ try {
317
+ const { resolveExtensions, seedActiveCorpusEntries } = await import("./extensions.mjs");
318
+ const { entries } = await resolveExtensions(root);
319
+ // `tmct.toml`'s `[seed] limit` knob is documented as capping the tier-1
320
+ // ConceptNet band specifically (the curated SEON ontology is small and
321
+ // always seeds whole) — so it overrides ONLY the resolved "conceptnet"
322
+ // entry's limit, exactly like the pre-fix single-corpus seed did.
323
+ if (config.seed?.limit != null && entries.has("conceptnet")) {
324
+ entries.set("conceptnet", { ...entries.get("conceptnet"), limit: Number(config.seed.limit) });
325
+ }
326
+ const { appended, skipped, total, perBundle } = await seedActiveCorpusEntries(memoryDir, entries);
327
+ // seedActiveCorpusEntries is failure-tolerant PER BUNDLE (a bad third-party
328
+ // pack never aborts the others) — but initRepo's own "FAILURE-TOLERANT
329
+ // SEED" contract is about the SEED AS A WHOLE degrading honestly. If every
330
+ // active bundle failed (e.g. the memory graph file itself is unwritable —
331
+ // see test/init.test.mjs "seed failure degrades"), re-throw the first
332
+ // bundle's error so the SAME outer catch below reports the familiar "seed
333
+ // skipped (corpus unavailable: …)" note, rather than claiming success with
334
+ // zero facts actually written.
335
+ const bundleNames = Object.keys(perBundle);
336
+ const allFailed = bundleNames.length > 0 && bundleNames.every((n) => perBundle[n].error);
337
+ if (allFailed) throw new Error(perBundle[bundleNames[0]].error);
338
+ seedResult = {
339
+ appended, skipped, total, perBundle,
340
+ seon: perBundle.seon?.appended || 0,
341
+ conceptnet: perBundle.conceptnet?.appended || 0,
342
+ };
343
+ const markerNew = !(await exists(paths.marker));
344
+ await mkdir(dirname(paths.marker), { recursive: true });
345
+ await writeFile(
346
+ paths.marker,
347
+ JSON.stringify({
348
+ seededAt: new Date().toISOString(),
349
+ limit: config.seed?.limit != null ? Number(config.seed.limit) : SEED_LIMIT,
350
+ appended: seedResult.appended,
351
+ skipped: seedResult.skipped,
352
+ perBundle,
353
+ }) + "\n",
354
+ );
355
+ if (markerNew) created.push(paths.marker);
356
+ seeded = true;
357
+ } catch (err) {
358
+ // Corpus unavailable/broken → an initialised-but-unseeded repo, not a crash.
359
+ seedNote = `seed skipped (corpus unavailable: ${err && err.message ? err.message : err})`;
360
+ } finally {
361
+ await closeMemoryStore();
278
362
  }
279
- const { appended, skipped, total, perBundle } = await seedActiveCorpusEntries(root, entries);
280
- // seedActiveCorpusEntries is failure-tolerant PER BUNDLE (a bad third-party
281
- // pack never aborts the others) — but initRepo's own "FAILURE-TOLERANT
282
- // SEED" contract is about the SEED AS A WHOLE degrading honestly. If every
283
- // active bundle failed (e.g. the memory graph file itself is unwritable —
284
- // see test/init.test.mjs "seed failure degrades"), re-throw the first
285
- // bundle's error so the SAME outer catch below reports the familiar "seed
286
- // skipped (corpus unavailable: …)" note, rather than claiming success with
287
- // zero facts actually written.
288
- const bundleNames = Object.keys(perBundle);
289
- const allFailed = bundleNames.length > 0 && bundleNames.every((n) => perBundle[n].error);
290
- if (allFailed) throw new Error(perBundle[bundleNames[0]].error);
291
- seedResult = {
292
- appended, skipped, total, perBundle,
293
- seon: perBundle.seon?.appended || 0,
294
- conceptnet: perBundle.conceptnet?.appended || 0,
295
- };
296
- const markerNew = !(await exists(paths.marker));
297
- await mkdir(dirname(paths.marker), { recursive: true });
298
- await writeFile(
299
- paths.marker,
300
- JSON.stringify({
301
- seededAt: new Date().toISOString(),
302
- limit: config.seed?.limit != null ? Number(config.seed.limit) : SEED_LIMIT,
303
- appended: seedResult.appended,
304
- skipped: seedResult.skipped,
305
- perBundle,
306
- }) + "\n",
307
- );
308
- if (markerNew) created.push(paths.marker);
309
- seeded = true;
310
- } catch (err) {
311
- // Corpus unavailable/broken → an initialised-but-unseeded repo, not a crash.
312
- seedNote = `seed skipped (corpus unavailable: ${err && err.message ? err.message : err})`;
313
363
  }
314
364
  }
315
365
 
@@ -356,6 +406,9 @@ async function readWrittenConfig(tomlPath, base) {
356
406
  // normalizeConfig).
357
407
  if (raw.extensions !== undefined) cfg.extensions = raw.extensions;
358
408
  if (raw.bias !== undefined) cfg.bias = raw.bias;
409
+ if (raw.memory && raw.memory.backend !== undefined) {
410
+ cfg.memory = { ...cfg.memory, backend: raw.memory.backend };
411
+ }
359
412
  return cfg;
360
413
  } catch {
361
414
  return base;
@@ -119,9 +119,7 @@ const KIND_NOUN_ANAPHORA_RE = /\b(this|that)\s+(class|module|function|method|att
119
119
  * "file"->"Module" convention every other lane in this grammar already
120
120
  * uses)? Returns that class, or null when no such anaphora is present.
121
121
  * Deliberately SEPARATE from normalizeQuery's own KIND_NOUN_ANAPHORA_RE
122
- * replace just above (which permanently collapses "this file" to bare
123
- * "this", discarding the kind-noun signal for good, by design — see that
124
- * replace's own docblock): this never mutates its input and has no effect
122
+ * replace just above (: this never mutates its input and has no effect
125
123
  * on normalizeQuery's behavior, signature, or any of its many call sites.
126
124
  * A caller that needs BOTH the collapsed pronoun AND the kind it stood for
127
125
  * (chat.mjs's runAsk, at its pronoun-reuse site) calls this side-channel on
@@ -108,7 +108,7 @@ const MEMORY_VOCABULARY = [
108
108
  { prop: DERIVED_FROM_PROP, predicate: "derivedFrom", note: "umbrella: a Fact derived from a Source (or another Fact). ext ref prov:wasDerivedFrom (UNVERIFIED-pending-web-check)" },
109
109
  { prop: STATED_BY_PROP, predicate: "statedBy", note: "subPropertyOf derivedFrom: a Source directly asserts this Fact (one edge per independent source — replaces the factProvenance union)" },
110
110
  { prop: CANONICALISED_FROM_PROP, predicate: "canonicalisedFrom", note: "subPropertyOf derivedFrom: a canonical Fact cleaned from a raw Block/Source, never replacing it" },
111
- { prop: "mgx:sourceType", note: "a Source's kind: operator | teach | provider | corpus | web | entailed (the trust-prior key)" },
111
+ { prop: "mgx:sourceType", note: "a Source's kind: operator | teach | provider | corpus | corpusWeak | extracted | web | entailed (the trust-prior key)" },
112
112
  { prop: "mgx:sourceUrl", note: "a web Source's URL" },
113
113
  { prop: "mgx:sourceRule", note: "an entailed Source's rule id" },
114
114
  { prop: "mgx:sourceReliability", note: "actor-level (session-scoped) trust nudge in [0.5,1.5], neutral 1.0 when absent — materialised by recomputeSourceReliability from a session's asserted-vs-contradicted track record (memory/trust.mjs's sessionReliabilityFrom); folds into computeTrust's per-source prior" },
@@ -312,6 +312,43 @@ export function closeSqliteMemoryStore(handle) {
312
312
  if (isSqliteHandle(handle)) handle.db.close();
313
313
  }
314
314
 
315
+ /**
316
+ * Resolve an already-lowercased/trimmed backend token ("memory" | "sqlite" |
317
+ * anything else, including "" or "default") into `{ dir, close }`: the exact
318
+ * `dir` value every dir-taking export in this module (loadMemory, appendFact,
319
+ * appendFacts, seedMemory via src/corpus/conceptnet.mjs, …) already accepts —
320
+ * a plain repo-path string for Backend A, or a live Backend B/C handle — plus
321
+ * an idempotent `close()` cleanup (a no-op for A/B; closes the sqlite
322
+ * connection for C).
323
+ *
324
+ * The ONE shared resolver for the storage-backend seam (PLAN_SEED.md §6):
325
+ * originally inlined only in chat.mjs's createSession, factored out here so
326
+ * `tmct init`'s corpus seed (src/init.mjs) and bin/tmct.mjs's
327
+ * `--corpus`/`--ontology`/`--lexicon` activation seed the SAME backend a
328
+ * later `tmct chat` in that repo will read — a caller resolving "sqlite"
329
+ * independently and picking a different db path (or a different in-memory
330
+ * store) would silently split a repo's memory across two places that can
331
+ * never see each other. `backendChoice` is a precedence result the CALLER
332
+ * already computed (CLI flag > env > tmct.toml > default, or whatever subset
333
+ * applies); this function does no precedence resolution of its own — it only
334
+ * maps the resolved token to a handle. `repoRoot` is used only for Backend C's
335
+ * db path (`<repoRoot>/.tmct/memory/graph.sqlite`); Backend A returns
336
+ * `repoRoot` itself unchanged, byte-identical to every existing plain-string
337
+ * caller.
338
+ */
339
+ export async function openMemoryBackend(repoRoot, backendChoice) {
340
+ if (backendChoice === BACKEND_MEMORY) {
341
+ return { dir: createInMemoryStore(), close: async () => {} };
342
+ }
343
+ if (backendChoice === BACKEND_SQLITE) {
344
+ const dbPath = join(repoRoot, ".tmct", "memory", "graph.sqlite");
345
+ await mkdir(dirname(dbPath), { recursive: true });
346
+ const handle = await createSqliteMemoryStore(dbPath);
347
+ return { dir: handle, close: async () => closeSqliteMemoryStore(handle) };
348
+ }
349
+ return { dir: repoRoot, close: async () => {} };
350
+ }
351
+
315
352
  /** Deep-clone a JSON-safe value. Used two ways here: (1) readSqlitePayload
316
353
  * hands every CALLER a clone of the cache, never the live cached object
317
354
  * itself, so this backend keeps the same "fresh object every call" contract
@@ -725,8 +762,56 @@ async function persistMemory(dir, payload) {
725
762
  * reached). `await fn(payload)` is a documented no-op for every existing
726
763
  * SYNC caller (appendUtterance(s), appendFacts) — awaiting a non-Promise
727
764
  * value just resolves to it, byte-identical behaviour to calling it plain. */
765
+ // ---- mutateMemory-scoped lookup index (PLAN_GRAPH_SCAN.md Phase 1) ----------
766
+ // syncFactSources's per-fact bookkeeping (upsertSource, upsertIndividual,
767
+ // upsertEdge's statedBy path, statedByObjectsFor, sourcesByIdMap) used to each
768
+ // re-scan payload.individuals or the statedBy edge list from scratch, turning
769
+ // one appendFacts batch of n facts into O(n^2) work. mutateMemory now builds
770
+ // three lookup Maps once per call (one O(n) pass) and attaches them to payload
771
+ // under a Symbol key — JSON.stringify skips Symbol-keyed properties
772
+ // automatically, so persistMemory's graph.json write is byte-identical to
773
+ // before. Every helper below checks for the Symbol slot: present → O(1) Map
774
+ // lookup; absent (a bare payload object built outside mutateMemory, e.g. a
775
+ // test fixture) → today's exact linear-scan fallback, so nothing outside
776
+ // mutateMemory's own call chain can observe a behaviour change. The index is
777
+ // discarded when mutateMemory returns — it never survives across calls, so
778
+ // there is no invalidation logic to get wrong.
779
+ const MEMORY_INDEX = Symbol("mutateMemory lookup index");
780
+
781
+ /** Build the three lookup Maps from the just-loaded payload and attach them
782
+ * under MEMORY_INDEX. Any code that pushes a new individual into
783
+ * payload.individuals, or a new statedBy edge, must also write the matching
784
+ * index entry in that same statement (see upsertIndividual/upsertSource/
785
+ * upsertEdge/appendFacts below) — the same discipline appendFacts's own
786
+ * local `byId` Map already used for the Fact upsert, generalised here. */
787
+ function buildMemoryIndex(payload) {
788
+ const individualsById = new Map();
789
+ const sourcesById = new Map();
790
+ const statedByBySubject = new Map();
791
+ for (const ind of payload.individuals || []) {
792
+ if (!ind?.id) continue;
793
+ individualsById.set(ind.id, ind);
794
+ if (ind.class === SOURCE_CLASS) sourcesById.set(ind.id, ind);
795
+ }
796
+ const statedGroup = (payload.objectProperties || []).find((g) => g?.prop === STATED_BY_PROP);
797
+ for (const e of statedGroup?.examples || []) {
798
+ if (!e?.subject) continue;
799
+ const list = statedByBySubject.get(e.subject);
800
+ if (list) list.push(e.object);
801
+ else statedByBySubject.set(e.subject, [e.object]);
802
+ }
803
+ payload[MEMORY_INDEX] = { individualsById, sourcesById, statedByBySubject };
804
+ return payload[MEMORY_INDEX];
805
+ }
806
+
807
+ /** The active lookup index for this payload, or null when this payload wasn't
808
+ * built by mutateMemory (a bare test fixture) — callers fall back to a
809
+ * linear scan in that case. */
810
+ const memoryIndexOf = (payload) => payload?.[MEMORY_INDEX] || null;
811
+
728
812
  async function mutateMemory(dir, fn) {
729
813
  const payload = await loadMemory(dir);
814
+ buildMemoryIndex(payload);
730
815
  const out = (await fn(payload)) ?? payload;
731
816
  migrateLegacyProvenance(out);
732
817
  recomputeSourceReliability(out);
@@ -771,6 +856,11 @@ function sourceIdFor(desc) {
771
856
  case "teach": return { id: desc.sessionId ? `${TEACH_SOURCE_ID}:${desc.sessionId}` : TEACH_SOURCE_ID, type: "teach" };
772
857
  case "provider": return { id: `src:provider:${desc.name}`, type: "provider" };
773
858
  case "corpus": return { id: `src:corpus:${desc.name}`, type: "corpus" };
859
+ // One Source per source-file basename (the corpus precedent above), not per
860
+ // extraction run — re-running scripts/extract-facts-from-text.mjs over the
861
+ // SAME file collapses onto the same Source instead of minting a new one
862
+ // every time, matching corpus's "one Source per named dataset" idiom.
863
+ case "extracted": return { id: `src:extracted:${desc.name}`, type: "extracted" };
774
864
  case "web": return { id: `src:learned:web:${fnv1aHex(String(desc.url || ""))}`, type: "web", url: String(desc.url || "") };
775
865
  case "entailed": return { id: `src:entailed:${desc.rule}`, type: "entailed", rule: String(desc.rule || "") };
776
866
  default: return null;
@@ -785,9 +875,10 @@ const sourceLabel = (id) => String(id).replace(/^src:/, "");
785
875
  function upsertSource(payload, desc, createdAtCandidate) {
786
876
  const info = sourceIdFor(desc);
787
877
  if (!info) return null;
788
- const prior = payload.individuals.find((i) => i?.id === info.id);
878
+ const idx = memoryIndexOf(payload);
879
+ const prior = idx ? idx.individualsById.get(info.id) : payload.individuals.find((i) => i?.id === info.id);
789
880
  const created = firstWriteCreatedAt(prior, desc?.createdAt || createdAtCandidate);
790
- upsertIndividual(payload, {
881
+ const ind = {
791
882
  id: info.id, label: sourceLabel(info.id), class: SOURCE_CLASS,
792
883
  derived_from: [], mentions: [],
793
884
  attributes: [
@@ -797,7 +888,9 @@ function upsertSource(payload, desc, createdAtCandidate) {
797
888
  ...(info.url ? [{ prop: "mgx:sourceUrl", key: "sourceUrl", value: info.url }] : []),
798
889
  ...(info.rule ? [{ prop: "mgx:sourceRule", key: "sourceRule", value: info.rule }] : []),
799
890
  ],
800
- });
891
+ };
892
+ const stored = upsertIndividual(payload, ind);
893
+ if (idx) idx.sourcesById.set(info.id, stored);
801
894
  return info.id;
802
895
  }
803
896
 
@@ -821,19 +914,32 @@ function parseChatTagRest(rest) {
821
914
  * set — the inverse the migration and the live write path both name Sources
822
915
  * through. The tag formats are exactly what the writers produce:
823
916
  * corpus:conceptnet /r/IsA → { kind:"corpus", name:"conceptnet" }
917
+ * corpus-weak:conceptnet /r/RelatedTo → { kind:"corpusWeak", name:"conceptnet" }
824
918
  * ace:chat:<session>@<ts> → { kind:"operator", createdAt:<ts>, sessionId:<session> }
825
919
  * teach:chat:<session>@<ts> → { kind:"teach", createdAt:<ts>, sessionId:<session> }
826
920
  * web:<url> | url:<url> → { kind:"web", url:<url> }
921
+ * extracted:<file-basename> → { kind:"extracted", name:<file-basename> }
827
922
  * entailed:<rule> → { kind:"entailed", rule:<rule> }
828
923
  * chat:/session: refs map to the operator; an unknown tag → null (no Source).
924
+ * `corpus-weak:` is the SAME corpus provenance shape as `corpus:`, just naming
925
+ * a lower trust-prior kind (memory/trust.mjs SOURCE_PRIOR.corpusWeak) for
926
+ * facts whose underlying relation is real but low-precision (e.g. ConceptNet's
927
+ * undirected /r/RelatedTo) — trust stays computed from the Source's kind, never
928
+ * hand-set on the Fact.
829
929
  * The session-id segment (Part B: session-scoped actor-level trust) feeds
830
930
  * sourceIdFor, which mints a PER-SESSION Source id when present, instead of
831
931
  * collapsing every session onto one singleton operator/teach Source.
932
+ * `extracted:` is scripts/extract-facts-from-text.mjs's own audit tag, layered
933
+ * ADDITIVELY (via appendFact's provenance union) on top of whatever the
934
+ * runTurn recognizer already wrote (ace:/teach:) — see that script for why:
935
+ * it distinguishes "this document evidenced this fact" from ordinary chat
936
+ * speech, at its own trust-prior tier (memory/trust.mjs SOURCE_PRIOR.extracted).
832
937
  */
833
938
  export function provenanceTagToSource(tag) {
834
939
  const t = String(tag || "").trim();
835
940
  if (!t) return null;
836
941
  const head = t.split(/\s+/)[0]; // drop trailing " /r/IsA" etc.
942
+ if (head.startsWith("corpus-weak:")) return { kind: "corpusWeak", name: head.slice("corpus-weak:".length) || "unknown" };
837
943
  if (head.startsWith("corpus:")) return { kind: "corpus", name: head.slice("corpus:".length) || "unknown" };
838
944
  if (head.startsWith("ace:")) return { kind: "operator", ...parseChatTagRest(head.slice("ace:".length)) };
839
945
  if (head.startsWith("teach:")) {
@@ -842,6 +948,7 @@ export function provenanceTagToSource(tag) {
842
948
  }
843
949
  if (head.startsWith("web:")) return { kind: "web", url: head.slice("web:".length) };
844
950
  if (head.startsWith("url:")) return { kind: "web", url: head.slice("url:".length) };
951
+ if (head.startsWith("extracted:")) return { kind: "extracted", name: head.slice("extracted:".length) || "unknown" };
845
952
  if (head.startsWith("entailed:")) return { kind: "entailed", rule: head.slice("entailed:".length) };
846
953
  if (head.startsWith("chat:") || head.startsWith("session:") || head.startsWith("operator")) return { kind: "operator" };
847
954
  return null;
@@ -850,13 +957,23 @@ export function provenanceTagToSource(tag) {
850
957
  /** Map a payload's Source individuals into the { id: Source } shape computeTrust
851
958
  * resolves against. */
852
959
  function sourcesByIdMap(payload) {
960
+ const idx = memoryIndexOf(payload);
853
961
  const m = {};
962
+ if (idx) {
963
+ // idx.sourcesById is kept incrementally correct by upsertSource, so this
964
+ // is O(distinct Sources) — a handful, roughly one per corpus/provider —
965
+ // never O(all individuals), unlike the fallback rebuild below.
966
+ for (const [id, ind] of idx.sourcesById) m[id] = ind;
967
+ return m;
968
+ }
854
969
  for (const i of payload.individuals) if (i?.class === SOURCE_CLASS) m[i.id] = i;
855
970
  return m;
856
971
  }
857
972
 
858
973
  /** The Source ids a Fact is statedBy, read off the edge group. */
859
974
  function statedByObjectsFor(payload, factId) {
975
+ const idx = memoryIndexOf(payload);
976
+ if (idx) return (idx.statedByBySubject.get(factId) || []).slice();
860
977
  const g = payload.objectProperties.find((x) => x?.prop === STATED_BY_PROP);
861
978
  return (g?.examples || []).filter((e) => e?.subject === factId).map((e) => e.object);
862
979
  }
@@ -983,8 +1100,9 @@ function recomputeSourceReliability(payload) {
983
1100
  }
984
1101
  if (!bySource.size) return;
985
1102
 
1103
+ const idx = memoryIndexOf(payload);
986
1104
  for (const [sid, counts] of bySource) {
987
- const source = payload.individuals.find((i) => i?.id === sid);
1105
+ const source = idx ? idx.individualsById.get(sid) : payload.individuals.find((i) => i?.id === sid);
988
1106
  if (!source) continue;
989
1107
  setAttr(source, SOURCE_RELIABILITY_PROP, "sourceReliability", String(sessionReliabilityFrom(counts)));
990
1108
  // Own-attribute mutation in place (PLAN_VIZ.md §2) — same reasoning as recomputeFactTrust.
@@ -997,16 +1115,38 @@ function recomputeSourceReliability(payload) {
997
1115
  const affected = new Set();
998
1116
  for (const e of statedGroup?.examples || []) if (bySource.has(e?.object)) affected.add(e.subject);
999
1117
  for (const id of affected) {
1000
- const ind = payload.individuals.find((i) => i?.id === id);
1118
+ const ind = idx ? idx.individualsById.get(id) : payload.individuals.find((i) => i?.id === id);
1001
1119
  if (ind) recomputeFactTrust(payload, ind);
1002
1120
  }
1003
1121
  }
1004
1122
 
1005
- /** Upsert an individual by id (replace-in-place keeps ordering stable). */
1123
+ /** Upsert an individual by id (replace-in-place keeps ordering stable).
1124
+ * Returns the individual object actually stored in payload.individuals — the
1125
+ * caller (e.g. upsertSource) should index THAT reference, not `ind` itself,
1126
+ * since the indexed path below merges into the prior object in place rather
1127
+ * than replacing the array slot. When a lookup index is present (built by
1128
+ * mutateMemory), an existing individual is updated via Object.assign — same
1129
+ * array position AND same object identity as before, so it stays trivially
1130
+ * in sync with individualsById without a second Map write, and a brand-new
1131
+ * individual is pushed + indexed in the same statement. Absent an index
1132
+ * (a bare payload built outside mutateMemory), this is EXACTLY the original
1133
+ * findIndex + replace-or-push code. */
1006
1134
  function upsertIndividual(payload, ind) {
1135
+ const idx = memoryIndexOf(payload);
1136
+ if (idx) {
1137
+ const prior = idx.individualsById.get(ind.id);
1138
+ if (prior) {
1139
+ Object.assign(prior, ind);
1140
+ return prior;
1141
+ }
1142
+ payload.individuals.push(ind);
1143
+ idx.individualsById.set(ind.id, ind);
1144
+ return ind;
1145
+ }
1007
1146
  const i = payload.individuals.findIndex((x) => x?.id === ind.id);
1008
- if (i >= 0) payload.individuals[i] = ind;
1009
- else payload.individuals.push(ind);
1147
+ if (i >= 0) { payload.individuals[i] = ind; return ind; }
1148
+ payload.individuals.push(ind);
1149
+ return ind;
1010
1150
  }
1011
1151
 
1012
1152
  /** Upsert one edge into the named relation group (dedupe by subject>object). Stamps `createdAt`
@@ -1020,6 +1160,27 @@ function upsertEdge(payload, { predicate, prop }, edge) {
1020
1160
  group = { predicate, prop, count: 0, examples: [] };
1021
1161
  payload.objectProperties.push(group);
1022
1162
  }
1163
+ // statedBy-only fast path (PLAN_GRAPH_SCAN.md Phase 1): statedByBySubject
1164
+ // tracks, per fact, the small list of Source ids already stated it (almost
1165
+ // always 0-1 during a seed), so the overwhelmingly common case — a brand
1166
+ // new (subject,object) statedBy pair — can append directly without the
1167
+ // find+filter scan of the WHOLE statedBy edge list below. Every other
1168
+ // predicate (saidInSession, inReplyTo, ...) is untouched and always takes
1169
+ // the original path.
1170
+ const idx = prop === STATED_BY_PROP ? memoryIndexOf(payload) : null;
1171
+ if (idx) {
1172
+ const existing = idx.statedByBySubject.get(edge.subject);
1173
+ if (!existing || !existing.includes(edge.object)) {
1174
+ group.examples.push({ ...edge, createdAt: edge.createdAt || nowIso() });
1175
+ group.count = group.examples.length;
1176
+ if (existing) existing.push(edge.object);
1177
+ else idx.statedByBySubject.set(edge.subject, [edge.object]);
1178
+ return;
1179
+ }
1180
+ // Rare re-assert of the exact same (subject,object) pair — fall through
1181
+ // to the exact original find+filter dance so first-write-wins createdAt
1182
+ // is preserved; the index is kept accurate below too.
1183
+ }
1023
1184
  // Edges are flat ({subject, object, ...}), not attribute-bearing individuals, so this can't
1024
1185
  // reuse firstWriteCreatedAt (which reads `.attributes`) directly — same discipline, edge shape:
1025
1186
  // the prior edge's OWN createdAt wins if it has one, else the incoming candidate, else now.
@@ -1030,6 +1191,11 @@ function upsertEdge(payload, { predicate, prop }, edge) {
1030
1191
  );
1031
1192
  group.examples.push({ ...edge, createdAt });
1032
1193
  group.count = group.examples.length;
1194
+ if (idx) {
1195
+ const list = idx.statedByBySubject.get(edge.subject) || [];
1196
+ if (!list.includes(edge.object)) list.push(edge.object);
1197
+ idx.statedByBySubject.set(edge.subject, list);
1198
+ }
1033
1199
  }
1034
1200
 
1035
1201
  /** Recount `classes[]` from the individuals — every memory class stays counted
@@ -1285,7 +1451,13 @@ export async function appendFacts(dir, facts) {
1285
1451
  if (!prepared.length) return { ids, appended: 0, skipped };
1286
1452
  await mutateMemory(dir, (payload) => {
1287
1453
  // id → individual index for O(1) upsert (the array grows to thousands).
1288
- const byId = new Map(payload.individuals.map((i) => [i?.id, i]));
1454
+ // When mutateMemory already built the Symbol-keyed lookup index, reuse
1455
+ // THAT Map directly (same object) instead of rescanning payload.individuals
1456
+ // a second time — every `byId.set` below then also keeps
1457
+ // idx.individualsById correct for upsertSource/recomputeSourceReliability's
1458
+ // later lookups in this same mutation, with no extra write.
1459
+ const idx = memoryIndexOf(payload);
1460
+ const byId = idx ? idx.individualsById : new Map(payload.individuals.map((i) => [i?.id, i]));
1289
1461
  const touched = [];
1290
1462
  const seen = new Set();
1291
1463
  const trustOptsById = new Map();
@@ -1314,10 +1486,15 @@ export async function appendFacts(dir, facts) {
1314
1486
  ...(f.justification && f.justification.length ? [{ prop: "mgx:factJustification", key: "justification", value: f.justification.join(" ") }] : []),
1315
1487
  ],
1316
1488
  };
1317
- // Upsert into BOTH the array (replace-in-place keeps order) and the index.
1318
- if (prior) payload.individuals[payload.individuals.indexOf(prior)] = ind;
1319
- else payload.individuals.push(ind);
1320
- byId.set(f.id, ind);
1489
+ // Upsert via the shared helper O(1) via the index (Object.assign in
1490
+ // place when `prior` exists, push+index when it's new), same as every
1491
+ // other upsert path now. Previously this did its own inline
1492
+ // `payload.individuals.indexOf(prior)` array scan on a re-assert within
1493
+ // the same batch — an O(n) fallback that could still blow up a batch
1494
+ // heavy with within-file duplicate triples; upsertIndividual has no
1495
+ // such case left.
1496
+ const stored = upsertIndividual(payload, ind);
1497
+ byId.set(f.id, stored);
1321
1498
  ids.push(f.id);
1322
1499
  if (!seen.has(f.id)) { seen.add(f.id); touched.push(f.id); }
1323
1500
  // Last-prepared-row-wins per id for the trust hook opts (mirrors the
@@ -4,7 +4,8 @@
4
4
  // Trust is a COMPUTED attribute of a Fact — never hand-set — a pure function of
5
5
  // its Source edges, those Sources' types, and its mgx:createdAt. Three inputs
6
6
  // combine:
7
- // - a Source-TYPE PRIOR (operator > teach > provider > corpus > web > entailed);
7
+ // - a Source-TYPE PRIOR (operator > teach > provider > corpus > corpusWeak >
8
+ // extracted > web > entailed);
8
9
  // - CORROBORATION over the fact's distinct Sources by noisy-OR
9
10
  // (1 − Π(1 − wᵢ), capped at 1) — two independent web sources (0.4) reach
10
11
  // 0.64, a lone operator fact is already 1.0;
@@ -35,17 +36,37 @@ export const TRUST_SCORE_PROP = "mgx:trustScore";
35
36
  export const TRUST_INPUTS_PROP = "mgx:trustInputs";
36
37
 
37
38
  /** Source-type priors — the ordering operator > teach > provider-graph >
38
- * curated-corpus > web > unverified-entailment. `teach` is the chat teach
39
- * lane's natural-frame writes ("remember that …", "<Name> owns <X>") — still
40
- * operator speech, but through a looser recognizer than the ACE-parsed
41
- * operator assert, so it sits just below the operator prior. The entailed
42
- * value is a FLOOR before premise adjustment (see the entailed hook below). */
39
+ * curated-corpus > weak-corpus > extracted-document > web > unverified-
40
+ * entailment. `teach` is the chat teach lane's natural-frame writes
41
+ * ("remember that …", "<Name> owns <X>") still operator speech, but
42
+ * through a looser recognizer than the ACE-parsed operator assert, so it
43
+ * sits just below the operator prior. The entailed value is a FLOOR before
44
+ * premise adjustment (see the entailed hook below).
45
+ *
46
+ * `corpusWeak` is for corpus-sourced facts whose underlying relation is real
47
+ * but low-precision (ConceptNet's /r/RelatedTo — ambiguous, undirected
48
+ * association, unlike the specific typed relations the plain `corpus` prior
49
+ * covers) — still a curated, committed dataset (above `web`), just not
50
+ * asserting the same strength of claim (below `corpus`).
51
+ *
52
+ * `extracted` is scripts/extract-facts-from-text.mjs's batch reader: it runs
53
+ * the SAME deterministic teach/assert recognizer as `teach`/`operator`, but
54
+ * unattended over an arbitrary document nobody in-session vetted sentence by
55
+ * sentence — the recognizer is exact/closed-set (no guessing), but the
56
+ * SOURCE DOCUMENT is unreviewed, so it sits just above `web` (also an
57
+ * unreviewed external source) and below `corpusWeak`/`corpus` (curated,
58
+ * committed datasets) and `teach` (a human typing into the live chat).
59
+ *
60
+ * Both are computed from the Source's type exactly like every other tier —
61
+ * never hand-set on a Fact directly. */
43
62
  export const SOURCE_PRIOR = Object.freeze({
44
63
  operator: 1.0,
45
64
  teach: 0.95,
46
65
  provider: 0.9,
47
66
  corpus: 0.7,
67
+ corpusWeak: 0.55,
48
68
  web: 0.4,
69
+ extracted: 0.45,
49
70
  entailed: 0.3,
50
71
  });
51
72
 
@@ -0,0 +1,36 @@
1
+ // memory-ask-browser-entry.mjs — the esbuild entry for `tmct viz`'s embedded
2
+ // "Ask the graph" panel's MEMORY-graph engine (PLAN_VIZ_MEMORY.md Bug 1 fix).
3
+ //
4
+ // Bug 1: the panel bundled ONLY ask.mjs — tmct's code-graph query engine
5
+ // ("which modules import X"), which has no concept of Facts/corpus data at
6
+ // all, so a memory-graph question like "what is a dog" always missed even
7
+ // though the page's own embedded payload had the answer. This is the SECOND,
8
+ // narrow browser entry point src/ask-browser-entry.mjs's own doc comment
9
+ // anticipates: re-exports just `factAnswer` (src/chat.mjs) — tmct's REAL
10
+ // memory-graph answer engine, the same one `npm run chat` uses — plus
11
+ // `createInMemoryStore` (src/memory/core.mjs), which is how the panel hands
12
+ // `factAnswer` the page's already-embedded PAYLOAD with ZERO fs I/O: a
13
+ // Backend-B handle's `loadMemory` branch returns `handle.payload` directly, no
14
+ // bundle-time module shimming needed (see factAnswer's own doc comment,
15
+ // src/chat.mjs, for the full reasoning — a simpler, more robust mechanism than
16
+ // intercepting loadMemory at bundle time, since it reuses machinery the
17
+ // codebase already ships and tests, rather than a new esbuild-only code path
18
+ // that could drift from the real one).
19
+ //
20
+ // `factAnswer` is called with `envelope: null, miss: true` — the exact,
21
+ // already-documented "no envelope available" bootstrap path (chat.mjs's own
22
+ // comments: "the FIRST turn of a graph-less session... leaves `envelope` null
23
+ // for the rest of THIS turn's processing" — a real, tested code path, not a
24
+ // hack) — which arms factAnswer's own bare-question regex fallbacks
25
+ // (BARE_WHATIS_RE and friends) to parse the query directly, with no
26
+ // dependency on the much larger structural-graph parse pipeline
27
+ // (dispatchTool/loadGraph, server.mjs) that pipeline needs a real --repo code
28
+ // index for and this panel has no use for.
29
+ import { factAnswer } from "./chat.mjs";
30
+ import { createInMemoryStore, normFactTerm } from "./memory/core.mjs";
31
+
32
+ // normFactTerm is re-exported too — viz.mjs's client-side "focus follows the
33
+ // memory-engine's answer" heuristic (guessTermIdFromQuery) needs the SAME
34
+ // term normalization the CLI's own `--term` seed flag and factAnswer's own
35
+ // subject/object matching use, never a second hand-rolled copy.
36
+ globalThis.tmctMemoryAsk = { factAnswer, createInMemoryStore, normFactTerm };