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

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.13",
3
+ "version": "6.0.14",
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.",
@@ -2421,12 +2421,14 @@ function sourceIdFor(desc) {
2421
2421
  case "provider": return { id: `src:provider:${desc.name}`, type: "provider" };
2422
2422
  case "corpus": return { id: `src:corpus:${desc.name}`, type: "corpus" };
2423
2423
  case "corpusWeak": return { id: `src:corpus-weak:${desc.name}`, type: "corpusWeak" };
2424
- // One Source per pack article (the @revid stays in the article segment),
2425
- // so two facts from the same article corroborate nothing extra.
2426
- case "reference": return { id: `src:reference:${desc.pack}:${desc.article}`, type: "reference" };
2427
- // The live-Wikipedia pack: same per-article Source id, but a lower trust
2424
+ // One Source per reference WORK, not per article: Simple English Wikipedia
2425
+ // is one party however many of its pages get read, so two of its articles
2426
+ // stating the same triple corroborate nothing. Which article said it stays
2427
+ // on the fact's own provenance tag, where the audit trail belongs.
2428
+ case "reference": return { id: `src:reference:${desc.pack}`, type: "reference" };
2429
+ // The live-Wikipedia pack: same per-work Source id, but a lower trust
2428
2430
  // type so a live lookup ranks below the curated revision-pinned pack.
2429
- case "referenceLive": return { id: `src:reference:${desc.pack}:${desc.article}`, type: "referenceLive" };
2431
+ case "referenceLive": return { id: `src:reference:${desc.pack}`, type: "referenceLive" };
2430
2432
  // One Source per source-file basename, not per extraction run.
2431
2433
  case "extracted": return { id: `src:extracted:${desc.name}`, type: "extracted" };
2432
2434
  // The fuzzy tier's candidates: one low-trust Source per source label.
@@ -2600,6 +2602,49 @@ const isSessionScopedSourceId = (id) =>
2600
2602
  || id.startsWith(`${TEACH_SOURCE_ID}:`)
2601
2603
  || id.startsWith(`${TEACH_NODE_SOURCE_ID}:`));
2602
2604
 
2605
+ /**
2606
+ * The slice of the graph a reliability pass can possibly read: the triples a
2607
+ * session-scoped Source stated, and every (subject, predicate) those triples
2608
+ * sit under so each one's disagreeing siblings come along. Null when nothing
2609
+ * in the store has a track record to keep, which is the whole of what a
2610
+ * seed-only graph or a store whose writers are all documents ever needs.
2611
+ *
2612
+ * A pure narrowing, not a cache: the answer is still folded from the fact set
2613
+ * on every call, and every group left out is one whose row could not have
2614
+ * changed a number in the tally. A Source nobody stated anything for keeps no
2615
+ * reliability attribute either way, so leaving it out is what the whole-graph
2616
+ * pass does too.
2617
+ */
2618
+ function sessionScopedFoldScope(payload) {
2619
+ // The Source list is a handful of individuals where the edge list is one per
2620
+ // fact record, so "is there an actor here at all" is asked of the Sources.
2621
+ const idx = memoryIndexOf(payload);
2622
+ if (idx) {
2623
+ let anyActor = false;
2624
+ for (const id of idx.sourcesById.keys()) if (isSessionScopedSourceId(id)) { anyActor = true; break; }
2625
+ if (!anyActor) return null;
2626
+ }
2627
+ const statedGroup = payload.objectProperties.find((g) => g?.prop === STATED_BY_PROP);
2628
+ const statedRecordIds = new Set();
2629
+ for (const e of statedGroup?.examples || []) {
2630
+ if (isSessionScopedSourceId(e?.object)) statedRecordIds.add(e.subject);
2631
+ }
2632
+ const scopedGroups = new Set();
2633
+ const pairs = new Set();
2634
+ for (const ind of payload.individuals) {
2635
+ if (ind?.class !== FACT_CLASS) continue;
2636
+ // A summary that absorbed an actor's record still votes for it, so the
2637
+ // group it stands in is in scope exactly as the record itself would be.
2638
+ const stated = statedRecordIds.has(ind.id)
2639
+ || (isHeadRollupId(ind.id) && absorbedSourceIds(ind).some(isSessionScopedSourceId));
2640
+ if (!stated) continue;
2641
+ scopedGroups.add(factGroupId(ind.id));
2642
+ const subject = individualKey(ind, "subject");
2643
+ if (subject) pairs.add(subjectPredicateKey(subject, individualKey(ind, "predicate")));
2644
+ }
2645
+ return scopedGroups.size ? { pairs, scopedGroups } : null;
2646
+ }
2647
+
2603
2648
  /**
2604
2649
  * Recompute + materialise mgx:sourceReliability on every session-scoped
2605
2650
  * operator/teach Source: count facts stated vs. contradicted
@@ -2610,11 +2655,17 @@ const isSessionScopedSourceId = (id) =>
2610
2655
  */
2611
2656
  function recomputeSourceReliability(payload) {
2612
2657
  if (!Array.isArray(payload?.individuals) || !Array.isArray(payload?.objectProperties)) return;
2613
- const rows = readFactRows(payload); // Fact-only — contradiction accounting is inherently Fact-shaped
2658
+ const scope = sessionScopedFoldScope(payload);
2659
+ if (!scope) return;
2660
+
2661
+ // Fact-only — contradiction accounting is inherently Fact-shaped. Scoped to
2662
+ // the triples a scorable actor actually stated, plus every sibling object
2663
+ // those triples could be contradicted by: no other group can put a number in
2664
+ // the tally below, so folding the rest of the graph only costs time.
2665
+ const rows = foldFactRows(payload, factFoldContext(payload, scope));
2614
2666
  const contradictedFactIds = new Set();
2615
2667
  // 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.
2668
+ // and nothing changes the payload between the two, so it goes across.
2618
2669
  for (const group of findContradictions(payload, { factRows: rows })) for (const r of group) contradictedFactIds.add(r.id);
2619
2670
 
2620
2671
  const bySource = new Map(); // sessionSourceId -> { factsAsserted, factsContradicted }
@@ -3813,7 +3864,14 @@ export async function resolveRelationChaseReverse(memory, name, objectTerm, help
3813
3864
  * answers "what did this source used to say", never "what do I trust now".
3814
3865
  */
3815
3866
  export function readFactRows(memory, opts = {}) {
3816
- const ctx = factFoldContext(memory);
3867
+ return foldFactRows(memory, factFoldContext(memory), opts);
3868
+ }
3869
+
3870
+ /** The fold itself, over whatever slice of the graph a context was built for.
3871
+ * `readFactRows` hands it the whole graph; a caller that only needs certain
3872
+ * (subject, predicate) pairs hands it a scoped context and gets exactly the
3873
+ * rows a whole-graph fold would have produced for those pairs. */
3874
+ function foldFactRows(memory, ctx, opts = {}) {
3817
3875
  // A materialised head, when the backend keeps one, replaces the group's own
3818
3876
  // fold with the audit trail that fold was last built from — the same records,
3819
3877
  // read back instead of re-derived. It carries no recency by construction, so
@@ -3840,17 +3898,47 @@ export function readFactRows(memory, opts = {}) {
3840
3898
  * Shared by the read fold and by the head materialisation below, deliberately:
3841
3899
  * a stored aggregate and a computed one folded from different inputs is the
3842
3900
  * failure a materialised table invites, and one shared builder is what keeps
3843
- * the two from ever drifting apart. */
3844
- function factFoldContext(memory) {
3901
+ * the two from ever drifting apart.
3902
+ *
3903
+ * `pairs` narrows the context to the groups a given set of (subject,
3904
+ * predicate) keys carries, with `scopedGroups` naming groups to keep outright
3905
+ * whatever their records read as. Every group of a kept pair is kept, whoever
3906
+ * stated it, so a scoped fold reads each of those triples exactly as a
3907
+ * whole-graph fold does — and a caller asking about one party's facts still
3908
+ * sees every sibling object that party's claim could be contradicted by. */
3909
+ function factFoldContext(memory, { pairs = null, scopedGroups = new Set() } = {}) {
3845
3910
  const individuals = memory?.individuals || [];
3846
- const sourcesById = new Map(individuals.filter((i) => i?.class === SOURCE_CLASS).map((i) => [i.id, i]));
3847
- const statedGroup = (memory?.objectProperties || []).find((g) => g?.prop === STATED_BY_PROP);
3848
- const statedByRecord = new Map();
3849
- for (const e of statedGroup?.examples || []) {
3850
- if (!statedByRecord.has(e.subject)) statedByRecord.set(e.subject, []);
3851
- statedByRecord.get(e.subject).push(e.object);
3911
+ const idx = memoryIndexOf(memory);
3912
+ const sourcesById = idx
3913
+ ? idx.sourcesById
3914
+ : new Map(individuals.filter((i) => i?.class === SOURCE_CLASS).map((i) => [i.id, i]));
3915
+ // The live index already keeps this map, edge for edge, and rebuilding it
3916
+ // allocates one array per fact record over the whole graph.
3917
+ let statedByRecord = idx?.statedByBySubject;
3918
+ if (!statedByRecord) {
3919
+ const statedGroup = (memory?.objectProperties || []).find((g) => g?.prop === STATED_BY_PROP);
3920
+ statedByRecord = new Map();
3921
+ for (const e of statedGroup?.examples || []) {
3922
+ if (!statedByRecord.has(e.subject)) statedByRecord.set(e.subject, []);
3923
+ statedByRecord.get(e.subject).push(e.object);
3924
+ }
3852
3925
  }
3853
3926
 
3927
+ // Subject first, predicate only on a subject hit: reading both attributes off
3928
+ // every fact record in the graph is the whole cost of a scoped pass, and the
3929
+ // subject rules out nearly all of them on one lookup.
3930
+ const wantedSubjects = pairs ? new Set([...pairs].map((key) => key.slice(0, key.indexOf("")))) : null;
3931
+ const outsideScope = (ind) => {
3932
+ if (!pairs) return false;
3933
+ // A group the caller named outright is in whatever its records read as —
3934
+ // a summary standing for absorbed records carries only a copied template,
3935
+ // so its own attributes are not what places it.
3936
+ if (scopedGroups.has(factGroupId(ind.id))) return false;
3937
+ const subject = individualKey(ind, "subject");
3938
+ if (!wantedSubjects.has(subject)) return true;
3939
+ return !pairs.has(subjectPredicateKey(subject, individualKey(ind, "predicate")));
3940
+ };
3941
+
3854
3942
  const groups = new Map();
3855
3943
  const retractionsByGroup = new Map();
3856
3944
  for (const ind of individuals) {
@@ -3864,6 +3952,7 @@ function factFoldContext(memory) {
3864
3952
  if (ind?.class !== FACT_CLASS) continue;
3865
3953
  if ((ind.attributes || []).some((a) => a?.prop === SUPERSEDED_BY_PROP)) continue; // a demoted leaf, not a head
3866
3954
  if (isChainRollupId(ind.id)) continue; // a summary of one source's demoted history, which was never a vote
3955
+ if (outsideScope(ind)) continue;
3867
3956
  const groupId = factGroupId(ind.id);
3868
3957
  const group = groups.get(groupId);
3869
3958
  if (group) group.push(ind);
@@ -3888,26 +3977,89 @@ function factFoldContext(memory) {
3888
3977
  else groups.delete(groupId);
3889
3978
  }
3890
3979
 
3891
- const groupsByPair = new Map();
3892
- for (const [groupId, members] of groups) {
3893
- // Codepoint order on the record id, which sorts by source key — the same
3894
- // locale-free determinism the P2P layer's own sort insists on, so two peers
3895
- // holding the same records read the same row.
3896
- members.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
3897
- const key = subjectPredicateKey(individualKey(members[0], "subject"), individualKey(members[0], "predicate"));
3898
- const held = groupsByPair.get(key);
3899
- if (held) held.push(groupId);
3900
- else groupsByPair.set(key, [groupId]);
3901
- }
3980
+ // Codepoint order on the record id, which sorts by source key — the same
3981
+ // locale-free determinism the P2P layer's own sort insists on, so two peers
3982
+ // holding the same records read the same row.
3983
+ for (const members of groups.values()) members.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
3984
+
3985
+ // One entry per distinct Source, not one attribute scan per record: a seed's
3986
+ // 60,000 facts name a handful of Sources between them.
3987
+ const typeBySource = new Map();
3988
+ const sourceTypeOf = (id) => {
3989
+ let type = typeBySource.get(id);
3990
+ if (type === undefined) {
3991
+ type = (sourcesById.get(id)?.attributes || []).find((a) => a?.prop === "mgx:sourceType")?.value || "";
3992
+ typeBySource.set(id, type);
3993
+ }
3994
+ return type;
3995
+ };
3996
+
3997
+ // Likewise for the timestamp a provenance string embeds: a corpus band writes
3998
+ // one tag over tens of thousands of records, and parsing it is the same
3999
+ // answer every time.
4000
+ const embeddedBySource = new Map();
4001
+ const embeddedTimestampOf = (provenance) => {
4002
+ let ts = embeddedBySource.get(provenance);
4003
+ if (ts === undefined) {
4004
+ ts = embeddedTagTimestamp(provenance.split(" | ").filter(Boolean));
4005
+ embeddedBySource.set(provenance, ts);
4006
+ }
4007
+ return ts;
4008
+ };
4009
+
4010
+ // Only the sqlite head materialisation walks siblings by pair, and building
4011
+ // the index costs two attribute reads per group — so it is built when asked
4012
+ // for and not before.
4013
+ let groupsByPair = null;
3902
4014
 
3903
4015
  return {
3904
4016
  groups,
3905
- groupsByPair,
4017
+ get groupsByPair() {
4018
+ if (groupsByPair) return groupsByPair;
4019
+ groupsByPair = new Map();
4020
+ for (const [groupId, members] of groups) {
4021
+ const key = subjectPredicateKey(individualKey(members[0], "subject"), individualKey(members[0], "predicate"));
4022
+ const held = groupsByPair.get(key);
4023
+ if (held) held.push(groupId);
4024
+ else groupsByPair.set(key, [groupId]);
4025
+ }
4026
+ return groupsByPair;
4027
+ },
3906
4028
  statedByRecord,
3907
- sourceTypeOf: (id) => (sourcesById.get(id)?.attributes || []).find((a) => a?.prop === "mgx:sourceType")?.value || "",
4029
+ sourceTypeOf,
4030
+ embeddedTimestampOf,
3908
4031
  };
3909
4032
  }
3910
4033
 
4034
+ /** Everything the fold reads off one live head, gathered in ONE walk of its
4035
+ * attributes. Ten `.find()` scans of the same short array, once per record, is
4036
+ * the single most expensive thing a whole-graph fold does — the work is all in
4037
+ * the scanning, not in the reading. */
4038
+ function foldHeadFields(head) {
4039
+ let provenance = "";
4040
+ let createdAt = "";
4041
+ let observedAt = "";
4042
+ let extraction = "";
4043
+ let trustScore = "";
4044
+ let sourceId = "";
4045
+ let quantifier = "";
4046
+ let justification = "";
4047
+ for (const a of head?.attributes || []) {
4048
+ switch (a?.prop) {
4049
+ case "mgx:factProvenance": provenance = a.value || ""; continue;
4050
+ case CREATED_AT_PROP: createdAt = a.value || ""; continue;
4051
+ case OBSERVED_AT_PROP: observedAt = a.value || ""; continue;
4052
+ case EXTRACTION_FINDING_PROP: extraction = a.value || ""; continue;
4053
+ case TRUST_SCORE_PROP: trustScore = a.value || ""; continue;
4054
+ case SOURCE_ID_PROP: sourceId = a.value || ""; continue;
4055
+ default: break;
4056
+ }
4057
+ if (a?.key === "quantifier") quantifier = a.value || "";
4058
+ else if (a?.key === "justification") justification = a.value || "";
4059
+ }
4060
+ return { provenance, createdAt, observedAt, extraction, trustScore, sourceId, quantifier, justification };
4061
+ }
4062
+
3911
4063
  /** One triple group folded into its row, minus the aggregate trust — that is
3912
4064
  * the caller's, because it is the only part that depends on when you ask. */
3913
4065
  function foldFactGroup(id, heads, ctx) {
@@ -3953,10 +4105,11 @@ function foldFactGroup(id, heads, ctx) {
3953
4105
  });
3954
4106
  continue;
3955
4107
  }
3956
- const headTags = attrOf(head, "mgx:factProvenance").split(" | ").filter(Boolean);
4108
+ const field = foldHeadFields(head);
4109
+ const headTags = field.provenance.split(" | ").filter(Boolean);
3957
4110
  for (const tag of headTags) tags.add(tag);
3958
4111
  const [statedBy] = statedByRecord.get(head.id) || [];
3959
- const sourceId = statedBy || attrOf(head, SOURCE_ID_PROP);
4112
+ const sourceId = statedBy || field.sourceId;
3960
4113
  const sourceType = sourceTypeOf(sourceId);
3961
4114
  // src:none stands for "no Source at all", so it stays out of the union a
3962
4115
  // reader renders and out of the corroboration count, exactly as an
@@ -3965,9 +4118,9 @@ function foldFactGroup(id, heads, ctx) {
3965
4118
  sourceIds.push(statedBy);
3966
4119
  if (sourceType) sourceTypes.push(sourceType);
3967
4120
  }
3968
- const createdAt = attrOf(head, CREATED_AT_PROP);
3969
- const observedAt = attrOf(head, OBSERVED_AT_PROP);
3970
- const extraction = attrOf(head, EXTRACTION_FINDING_PROP).split(" ").filter(Boolean);
4121
+ const createdAt = field.createdAt;
4122
+ const observedAt = field.observedAt;
4123
+ const extraction = field.extraction ? field.extraction.split(" ").filter(Boolean) : [];
3971
4124
  for (const finding of extraction) findings.add(finding);
3972
4125
  assertions.push({
3973
4126
  id: head.id, sourceId, sourceType,
@@ -3975,13 +4128,13 @@ function foldFactGroup(id, heads, ctx) {
3975
4128
  createdAt,
3976
4129
  ...(observedAt ? { observedAt } : {}),
3977
4130
  ...(extraction.length ? { extraction } : {}),
3978
- ownTrust: Number(attrOf(head, TRUST_SCORE_PROP)) || 0,
3979
- assertedAt: assertionTimestampFor(headTags, createdAt),
4131
+ ownTrust: Number(field.trustScore) || 0,
4132
+ assertedAt: ctx.embeddedTimestampOf(field.provenance) || (Number.isFinite(Date.parse(createdAt)) ? createdAt : ""),
3980
4133
  });
3981
- quantifier = quantifier || keyOf(head, "quantifier");
4134
+ quantifier = quantifier || field.quantifier;
3982
4135
  // ' | '-separated environments, one premise-id list per independent
3983
4136
  // derivation; a legacy value with no ' | ' parses as one environment.
3984
- for (const chunk of keyOf(head, "justification").split(" | ")) {
4137
+ for (const chunk of field.justification.split(" | ")) {
3985
4138
  const env = chunk.split(" ").filter(Boolean);
3986
4139
  if (!env.length) continue;
3987
4140
  const key = env.join(" ");
@@ -59,8 +59,8 @@ const SPANS_A_SENTENCE_BOUNDARY_RE = /[.!?]\s+\w/;
59
59
  // never for the group part: a hand-built individual with a short opaque id is a
60
60
  // legitimate sparse write, and rejecting it is exactly the false positive this
61
61
  // gate must never produce. The source suffix is matched loosely on purpose — a
62
- // Source id legitimately carries colons, spaces and an `@revid` of its own
63
- // ("src:reference:simplewiki:Polar bear@912").
62
+ // Source id legitimately carries colons and spaces of its own
63
+ // ("src:corpus:mud:amber fox").
64
64
  const FACT_RECORD_ID_RE = /^[^@]+@(.+?)(#v[1-9][0-9]*)?$/;
65
65
  const looksLikeFactRecordId = (id) => id.includes("@") || /#v\d/.test(id);
66
66
 
@@ -107,6 +107,60 @@ function parsePeerNodeTagRest(rest) {
107
107
  const LIVE_REFERENCE_PACK = "wikipedia-live";
108
108
  const referenceKindFor = (pack) => (pack === LIVE_REFERENCE_PACK ? "referenceLive" : "reference");
109
109
 
110
+ /** The part of a tag that names WHICH publication it came from, with the
111
+ * per-item tail cut off: `news:nyt-world@item-91` names the NYT world feed,
112
+ * and the item id beside it says which article, not which publisher. One feed
113
+ * is one asserting party however many articles it runs, so the tail stays on
114
+ * the tag for audit and out of the Source identity — the same split
115
+ * `world:<name>:turnN` and `mud:<character>:turnN` already make. */
116
+ const publicationKeyOf = (rest) => String(rest || "").split("@")[0];
117
+
118
+ /** The same cut for a tag the fuzzy tier labels its own Source with: the feed
119
+ * or the reference work, never the article. `news:nyt-world@item-91` folds to
120
+ * `news:nyt-world`, `research:wikidata:otter` to `research:wikidata`, and the
121
+ * research lane's `research:otter@2` to `research`. */
122
+ function publicationSourceLabel(rest) {
123
+ if (rest.startsWith("news:")) return publicationKeyOf(rest);
124
+ const { pack } = researchTagToSource(rest.slice("research:".length));
125
+ return `research:${pack}`;
126
+ }
127
+
128
+ /** `teach:chat:ingest#<tag>@<ts>` — the ingest seam driving the chat teach lane
129
+ * as a RECOGNIZER over a document. The party asserting is the document's own
130
+ * publisher, which the embedded `<tag>` names, so the record lands on that
131
+ * publisher's Source rather than on a chat session. Without it an ingest mints
132
+ * a throwaway teach Source per SENTENCE, and one publication's sentences
133
+ * corroborate each other for free. */
134
+ export const INGEST_SESSION_MARKER = "ingest#";
135
+
136
+ /** The two `research:` tag shapes, both live-fetched at query time and both
137
+ * scored at the referenceLive prior, below every curated pack:
138
+ * research:<source>:<term> one KB adapter's own lookup (researchSourceTag)
139
+ * research:<topic>@<depth> the research lane's fan-out, which records how
140
+ * far it reached rather than which adapter answered
141
+ * The `@` is what tells them apart — a folded term never carries one. Either
142
+ * way the PACK names the reference work and the article segment names the page
143
+ * inside it, so `sourceIdFor` can key one Source per work. */
144
+ function researchTagToSource(rest) {
145
+ const at = rest.lastIndexOf("@");
146
+ if (at >= 0) return { kind: "referenceLive", pack: "research", article: rest.slice(0, at).trim() || "unknown" };
147
+ const colon = rest.indexOf(":");
148
+ if (colon < 0) return { kind: "referenceLive", pack: "research", article: rest.trim() || "unknown" };
149
+ return { kind: "referenceLive", pack: rest.slice(0, colon) || "research", article: rest.slice(colon + 1).trim() };
150
+ }
151
+
152
+ /** The publisher a chat-shaped tag stands in for, when its session slot carries
153
+ * the ingest marker; null for an ordinary session, which is every tag a person
154
+ * actually typed. One level only — an embedded chat tag is refused rather than
155
+ * followed, so a hand-written tag cannot nest its way anywhere. */
156
+ function ingestPublisherOf(chat) {
157
+ if (!chat.sessionId?.startsWith(INGEST_SESSION_MARKER)) return null;
158
+ const embedded = chat.sessionId.slice(INGEST_SESSION_MARKER.length);
159
+ if (embedded.startsWith("teach:") || embedded.startsWith("ace:")) return null;
160
+ const publisher = provenanceTagToSource(embedded);
161
+ return publisher ? { ...publisher, ...(chat.createdAt ? { createdAt: chat.createdAt } : {}) } : null;
162
+ }
163
+
110
164
  export function provenanceTagToSource(tag) {
111
165
  const t = String(tag || "").trim();
112
166
  if (!t) return null;
@@ -119,17 +173,7 @@ export function provenanceTagToSource(tag) {
119
173
  const pack = rest.slice(0, colon) || "unknown";
120
174
  return { kind: referenceKindFor(pack), pack, article: rest.slice(colon + 1) };
121
175
  }
122
- // research:<topic>@<depth> the research lane's Simple English Wikipedia
123
- // loads. Live-fetched at query time like the wikipedia-live pack, so it
124
- // scores at the same referenceLive prior, below every curated pack. Parsed
125
- // from the FULL tag (a topic may contain spaces); the depth segment records
126
- // how far the fan-out reached and is not part of the Source identity.
127
- if (t.startsWith("research:")) {
128
- const rest = t.slice("research:".length);
129
- const at = rest.lastIndexOf("@");
130
- const topic = (at >= 0 ? rest.slice(0, at) : rest).trim();
131
- return { kind: "referenceLive", pack: "research", article: topic || "unknown" };
132
- }
176
+ if (t.startsWith("research:")) return researchTagToSource(t.slice("research:".length));
133
177
  const head = t.split(/\s+/)[0]; // drop trailing " /r/IsA" etc.
134
178
  if (head.startsWith("corpus-weak:")) return { kind: "corpusWeak", name: head.slice("corpus-weak:".length) || "unknown" };
135
179
  if (head.startsWith("corpus:")) return { kind: "corpus", name: head.slice("corpus:".length) || "unknown" };
@@ -148,7 +192,10 @@ export function provenanceTagToSource(tag) {
148
192
  // `mud:` prefix so `src:corpus:mud:<character>` can never collide with a
149
193
  // `world:<name>` Source that happens to share the literal name.
150
194
  if (head.startsWith("mud:")) return { kind: "corpus", name: `mud:${head.slice("mud:".length).split(":")[0] || "unknown"}` };
151
- if (head.startsWith("ace:")) return { kind: "operator", ...parseChatTagRest(head.slice("ace:".length)) };
195
+ if (head.startsWith("ace:")) {
196
+ const chat = parseChatTagRest(head.slice("ace:".length));
197
+ return ingestPublisherOf(chat) || { kind: "operator", ...chat };
198
+ }
152
199
  if (head.startsWith("teach:")) {
153
200
  const rest = head.slice("teach:".length);
154
201
  // teach:peer:<name>#node:<id>@<ts> — a peer's own relabeled tag off the
@@ -156,7 +203,8 @@ export function provenanceTagToSource(tag) {
156
203
  const peerNode = parsePeerNodeTagRest(rest);
157
204
  if (peerNode) return peerNode;
158
205
  // the chat teach lane's natural frames — chat.mjs's teachProvenanceTag
159
- return { kind: "teach", ...parseChatTagRest(rest) };
206
+ const chat = parseChatTagRest(rest);
207
+ return ingestPublisherOf(chat) || { kind: "teach", ...chat };
160
208
  }
161
209
  if (head.startsWith("web:")) return { kind: "web", url: head.slice("web:".length) };
162
210
  if (head.startsWith("url:")) return { kind: "web", url: head.slice("url:".length) };
@@ -172,10 +220,23 @@ export function provenanceTagToSource(tag) {
172
220
  // the bare extracted: fallback below, which still catches every other
173
221
  // extracted: caller unchanged. A bare news:<sourceId>@<itemId> tag (a
174
222
  // future caller that writes it directly, with no extracted: wrapper)
175
- // scores the same way.
176
- if (head.startsWith("extracted:news:")) return { kind: "web", url: head.slice("extracted:".length) };
177
- if (head.startsWith("news:")) return { kind: "web", url: head };
178
- if (head.startsWith("optimistic-extract:")) return { kind: "optimisticExtract", name: head.slice("optimistic-extract:".length) || "unknown" };
223
+ // scores the same way. One Source per FEED: the item id rides the tag for
224
+ // audit and stays out of the identity.
225
+ if (head.startsWith("extracted:news:")) return { kind: "web", url: publicationKeyOf(head.slice("extracted:".length)) };
226
+ if (head.startsWith("news:")) return { kind: "web", url: publicationKeyOf(head) };
227
+ // The same wrapper over a KB lookup's own tag: the reference work that
228
+ // answered is the asserting party, not the ingest run that read it.
229
+ if (head.startsWith("extracted:research:")) return researchTagToSource(head.slice("extracted:research:".length));
230
+ // The fuzzy tier keeps a Source of its OWN — a candidate the strict
231
+ // recognizer skipped must never corroborate a curated fact — but one per
232
+ // publication, not one per article.
233
+ if (head.startsWith("optimistic-extract:")) {
234
+ const rest = head.slice("optimistic-extract:".length);
235
+ const publication = rest.startsWith("news:") || rest.startsWith("research:")
236
+ ? publicationSourceLabel(rest)
237
+ : rest;
238
+ return { kind: "optimisticExtract", name: publication || "unknown" };
239
+ }
179
240
  if (head.startsWith("extracted:")) return { kind: "extracted", name: head.slice("extracted:".length) || "unknown" };
180
241
  if (head.startsWith("entailed:")) return { kind: "entailed", rule: head.slice("entailed:".length) };
181
242
  if (head.startsWith("chat:") || head.startsWith("session:") || head.startsWith("operator")) return { kind: "operator" };
@@ -18487,14 +18487,15 @@ async function factRowSnapshot(memoryDir) {
18487
18487
  try { return readStoredFactRows(await loadMemoryStore(memoryDir)); } catch { return null; }
18488
18488
  }
18489
18489
 
18490
- /** The Fact rows this turn wrote, diffed against the snapshot taken before it.
18491
- * Empty when the turn had no store to write to, or wrote nothing. */
18490
+ /** The Fact rows this turn wrote, diffed against the snapshot taken before it,
18491
+ * with the after-snapshot handed back beside them. Empty when the turn had no
18492
+ * store to write to, or wrote nothing. */
18492
18493
  async function factsTouchedSince(memoryDir, before) {
18493
- if (!before) return [];
18494
+ if (!before) return { factsTouched: [], factRowsAfter: null };
18494
18495
  const after = await factRowSnapshot(memoryDir);
18495
- if (!after) return [];
18496
+ if (!after) return { factsTouched: [], factRowsAfter: null };
18496
18497
  const { touchedFactRows } = await import("../domain/memory/touched-facts.mjs");
18497
- return touchedFactRows(before, after);
18498
+ return { factsTouched: touchedFactRows(before, after), factRowsAfter: after };
18498
18499
  }
18499
18500
 
18500
18501
  /** A whole-line miss whose only problem is a closed filler clause in front of a
@@ -18519,12 +18520,19 @@ async function answerWithoutFillerPrefix(input, options, missed) {
18519
18520
  }
18520
18521
 
18521
18522
  /** Run one turn and report which Fact rows it wrote, as `factsTouched` beside
18522
- * the answer/record/logLines every caller already reads. The dispatch itself
18523
- * is dispatchTurn, below; this wrapper exists so the field lands on EVERY
18524
- * return path (dispatched, conversational, multi-sentence) from one place. */
18523
+ * the answer/record/logLines every caller already reads, with the after-fold
18524
+ * the diff was taken against as `factRowsAfter`. The dispatch itself is
18525
+ * dispatchTurn, below; this wrapper exists so the field lands on EVERY return
18526
+ * path (dispatched, conversational, multi-sentence) from one place.
18527
+ *
18528
+ * `options.factRowsBefore` is the caller's own already-folded view of the
18529
+ * store, standing in for the before-snapshot. A caller running many turns over
18530
+ * one document folds once and threads it; folding a seed-sized graph again per
18531
+ * turn, to read back the rows the caller just handed over, is the most
18532
+ * expensive thing an ingest does. */
18525
18533
  export async function runTurn(input, options = {}) {
18526
18534
  const memoryDir = options?.memoryDir ?? null;
18527
- const before = await factRowSnapshot(memoryDir);
18535
+ const before = options?.factRowsBefore || await factRowSnapshot(memoryDir);
18528
18536
  let result;
18529
18537
  try {
18530
18538
  result = await dispatchTurn(input, options);
@@ -18541,7 +18549,7 @@ export async function runTurn(input, options = {}) {
18541
18549
  });
18542
18550
  }
18543
18551
  if (!result || typeof result !== "object") return result;
18544
- return { ...result, factsTouched: await factsTouchedSince(memoryDir, before) };
18552
+ return { ...result, ...(await factsTouchedSince(memoryDir, before)) };
18545
18553
  }
18546
18554
 
18547
18555
  async function dispatchTurn(input, { config, source = defaultSource, graph = null, focus = null, last = null, memoryDir = null, sessionId = "", env = process.env, lexicon = null, narrate = false, liveReference = false, researchSource = null, onLiveLookup = null, vocabHint = null, tel = null, biasByBundle = {}, factRowsCache: injectedFactRowsCache = null, planState = null, gameConfig = null, uiContext = "cli", synthesisBudget = AUTO_SYNTHESIS_BUDGET, researchState = null, researchConfig = null, newsState = null, newsConfig = null, newsProviders = null, discourse = null, _noSplit = false, actingSubject = "player", codeDomainActive = null, laneVocab = null, domainPacks = null, retrieval = null } = {}) {
@@ -66,6 +66,7 @@ import { splitSentencesPreservingPaths, stripCitationResidue } from "./sentences
66
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
+ import { INGEST_SESSION_MARKER } from "../domain/memory/trust.mjs";
69
70
  import { normFactTerm } from "../domain/hash.mjs";
70
71
  import { splitIdentifierWords } from "../domain/prose.mjs";
71
72
  import { winkInstance } from "../adapters/wink-model.mjs";
@@ -104,18 +105,22 @@ export function parseArgs(argv) {
104
105
  * on a browser-sized graph — so the caller threads one fold from sentence to
105
106
  * sentence instead of paying a fresh one per candidate.
106
107
  */
107
- async function runSentence(sentence, { config, memoryDir, env, beforeRows }) {
108
+ async function runSentence(sentence, { config, memoryDir, env, beforeRows, sessionId = "" }) {
108
109
  const before = beforeRows || readFactRows(await loadMemory(memoryDir));
109
110
  if (ingestYield) await ingestYield();
110
- const { record, answer } = await runTurn(sentence, { config, memoryDir, sessionId: uuidv7(), env });
111
+ // The turn takes the caller's fold as its own before-view and hands back the
112
+ // after-fold it already had to take, so one sentence costs one fold rather
113
+ // than three of the same graph.
114
+ const { record, answer, factsTouched, factRowsAfter } = await runTurn(sentence, {
115
+ config, memoryDir, sessionId: sessionId || uuidv7(), env, factRowsBefore: before,
116
+ });
117
+ const after = factRowsAfter || before;
111
118
  // Only an assert turn can have written a Fact, so only an assert turn earns
112
- // the post-turn fold; every other turn hands the caller's own view straight
113
- // back untouched.
119
+ // a fresh view; every other turn hands the caller's own straight back.
114
120
  if (record?.via !== "assert") return { recognized: false, rows: [], afterRows: before, decline: String(answer || "") };
115
121
  if (ingestYield) await ingestYield();
116
- const after = readFactRows(await loadMemory(memoryDir));
117
122
  if (record?.miss) return { recognized: false, rows: [], afterRows: after, decline: String(answer || "") };
118
- return { recognized: true, rows: touchedFactRows(before, after), afterRows: after };
123
+ return { recognized: true, rows: factsTouched || touchedFactRows(before, after), afterRows: after };
119
124
  }
120
125
 
121
126
  /** The recognizer's own words for why it turned a sentence down, when it named
@@ -800,6 +805,13 @@ function canonicalLines(facts, storeRows) {
800
805
  * are the only output.
801
806
  * sourceTag the label the audit provenance carries (extracted:<tag> /
802
807
  * optimistic-extract:<tag>). Default "text".
808
+ * attributeToSource
809
+ * file the recognizer's own assertion under `sourceTag`'s
810
+ * publication instead of a fresh chat session per sentence.
811
+ * Off by default: an operator running `tmct extract` over their
812
+ * own notes IS the asserting party, so that lane keeps minting
813
+ * a session. A feed or a reference work is not, and one
814
+ * publication's sentences must never corroborate each other.
803
815
  * optimistic also run the fuzzy tier over strict-skipped sentences.
804
816
  * canonical include a `canonical` array: one enriched triple line per
805
817
  * ingested fact.
@@ -830,7 +842,12 @@ function canonicalLines(facts, storeRows) {
830
842
  export async function ingestText(text, {
831
843
  memoryDir = null, sourceTag = "text", optimistic = false,
832
844
  canonical = false, config = null, lexicon = null, observedAt = "", findings = false,
845
+ attributeToSource = false,
833
846
  } = {}) {
847
+ // The session id every sentence's recognizer turn runs under. Stable and
848
+ // derived from the publication when the caller attributes to it, so the whole
849
+ // run lands on one Source; a fresh uuid per sentence otherwise (runSentence).
850
+ const recognizerSessionId = attributeToSource ? `${INGEST_SESSION_MARKER}${sourceTag.split("@")[0]}` : "";
834
851
  // Paragraphs first (blank-line separated), so the pronoun carry never bridges
835
852
  // a topic break: a fresh paragraph clears the last-subject it would resolve
836
853
  // "they"/"it" against. Each paragraph then splits into sentences the shared
@@ -900,7 +917,7 @@ export async function ingestText(text, {
900
917
  if (ingestYield) await ingestYield();
901
918
  const knownIds = new Set(currentRows.map((r) => r.id));
902
919
  const { recognized, rows, afterRows, decline } = await runSentence(form, {
903
- config: cfg, memoryDir: dir, env: runEnv, beforeRows: currentRows,
920
+ config: cfg, memoryDir: dir, env: runEnv, beforeRows: currentRows, sessionId: recognizerSessionId,
904
921
  });
905
922
  currentRows = afterRows;
906
923
  if (!recognized) { lastDecline = decline || lastDecline; return null; }
@@ -338,6 +338,7 @@ async function ingestSnapshotFacts(ctx, snapshot) {
338
338
  const sourceTag = `news:${snapshot.sourceId}@${snapshot.id}`;
339
339
  const result = await ingestText(text, {
340
340
  memoryDir, sourceTag, optimistic: true, lexicon: lex, observedAt: nowVal, findings: true,
341
+ attributeToSource: true,
341
342
  });
342
343
  invalidateCache(cache);
343
344
 
@@ -662,6 +663,7 @@ async function ingestResearchArticle(ctx, term, provider, article) {
662
663
  ingested = await ingestText(prose, {
663
664
  memoryDir, sourceTag: provenance, optimistic: true,
664
665
  lexicon: lexicon || loadLexicon(), observedAt: resolveNow(now), findings: true,
666
+ attributeToSource: true,
665
667
  });
666
668
  invalidateCache(ctx.cache);
667
669
  }