@polycode-projects/the-mechanical-code-talker 6.0.12 → 6.0.13

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@polycode-projects/the-mechanical-code-talker",
3
- "version": "6.0.12",
3
+ "version": "6.0.13",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "The Mechanical Code Talker (tmct) — a tolerant, offline, $0 chat surface that guides you toward precision queries about a software repository. ELIZA/PARRY-style but domain-obsessed with code. No model calls; indexes a repo on request (tmct index) or reads any producer's graph.",
@@ -766,12 +766,22 @@ const ROW_NODE_ID_KEY = "nodeId";
766
766
  * `wrapRowBackendOverSqliteSeed`, the entry point that takes one.
767
767
  * `sqliteSeedOverlayRows` are rows that layer over that seed and under the
768
768
  * session's own (a turn's retrieved corpus subgraph), read-only exactly as
769
- * the seed is. */
769
+ * the seed is.
770
+ *
771
+ * `copyOnRead: false` hands every reader the assembled payload itself instead
772
+ * of a copy of it. Copying a seed-sized payload is the most expensive thing
773
+ * this backend does — over a second per read at 60k facts — and a caller that
774
+ * drives the whole session through this one handle (a worker cycle, a request
775
+ * handler) has nobody to protect the payload from. Anyone sharing a handle
776
+ * across independent readers leaves the default alone: without the copy, a
777
+ * reader that mutates what it read has changed the store's own view of
778
+ * itself. */
770
779
  export function wrapRowBackend(impl, {
771
780
  basePayload = null,
772
781
  sqliteSeedStore = null,
773
782
  sqliteSeedOverlayRows = null,
774
783
  onOversizedRow = "throw",
784
+ copyOnRead = true,
775
785
  log = undefined,
776
786
  } = {}) {
777
787
  const problems = rowBackendProblems(impl);
@@ -795,6 +805,7 @@ export function wrapRowBackend(impl, {
795
805
  baseRows: null,
796
806
  storedRows: null,
797
807
  onOversizedRow,
808
+ copyOnRead,
798
809
  log,
799
810
  };
800
811
  }
@@ -805,8 +816,8 @@ export function wrapRowBackend(impl, {
805
816
  * never materialized as a row array: the assembled payload is the only copy
806
817
  * of it this process holds. `overlayRows` layer over the seed and under the
807
818
  * session's own rows. */
808
- export function wrapRowBackendOverSqliteSeed(impl, sqliteSeedStore, { overlayRows = null, onOversizedRow = "throw", log = undefined } = {}) {
809
- return wrapRowBackend(impl, { sqliteSeedStore, sqliteSeedOverlayRows: overlayRows, onOversizedRow, log });
819
+ export function wrapRowBackendOverSqliteSeed(impl, sqliteSeedStore, { overlayRows = null, onOversizedRow = "throw", copyOnRead = true, log = undefined } = {}) {
820
+ return wrapRowBackend(impl, { sqliteSeedStore, sqliteSeedOverlayRows: overlayRows, onOversizedRow, copyOnRead, log });
810
821
  }
811
822
 
812
823
  /** Drain whatever `readRows()` returned: the contract allows an array or an
@@ -863,7 +874,7 @@ async function ensureRowPayload(handle) {
863
874
  prefixes: await readRowMeta(handle, ROW_META_PREFIXES_KEY, seedScalar(handle, "prefixes", empty.prefixes)),
864
875
  };
865
876
  if (handle.sqliteSeedStore) {
866
- handle.cachedPayload = assembleSqliteSeededPayload(handle, meta);
877
+ handle.cachedPayload = migrateStoredMemory(assembleSqliteSeededPayload(handle, meta));
867
878
  } else {
868
879
  // "keep": the base overlay's own rows never reach the wire (persistRowPayload
869
880
  // excludes every seed key from every write via seedOnlyKeys below), so the
@@ -872,16 +883,49 @@ async function ensureRowPayload(handle) {
872
883
  // band's own high-fan-out property (one edge per fact is normal, not a
873
884
  // pathology) and break every read that depends on it.
874
885
  handle.baseRows = payloadToRows(handle.basePayload || empty, { onOversizedRow: "keep" });
875
- handle.cachedPayload = rowsToPayload(overlayRows(handle.baseRows, handle.storedRows), { meta });
886
+ handle.cachedPayload = migrateStoredMemory(rowsToPayload(overlayRows(handle.baseRows, handle.storedRows), { meta }));
876
887
  }
877
888
  }
878
889
  return handle.cachedPayload;
879
890
  }
880
891
 
881
892
  /** loadMemory's read for Backend D: one `readRows()` per cold open, then a
882
- * clone of the assembled payload per call. */
893
+ * clone of the assembled payload per call — unless the handle was opened
894
+ * `copyOnRead: false`, where the reader gets the assembled payload itself. */
883
895
  async function readRowPayload(handle) {
884
- return cloneJson(await ensureRowPayload(handle));
896
+ const payload = await ensureRowPayload(handle);
897
+ return handle.copyOnRead === false ? payload : cloneJson(payload);
898
+ }
899
+
900
+ /** A payload a mutation can work on without any of it reaching the one it was
901
+ * copied from. Every container a write reaches into is its own: the individuals
902
+ * array and each individual, the edge groups and each group's example list.
903
+ * What stays shared is what a write never changes in place — an individual's
904
+ * `attributes` array is REPLACED by `setAttr`, never pushed onto, and an edge
905
+ * is replaced rather than edited, so both sides keep reading their own.
906
+ *
907
+ * This is `structuredClone`'s job done at the granularity writes actually use.
908
+ * At seed scale the deep copy runs well over a second and a cycle pays it on
909
+ * every fact it grounds; this is a few milliseconds of pointer copying. */
910
+ function mutablePayloadCopy(payload) {
911
+ return {
912
+ ...payload,
913
+ individuals: (payload.individuals || []).map((ind) => ({ ...ind })),
914
+ objectProperties: (payload.objectProperties || []).map((group) => ({
915
+ ...group, examples: [...(group.examples || [])],
916
+ })),
917
+ };
918
+ }
919
+
920
+ /** Forget a row handle's assembled payload, so the next read rebuilds it from
921
+ * the store. The one thing that must happen after a mutation dies part-way
922
+ * through: the payload it was changing is neither what the store holds nor a
923
+ * coherent graph. */
924
+ function dropAssembledRowPayload(handle) {
925
+ if (!isRowHandle(handle)) return;
926
+ handle.cachedPayload = null;
927
+ handle.storedRows = null;
928
+ handle.baseRows = null;
885
929
  }
886
930
 
887
931
  /** A record with its audit stamp removed. `mgx:updatedAt` moves on every
@@ -935,9 +979,7 @@ async function persistRowPayload(handle, payload) {
935
979
  await handle.impl.putMeta(ROW_META_MEMORY_KEY, JSON.stringify(payload.memory ?? emptyMemory().memory));
936
980
  await handle.impl.putMeta(ROW_META_PREFIXES_KEY, JSON.stringify(payload.prefixes ?? emptyMemory().prefixes));
937
981
  } catch (e) {
938
- handle.cachedPayload = null;
939
- handle.storedRows = null;
940
- handle.baseRows = null;
982
+ dropAssembledRowPayload(handle);
941
983
  throw e;
942
984
  }
943
985
  const removed = new Set(removals);
@@ -949,13 +991,13 @@ async function persistRowPayload(handle, payload) {
949
991
  patchAssembledPayload(handle.cachedPayload, meta, writes);
950
992
  } else if (handle.sqliteSeedStore) {
951
993
  handle.cachedPayload = null;
952
- handle.cachedPayload = assembleSqliteSeededPayload(handle, meta);
994
+ handle.cachedPayload = migrateStoredMemory(assembleSqliteSeededPayload(handle, meta));
953
995
  } else {
954
996
  // Dropped before the rebuild, not after it: the payload this replaces is the
955
997
  // largest object the handle holds, and keeping it reachable while the next
956
998
  // one assembles doubles the peak for no reason.
957
999
  handle.cachedPayload = null;
958
- handle.cachedPayload = rowsToPayload(overlayRows(handle.baseRows, handle.storedRows), { meta });
1000
+ handle.cachedPayload = migrateStoredMemory(rowsToPayload(overlayRows(handle.baseRows, handle.storedRows), { meta }));
959
1001
  }
960
1002
  }
961
1003
 
@@ -1951,7 +1993,9 @@ export async function snapshotMemory(dir, { retentionVersions } = {}) {
1951
1993
  export async function loadMemory(dir) {
1952
1994
  if (isMemoryHandle(dir)) return migrateStoredMemory(dir.payload);
1953
1995
  if (isSqliteHandle(dir)) return migrateStoredMemory(readSqlitePayload(dir));
1954
- if (isRowHandle(dir)) return migrateStoredMemory(await readRowPayload(dir));
1996
+ // Not migrated here: a row handle migrates the payload once, as it assembles
1997
+ // it, so every read after the first is spared two walks of the whole graph.
1998
+ if (isRowHandle(dir)) return readRowPayload(dir);
1955
1999
  let text;
1956
2000
  try {
1957
2001
  text = await readFile(memoryGraphFile(dir), "utf8");
@@ -2315,15 +2359,31 @@ function retractionsFor(payload, groupId) {
2315
2359
  * linear scan in that case. */
2316
2360
  const memoryIndexOf = (payload) => payload?.[MEMORY_INDEX] || null;
2317
2361
 
2362
+ /** Load, change, persist. A row handle always works on a copy here, even one
2363
+ * opened `copyOnRead: false`: a write drops every change that lands on a
2364
+ * seed-owned row, so a mutation applied straight to the assembled payload
2365
+ * would leave the handle holding changes the store refused. The copy is what
2366
+ * keeps "what this handle reads" and "what a fresh handle would assemble" the
2367
+ * same thing.
2368
+ *
2369
+ * A row handle does skip the prose index built here: `persistRowPayload`
2370
+ * re-derives every derived structure from the rows it just wrote, so building
2371
+ * one now builds it twice over the whole graph. */
2318
2372
  async function mutateMemory(dir, fn) {
2319
- const payload = await loadMemory(dir);
2320
- buildMemoryIndex(payload);
2321
- const out = (await fn(payload)) ?? payload;
2322
- migrateLegacyProvenance(out);
2323
- recomputeSourceReliability(out);
2324
- out.proseIndex = buildProseIndex(out.individuals);
2325
- await persistMemory(dir, out);
2326
- return out;
2373
+ const overRowHandle = isRowHandle(dir);
2374
+ const payload = overRowHandle ? mutablePayloadCopy(await ensureRowPayload(dir)) : await loadMemory(dir);
2375
+ try {
2376
+ buildMemoryIndex(payload);
2377
+ const out = (await fn(payload)) ?? payload;
2378
+ migrateLegacyProvenance(out);
2379
+ recomputeSourceReliability(out);
2380
+ if (!overRowHandle) out.proseIndex = buildProseIndex(out.individuals);
2381
+ await persistMemory(dir, out);
2382
+ return overRowHandle ? dir.cachedPayload : out;
2383
+ } catch (e) {
2384
+ dropAssembledRowPayload(dir);
2385
+ throw e;
2386
+ }
2327
2387
  }
2328
2388
 
2329
2389
  const labelOf = (text) => (text.length > LABEL_CAP ? text.slice(0, LABEL_CAP - 1) + "…" : text);
@@ -2552,7 +2612,10 @@ function recomputeSourceReliability(payload) {
2552
2612
  if (!Array.isArray(payload?.individuals) || !Array.isArray(payload?.objectProperties)) return;
2553
2613
  const rows = readFactRows(payload); // Fact-only — contradiction accounting is inherently Fact-shaped
2554
2614
  const contradictedFactIds = new Set();
2555
- for (const group of findContradictions(payload)) for (const r of group) contradictedFactIds.add(r.id);
2615
+ // The fold above is the same one findContradictions would take for itself,
2616
+ // and over a seed-sized graph it is half a second. Nothing changes the
2617
+ // payload between the two, so it goes across.
2618
+ for (const group of findContradictions(payload, { factRows: rows })) for (const r of group) contradictedFactIds.add(r.id);
2556
2619
 
2557
2620
  const bySource = new Map(); // sessionSourceId -> { factsAsserted, factsContradicted }
2558
2621
  for (const row of rows) {
@@ -4195,8 +4258,8 @@ export const MULTI_VALUED_PREDICATES = MERGE_PREDICATES;
4195
4258
  * only what its own clock could not order (the resolver's trust and codepoint
4196
4259
  * tie-breaks — see resolveSiblingGroups), so ordinary succession stops reading
4197
4260
  * as disagreement; every other predicate keeps the full keep-both contract. */
4198
- export function findContradictions(memory, { floor = CONTRADICTION_TRUST_FLOOR } = {}) {
4199
- const rows = readFactRows(memory).filter((r) => r.trust >= floor);
4261
+ export function findContradictions(memory, { floor = CONTRADICTION_TRUST_FLOOR, factRows = null } = {}) {
4262
+ const rows = (factRows || readFactRows(memory)).filter((r) => r.trust >= floor);
4200
4263
  const byKey = new Map();
4201
4264
  for (const r of rows) {
4202
4265
  if (resolutionStrategyFor(r.predicate) === RESOLUTION_MERGE) continue;
@@ -63,7 +63,7 @@ import { basename, join, resolve } from "node:path";
63
63
  import { runTurn, uuidv7, stripLeadingDiscourseAdverb } from "./chat.mjs";
64
64
  import { beginsWithVowelSound, grammarRules } from "./finish.mjs";
65
65
  import { splitSentencesPreservingPaths, stripCitationResidue } from "./sentences.mjs";
66
- import { loadMemory, readFactRows, appendFact, removeFacts } from "../adapters/memory/core.mjs";
66
+ import { loadMemory, readFactRows, appendFacts, removeFacts } from "../adapters/memory/core.mjs";
67
67
  import { loadConfig } from "../adapters/config.mjs";
68
68
  import { touchedFactRows } from "../domain/memory/touched-facts.mjs";
69
69
  import { normFactTerm } from "../domain/hash.mjs";
@@ -971,9 +971,14 @@ export async function ingestText(text, {
971
971
  const subjects = new Set(rows.map((r) => r.subject));
972
972
  if (subjects.size === 1) carrySubject = [...subjects][0];
973
973
  const tag = `extracted:${sourceTag}`;
974
+ // One write for the whole sentence, not one per row: a write reads
975
+ // and re-derives the whole graph, so N of them cost N times what one
976
+ // carrying the same N rows does. The batch stays inside the sentence
977
+ // — a later sentence still reads everything the earlier ones wrote.
978
+ const writes = [];
974
979
  for (const row of rows) {
975
980
  const extraction = findingsForRow(row, readingFindings, identifierTerms);
976
- await appendFact(dir, {
981
+ writes.push({
977
982
  subject: row.subject, predicate: row.predicate, object: row.object,
978
983
  provenance: tag, quantifier: row.quantifier || "", observedAt,
979
984
  ...(extraction.length ? { extraction } : {}),
@@ -985,6 +990,7 @@ export async function ingestText(text, {
985
990
  });
986
991
  taggedIds.add(row.id);
987
992
  }
993
+ await appendFacts(dir, writes);
988
994
  continue;
989
995
  }
990
996
  const ungrounded = ungroundedTermsIn(lastDecline);
@@ -1009,15 +1015,17 @@ export async function ingestText(text, {
1009
1015
  if (!keptCandidates.length) continue;
1010
1016
  optimisticSentences += 1;
1011
1017
  const tag = `optimistic-extract:${sourceTag}`;
1018
+ const candidateWrites = [];
1012
1019
  for (const t of keptCandidates) {
1013
1020
  const extraction = findingsForRow(t, mintFindings.has(t) ? [mintFindings.get(t)] : [], identifierTerms);
1014
- const written = await appendFact(dir, {
1021
+ candidateWrites.push({
1015
1022
  subject: t.subject, predicate: t.predicate, object: t.object, provenance: tag, observedAt,
1016
1023
  ...(extraction.length ? { extraction } : {}),
1017
1024
  });
1018
1025
  optimisticFacts.push({ ...t, provenance: tag, sentence, ...(extraction.length ? { extraction } : {}) });
1019
- taggedIds.add(written.id);
1020
1026
  }
1027
+ const { ids } = await appendFacts(dir, candidateWrites);
1028
+ for (const id of ids) taggedIds.add(id);
1021
1029
  }
1022
1030
  }
1023
1031
 
@@ -494,6 +494,27 @@ function mergeSnapshotsById(existing, incoming, cap) {
494
494
  return { items, added };
495
495
  }
496
496
 
497
+ const fetchedAtMs = (snapshot) => {
498
+ const ms = toMs(snapshot?.fetchedAt);
499
+ return Number.isFinite(ms) ? ms : 0;
500
+ };
501
+
502
+ /** True once a snapshot has been through a grounding round. `mergeSnapshotsById`
503
+ * files a snapshot the moment it arrives, so "known" and "grounded" are two
504
+ * different states and only this one means the facts landed. */
505
+ const isGroundedSnapshot = (snapshot) => (snapshot?.processedRounds || 0) > 0;
506
+
507
+ /** One source's fetched-but-not-yet-grounded snapshots, oldest first. A cycle
508
+ * that ran out of time leaves its backlog here, so the next cycle works
509
+ * through that before anything newer and the same article is never ingested
510
+ * twice. Ordered off the snapshots' own fields, so two cycles reading the same
511
+ * state take the same work in the same order. */
512
+ function pendingSnapshotsFor(items, sourceId) {
513
+ return (items || [])
514
+ .filter((snap) => snap?.sourceId === sourceId && !isGroundedSnapshot(snap))
515
+ .sort((a, b) => (fetchedAtMs(a) - fetchedAtMs(b)) || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
516
+ }
517
+
497
518
  function emptyCycleAccumulator(at) {
498
519
  return { at, sentences: 0, recognized: 0, optimisticCount: 0, factsAdded: 0, termsResolved: 0, derived: 0 };
499
520
  }
@@ -548,21 +569,28 @@ export async function pollNewsSources(ctx) {
548
569
  perSource.push({ sourceId, status: "failed" });
549
570
  continue;
550
571
  }
572
+ let added = [];
551
573
  if (result.notModified) {
552
574
  recordSuccess(health, nowVal, "not-modified");
553
- perSource.push({ sourceId, status: "not-modified" });
554
- continue;
575
+ } else {
576
+ recordSuccess(health, nowVal, "ok");
577
+ const merged = mergeSnapshotsById(state.items, result.items, config.itemCap);
578
+ state.items = merged.items;
579
+ added = merged.added;
580
+ newItemsTotal += added.length;
555
581
  }
556
- recordSuccess(health, nowVal, "ok");
557
- const { items: mergedItems, added } = mergeSnapshotsById(state.items, result.items, config.itemCap);
558
- state.items = mergedItems;
559
- newItemsTotal += added.length;
560
582
 
583
+ // Everything this source has fetched and not yet grounded, not just what
584
+ // arrived on this fetch: a source that answers 304 still has a backlog to
585
+ // finish, and an aborted cycle's leftovers would otherwise sit in the state
586
+ // marked known and never be read again.
561
587
  const before = emptyCycleAccumulator(nowVal);
562
588
  const after = emptyCycleAccumulator(nowVal);
563
- for (const snapshot of added) {
589
+ let grounded = 0;
590
+ for (const snapshot of pendingSnapshotsFor(state.items, sourceId)) {
564
591
  if (shouldAbort()) { aborted = true; break; }
565
592
  const r = await ingestNewsSnapshot(ctx, snapshot);
593
+ grounded += 1;
566
594
  after.sentences += r.sentences;
567
595
  after.recognized += r.recognized;
568
596
  after.optimisticCount += r.optimisticCount;
@@ -571,10 +599,17 @@ export async function pollNewsSources(ctx) {
571
599
  factsTotal += r.facts;
572
600
  derivedTotal += r.derived;
573
601
  }
574
- if (added.length) {
602
+ if (grounded) {
575
603
  state.metrics = [...(state.metrics || []), cycleMetrics(before, after, { source: sourceId })];
576
604
  }
577
- perSource.push({ sourceId, status: "ok", newItems: added.length });
605
+ const pendingLeft = pendingSnapshotsFor(state.items, sourceId).length;
606
+ perSource.push({
607
+ sourceId,
608
+ status: result.notModified ? "not-modified" : "ok",
609
+ newItems: added.length,
610
+ grounded,
611
+ pending: pendingLeft,
612
+ });
578
613
  if (aborted) break;
579
614
  }
580
615