@polycode-projects/the-mechanical-code-talker 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +77 -3
- package/ROADMAP.md +416 -3
- package/bin/tmct.mjs +308 -12
- package/corpus/README.md +52 -0
- package/corpus/conceptnet/LICENSE-NOTICE +37 -0
- package/corpus/conceptnet/README.md +103 -0
- package/corpus/conceptnet/fetch-slice.mjs +136 -0
- package/corpus/conceptnet/filter-dump.mjs +89 -0
- package/corpus/conceptnet/slice.jsonl +14258 -0
- package/data/phrasebook/software-phrases.txt +231 -0
- package/data/templates/grammar-rules.toml +89 -0
- package/data/templates/responses.jsonl +68 -0
- package/package.json +40 -3
- package/src/ask-nlp.mjs +22 -10
- package/src/ask-vocab.mjs +35 -1
- package/src/ask.mjs +171 -494
- package/src/chat.mjs +709 -81
- package/src/corpus/conceptnet-map.toml +251 -0
- package/src/corpus/conceptnet.mjs +167 -0
- package/src/corpus/templates.mjs +188 -0
- package/src/finish.mjs +443 -0
- package/src/grammar/ace.mjs +341 -0
- package/src/grammar/assert.mjs +40 -0
- package/src/grammar/lexicon-core.json +287 -0
- package/src/grammar/lexicon.mjs +202 -0
- package/src/hash.mjs +32 -0
- package/src/index.mjs +21 -5
- package/src/init.mjs +264 -0
- package/src/interpret/fuzzy.mjs +89 -0
- package/src/interpret/merge.mjs +148 -0
- package/src/interpret/normalize.mjs +151 -0
- package/src/interpret/pipeline.mjs +112 -0
- package/src/interpret/strategies/grammar.mjs +137 -0
- package/src/interpret/strategies/keywords.mjs +241 -0
- package/src/interpret/strategies/noise-strip.mjs +114 -0
- package/src/memory/blocks.mjs +221 -0
- package/src/memory/core.mjs +533 -0
- package/src/memory/fold.mjs +0 -0
- package/src/memory/inspect.mjs +141 -0
- package/src/memory/trust.mjs +113 -0
- package/src/prose-nlp.mjs +14 -16
- package/src/providers/bootstrap.mjs +24 -0
- package/src/providers/fixture.mjs +118 -0
- package/src/providers/graph-service.mjs +312 -0
- package/src/repository-interface.mjs +318 -0
- package/src/server.mjs +44 -28
- package/src/sessions.mjs +137 -4
- package/src/source.mjs +44 -5
- package/src/syllogise.mjs +0 -0
- package/src/toml-config.mjs +14 -0
- package/src/tui/app.mjs +173 -0
- package/src/wink-model.mjs +74 -0
- package/bin/cli.mjs +0 -226
|
@@ -0,0 +1,533 @@
|
|
|
1
|
+
// memory/core.mjs — tmct's OWN conversational memory graph (ROADMAP item 9).
|
|
2
|
+
//
|
|
3
|
+
// A dedicated OWL-labelled store at <repo>/.tmct/memory/graph.json — raw JSON in
|
|
4
|
+
// the exact `entities` shape buildEntities produces, so codegraph.mjs's
|
|
5
|
+
// parseEntities() loads it unchanged ({ individuals, byId, relations, proseIndex }).
|
|
6
|
+
// It is DISTINCT from any provider-supplied code graph: tmct never writes a
|
|
7
|
+
// provider's graph (docs/adapter-contract.md); memory writes land ONLY here.
|
|
8
|
+
//
|
|
9
|
+
// What goes in:
|
|
10
|
+
// - every parsed inbound request becomes an "a-visitor-said" individual
|
|
11
|
+
// (class `Utterance`, role=visitor) and every response an "a-tmct-said"
|
|
12
|
+
// individual (role=tmct), each carrying text/ts/role attributes, an
|
|
13
|
+
// `mgx:saidInSession` edge to its Session anchor, and — for a response —
|
|
14
|
+
// an `mgx:inReplyTo` edge to the visitor utterance it answers;
|
|
15
|
+
// - grammar-derived OWL triples via appendFact() (subject/predicate/object +
|
|
16
|
+
// provenance), reified RDF-style (rdf:subject / rdf:predicate / rdf:object
|
|
17
|
+
// on a `Fact` individual) — the Phase-2 ACE parser's write point.
|
|
18
|
+
//
|
|
19
|
+
// OWL labelling: individuals are rdf-ish typed twice — the payload-level `class`
|
|
20
|
+
// field (Utterance / Fact / Session, counted in `classes[]` like every other
|
|
21
|
+
// graph class) AND an `rdf:type` attribute naming the OWL term
|
|
22
|
+
// (owl:NamedIndividual for utterances, rdf:Statement for reified facts), with
|
|
23
|
+
// the owl/rdf/rdfs prefixes declared in the payload's `prefixes` block —
|
|
24
|
+
// consistent with graph-build.mjs's JSON-label-only vocabulary style.
|
|
25
|
+
//
|
|
26
|
+
// Every append is crash-safe (fresh read → mutate → temp-file + rename, the
|
|
27
|
+
// sessions.mjs discipline) and IDEMPOTENT: utterance ids are deterministic
|
|
28
|
+
// (utt:<session>#<ts>#<role>) and fact ids hash the triple, so the per-turn
|
|
29
|
+
// re-append sessions.mjs performs replaces rather than duplicates.
|
|
30
|
+
|
|
31
|
+
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
32
|
+
import { dirname, join } from "node:path";
|
|
33
|
+
import { proseTokensFor, buildProseIndex } from "../prose.mjs";
|
|
34
|
+
import { fnv1aHex } from "../hash.mjs";
|
|
35
|
+
import { computeTrust, TRUST_SCORE_PROP, TRUST_INPUTS_PROP } from "./trust.mjs";
|
|
36
|
+
|
|
37
|
+
export const MEMORY_DIR_REL = join(".tmct", "memory");
|
|
38
|
+
export const MEMORY_GRAPH_REL = join(MEMORY_DIR_REL, "graph.json");
|
|
39
|
+
|
|
40
|
+
export const UTTERANCE_CLASS = "Utterance";
|
|
41
|
+
export const FACT_CLASS = "Fact";
|
|
42
|
+
export const MEMORY_SESSION_CLASS = "Session";
|
|
43
|
+
export const SOURCE_CLASS = "Source";
|
|
44
|
+
|
|
45
|
+
export const SAID_IN_SESSION_PROP = "mgx:saidInSession";
|
|
46
|
+
export const IN_REPLY_TO_PROP = "mgx:inReplyTo";
|
|
47
|
+
|
|
48
|
+
// The provenance-link predicate family (PLAN_PROVENANCE_TRUST step (b)): one
|
|
49
|
+
// umbrella object property with two workhorse subproperties, minted in the owned
|
|
50
|
+
// mgx: namespace to match tmct-core.ttl's object-property style.
|
|
51
|
+
export const DERIVED_FROM_PROP = "mgx:derivedFrom"; // umbrella: Fact → Source|Fact
|
|
52
|
+
export const STATED_BY_PROP = "mgx:statedBy"; // a Source directly asserts a Fact
|
|
53
|
+
export const CANONICALISED_FROM_PROP = "mgx:canonicalisedFrom"; // a canonical Fact ← its raw form
|
|
54
|
+
export const CREATED_AT_PROP = "mgx:createdAt"; // first-write-wins ISO-8601 on every individual
|
|
55
|
+
|
|
56
|
+
// The one deterministic operator Source id — the operator chatting to tmct.
|
|
57
|
+
export const OPERATOR_SOURCE_ID = "src:operator-chat";
|
|
58
|
+
|
|
59
|
+
const ROLES = new Set(["visitor", "tmct"]);
|
|
60
|
+
const LABEL_CAP = 48; // utterance/fact labels stay skimmable in renders
|
|
61
|
+
const TEXT_CAP = 2000; // an utterance's stored text (a whole answer fits; a pasted book doesn't)
|
|
62
|
+
|
|
63
|
+
/** The memory graph's vocabulary — documented in-payload exactly like
|
|
64
|
+
* graph-build.mjs documents the code graph's. */
|
|
65
|
+
const MEMORY_VOCABULARY = [
|
|
66
|
+
{ prop: "rdf:type", note: "rdf-ish typing attribute: owl:NamedIndividual (utterances/sessions) or rdf:Statement (reified facts)" },
|
|
67
|
+
{ prop: "mgx:utteranceRole", note: "who said it: visitor (an a-visitor-said item) or tmct (the response alongside it)" },
|
|
68
|
+
{ prop: "mgx:utteranceText", note: "the utterance's normalized text (capped)" },
|
|
69
|
+
{ prop: "mgx:utteranceTs", note: "when it was said, ISO-8601 (the chat turn timestamp)" },
|
|
70
|
+
{ prop: "mgx:utteranceParsed", note: "optional JSON of the parse the interpretation pipeline produced for this request" },
|
|
71
|
+
{ prop: SAID_IN_SESSION_PROP, predicate: "saidInSession", note: "Utterance → Session it was said in; runtime observation, owned (no SEON term)" },
|
|
72
|
+
{ prop: IN_REPLY_TO_PROP, predicate: "inReplyTo", note: "tmct Utterance → the visitor Utterance it answers (the Q/A pairing)" },
|
|
73
|
+
{ prop: "rdf:subject", note: "reified fact: the triple's subject term" },
|
|
74
|
+
{ prop: "rdf:predicate", note: "reified fact: the triple's predicate term" },
|
|
75
|
+
{ prop: "rdf:object", note: "reified fact: the triple's object term" },
|
|
76
|
+
{ prop: "mgx:factProvenance", note: "LEGACY COMPAT SHIM: the ' | '-joined provenance tag string a fact came from; the source-of-truth is now the mgx:statedBy edges derived from it" },
|
|
77
|
+
{ prop: CREATED_AT_PROP, note: "when an individual was FIRST written, ISO-8601 (first-write-wins on upsert); the audit 'when', the recency input to trust, the novelty signal" },
|
|
78
|
+
{ 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)" },
|
|
79
|
+
{ prop: STATED_BY_PROP, predicate: "statedBy", note: "subPropertyOf derivedFrom: a Source directly asserts this Fact (one edge per independent source — replaces the factProvenance union)" },
|
|
80
|
+
{ prop: CANONICALISED_FROM_PROP, predicate: "canonicalisedFrom", note: "subPropertyOf derivedFrom: a canonical Fact cleaned from a raw Block/Source, never replacing it" },
|
|
81
|
+
{ prop: "mgx:sourceType", note: "a Source's kind: operator | provider | corpus | web | entailed (the trust-prior key)" },
|
|
82
|
+
{ prop: "mgx:sourceUrl", note: "a web Source's URL" },
|
|
83
|
+
{ prop: "mgx:sourceRule", note: "an entailed Source's rule id" },
|
|
84
|
+
{ prop: TRUST_SCORE_PROP, note: "materialised trust cache in [0,1] — pure function of a fact's Sources + createdAt (memory/trust.mjs); invalidated when a statedBy edge is added" },
|
|
85
|
+
{ prop: TRUST_INPUTS_PROP, note: "JSON of the inputs the trust score was computed from (source-type multiset, corroboration count, createdAt, recency) — makes the score auditable" },
|
|
86
|
+
{ prop: "mgx:hasProseTokens", note: "prose tokens (prose.mjs tokenizer) backing the payload's proseIndex" },
|
|
87
|
+
{ prop: "mgx:sessionStarted", note: "session anchor: when the session started, ISO-8601" },
|
|
88
|
+
];
|
|
89
|
+
|
|
90
|
+
/** A fresh, empty memory payload — the buildEntities shape, plus the OWL/RDF
|
|
91
|
+
* prefixes the memory vocabulary uses. `memory: true` marks it as tmct's own
|
|
92
|
+
* store (never a provider artifact). */
|
|
93
|
+
export function emptyMemory() {
|
|
94
|
+
return {
|
|
95
|
+
generated_at: "",
|
|
96
|
+
memory: true,
|
|
97
|
+
prefixes: {
|
|
98
|
+
owl: "http://www.w3.org/2002/07/owl#",
|
|
99
|
+
rdf: "http://www.w3.org/1999/02/22-rdf-syntax-ns#",
|
|
100
|
+
rdfs: "http://www.w3.org/2000/01/rdf-schema#",
|
|
101
|
+
mgx: "urn:tmct:mgx#",
|
|
102
|
+
},
|
|
103
|
+
vocabulary: MEMORY_VOCABULARY.map((v) => ({ ...v })),
|
|
104
|
+
classes: [],
|
|
105
|
+
objectProperties: [],
|
|
106
|
+
individuals: [],
|
|
107
|
+
proseIndex: {},
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const memoryGraphFile = (dir) => join(dir, MEMORY_GRAPH_REL);
|
|
112
|
+
|
|
113
|
+
/** Atomic JSON write (temp in the same dir + rename) — same discipline as
|
|
114
|
+
* sessions.mjs's graph append: a crash never destroys the previous store. */
|
|
115
|
+
async function atomicWriteJson(file, obj) {
|
|
116
|
+
const tmp = `${file}.tmp-${process.pid}-${Math.random().toString(36).slice(2, 10)}`;
|
|
117
|
+
await writeFile(tmp, JSON.stringify(obj));
|
|
118
|
+
await rename(tmp, file);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Load the memory graph for a repo dir. A missing store is the bootstrap:
|
|
122
|
+
* return the empty payload (uncached — the first append creates the file).
|
|
123
|
+
* The result is a raw entities payload; parseEntities() loads it. */
|
|
124
|
+
export async function loadMemory(dir) {
|
|
125
|
+
let text;
|
|
126
|
+
try {
|
|
127
|
+
text = await readFile(memoryGraphFile(dir), "utf8");
|
|
128
|
+
} catch (e) {
|
|
129
|
+
if (e?.code === "ENOENT") return emptyMemory();
|
|
130
|
+
throw e;
|
|
131
|
+
}
|
|
132
|
+
return JSON.parse(text);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Fresh read → mutate → atomic write. Serialized per call; every public append
|
|
136
|
+
* goes through here so a concurrent reader never sees a torn store. The lazy,
|
|
137
|
+
* idempotent legacy-provenance migration rides this same cycle (step (b)): any
|
|
138
|
+
* Fact still carrying only the old mgx:factProvenance string gets its Sources +
|
|
139
|
+
* statedBy edges + trust materialised on the next write of any kind. */
|
|
140
|
+
async function mutateMemory(dir, fn) {
|
|
141
|
+
const payload = await loadMemory(dir);
|
|
142
|
+
const out = fn(payload) ?? payload;
|
|
143
|
+
migrateLegacyProvenance(out);
|
|
144
|
+
out.proseIndex = buildProseIndex(out.individuals);
|
|
145
|
+
await mkdir(dirname(memoryGraphFile(dir)), { recursive: true });
|
|
146
|
+
await atomicWriteJson(memoryGraphFile(dir), out);
|
|
147
|
+
return out;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const normText = (t) => String(t ?? "").replace(/\s+/g, " ").trim().slice(0, TEXT_CAP);
|
|
151
|
+
const labelOf = (text) => (text.length > LABEL_CAP ? text.slice(0, LABEL_CAP - 1) + "…" : text);
|
|
152
|
+
const nowIso = () => new Date().toISOString();
|
|
153
|
+
|
|
154
|
+
/** First-write-wins createdAt: keep the prior individual's timestamp if it has
|
|
155
|
+
* one (records when a thing was FIRST learned, not when last touched), else the
|
|
156
|
+
* candidate. */
|
|
157
|
+
function firstWriteCreatedAt(prior, candidate) {
|
|
158
|
+
return prior?.attributes?.find((a) => a?.prop === CREATED_AT_PROP)?.value || candidate || nowIso();
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Set (replace-or-append) one attribute on an individual by prop. */
|
|
162
|
+
function setAttr(ind, prop, key, value) {
|
|
163
|
+
ind.attributes = (ind.attributes || []).filter((a) => a?.prop !== prop);
|
|
164
|
+
ind.attributes.push({ prop, key, value });
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ---- Sources (step (b)): first-class provenance individuals -----------------
|
|
168
|
+
|
|
169
|
+
/** Deterministic Source id + type over the closed kind set. Returns null for an
|
|
170
|
+
* unknown kind (an unmappable provenance tag → no Source, honestly). */
|
|
171
|
+
function sourceIdFor(desc) {
|
|
172
|
+
switch (desc?.kind) {
|
|
173
|
+
case "operator": return { id: OPERATOR_SOURCE_ID, type: "operator" };
|
|
174
|
+
case "provider": return { id: `src:provider:${desc.name}`, type: "provider" };
|
|
175
|
+
case "corpus": return { id: `src:corpus:${desc.name}`, type: "corpus" };
|
|
176
|
+
case "web": return { id: `src:learned:web:${fnv1aHex(String(desc.url || ""))}`, type: "web", url: String(desc.url || "") };
|
|
177
|
+
case "entailed": return { id: `src:entailed:${desc.rule}`, type: "entailed", rule: String(desc.rule || "") };
|
|
178
|
+
default: return null;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const sourceLabel = (id) => String(id).replace(/^src:/, "");
|
|
183
|
+
|
|
184
|
+
/** Upsert a Source individual (deterministic id → idempotent, edges never
|
|
185
|
+
* dangle). createdAt is first-write-wins; a recovered @<ts> (desc.createdAt)
|
|
186
|
+
* seeds it when present. Returns the Source id, or null for an unknown kind. */
|
|
187
|
+
function upsertSource(payload, desc, createdAtCandidate) {
|
|
188
|
+
const info = sourceIdFor(desc);
|
|
189
|
+
if (!info) return null;
|
|
190
|
+
const prior = payload.individuals.find((i) => i?.id === info.id);
|
|
191
|
+
const created = firstWriteCreatedAt(prior, desc?.createdAt || createdAtCandidate);
|
|
192
|
+
upsertIndividual(payload, {
|
|
193
|
+
id: info.id, label: sourceLabel(info.id), class: SOURCE_CLASS,
|
|
194
|
+
derived_from: [], mentions: [],
|
|
195
|
+
attributes: [
|
|
196
|
+
{ prop: "rdf:type", key: "type", value: "owl:NamedIndividual" },
|
|
197
|
+
{ prop: "mgx:sourceType", key: "sourceType", value: info.type },
|
|
198
|
+
{ prop: CREATED_AT_PROP, key: "createdAt", value: created },
|
|
199
|
+
...(info.url ? [{ prop: "mgx:sourceUrl", key: "sourceUrl", value: info.url }] : []),
|
|
200
|
+
...(info.rule ? [{ prop: "mgx:sourceRule", key: "sourceRule", value: info.rule }] : []),
|
|
201
|
+
],
|
|
202
|
+
});
|
|
203
|
+
return info.id;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Parse one legacy provenance TAG into a Source descriptor over the closed kind
|
|
208
|
+
* set — the inverse the migration and the live write path both name Sources
|
|
209
|
+
* through. The tag formats are exactly what the writers produce:
|
|
210
|
+
* corpus:conceptnet /r/IsA → { kind:"corpus", name:"conceptnet" }
|
|
211
|
+
* ace:chat:<session>@<ts> → { kind:"operator", createdAt:<ts> }
|
|
212
|
+
* web:<url> | url:<url> → { kind:"web", url:<url> }
|
|
213
|
+
* entailed:<rule> → { kind:"entailed", rule:<rule> }
|
|
214
|
+
* chat:/session: refs map to the operator; an unknown tag → null (no Source).
|
|
215
|
+
*/
|
|
216
|
+
export function provenanceTagToSource(tag) {
|
|
217
|
+
const t = String(tag || "").trim();
|
|
218
|
+
if (!t) return null;
|
|
219
|
+
const head = t.split(/\s+/)[0]; // drop trailing " /r/IsA" etc.
|
|
220
|
+
if (head.startsWith("corpus:")) return { kind: "corpus", name: head.slice("corpus:".length) || "unknown" };
|
|
221
|
+
if (head.startsWith("ace:")) {
|
|
222
|
+
const at = head.indexOf("@");
|
|
223
|
+
return { kind: "operator", createdAt: at >= 0 ? head.slice(at + 1) : "" };
|
|
224
|
+
}
|
|
225
|
+
if (head.startsWith("web:")) return { kind: "web", url: head.slice("web:".length) };
|
|
226
|
+
if (head.startsWith("url:")) return { kind: "web", url: head.slice("url:".length) };
|
|
227
|
+
if (head.startsWith("entailed:")) return { kind: "entailed", rule: head.slice("entailed:".length) };
|
|
228
|
+
if (head.startsWith("chat:") || head.startsWith("session:") || head.startsWith("operator")) return { kind: "operator" };
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Map a payload's Source individuals into the { id: Source } shape computeTrust
|
|
233
|
+
* resolves against. */
|
|
234
|
+
function sourcesByIdMap(payload) {
|
|
235
|
+
const m = {};
|
|
236
|
+
for (const i of payload.individuals) if (i?.class === SOURCE_CLASS) m[i.id] = i;
|
|
237
|
+
return m;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** The Source ids a Fact is statedBy, read off the edge group. */
|
|
241
|
+
function statedByObjectsFor(payload, factId) {
|
|
242
|
+
const g = payload.objectProperties.find((x) => x?.prop === STATED_BY_PROP);
|
|
243
|
+
return (g?.examples || []).filter((e) => e?.subject === factId).map((e) => e.object);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Recompute + materialise a Fact's trust cache (mgx:trustScore + the auditable
|
|
247
|
+
* mgx:trustInputs). Called exactly where a statedBy edge could have changed. */
|
|
248
|
+
function recomputeFactTrust(payload, fact, nowMs = Date.now()) {
|
|
249
|
+
const sourceIds = statedByObjectsFor(payload, fact.id);
|
|
250
|
+
const createdAt = (fact.attributes || []).find((a) => a?.prop === CREATED_AT_PROP)?.value || "";
|
|
251
|
+
const { score, inputs } = computeTrust({ sourceIds, createdAt }, sourcesByIdMap(payload), { now: nowMs });
|
|
252
|
+
setAttr(fact, TRUST_SCORE_PROP, "trustScore", String(score));
|
|
253
|
+
setAttr(fact, TRUST_INPUTS_PROP, "trustInputs", JSON.stringify(inputs));
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Reconcile a Fact's Sources + statedBy edges with its (unchanged, compat)
|
|
257
|
+
* mgx:factProvenance string, then recompute its trust. ADD-only over
|
|
258
|
+
* deterministic Source ids and upsertEdge's subject>object dedupe, so it is
|
|
259
|
+
* idempotent and NEVER re-keys the fact (its id still hashes only (s,p,o)). */
|
|
260
|
+
function syncFactSources(payload, fact, nowMs = Date.now()) {
|
|
261
|
+
const prov = (fact.attributes || []).find((a) => a?.prop === "mgx:factProvenance")?.value || "";
|
|
262
|
+
// a Source's createdAt candidate is the FIRST stating fact's createdAt (its
|
|
263
|
+
// "first seen"), falling back to now — first-write-wins keeps the earliest.
|
|
264
|
+
const factCreated = (fact.attributes || []).find((a) => a?.prop === CREATED_AT_PROP)?.value || new Date(nowMs).toISOString();
|
|
265
|
+
for (const tag of prov.split(" | ").filter(Boolean)) {
|
|
266
|
+
const desc = provenanceTagToSource(tag);
|
|
267
|
+
if (!desc) continue;
|
|
268
|
+
const sid = upsertSource(payload, desc, factCreated);
|
|
269
|
+
if (!sid) continue;
|
|
270
|
+
upsertEdge(payload, { predicate: "statedBy", prop: STATED_BY_PROP }, {
|
|
271
|
+
subject: fact.id, object: sid, subjectLabel: fact.label, objectLabel: sourceLabel(sid),
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
recomputeFactTrust(payload, fact, nowMs);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Lazy, idempotent migration of the legacy provenance union (step (b)): any
|
|
278
|
+
* Fact that carries the string but has NO statedBy edge yet gets its Sources +
|
|
279
|
+
* edges + trust materialised. The string is KEPT as a compat shim (readers on
|
|
280
|
+
* chat.mjs still key on it). New writes stay reconciled via syncFactSources, so
|
|
281
|
+
* in steady state this scan finds nothing and converges. */
|
|
282
|
+
function migrateLegacyProvenance(payload) {
|
|
283
|
+
if (!Array.isArray(payload?.individuals) || !Array.isArray(payload?.objectProperties)) return;
|
|
284
|
+
const statedGroup = payload.objectProperties.find((g) => g?.prop === STATED_BY_PROP);
|
|
285
|
+
const haveEdge = new Set((statedGroup?.examples || []).map((e) => e.subject));
|
|
286
|
+
let changed = false;
|
|
287
|
+
const now = Date.now();
|
|
288
|
+
for (const ind of payload.individuals) {
|
|
289
|
+
if (ind?.class !== FACT_CLASS) continue;
|
|
290
|
+
if (haveEdge.has(ind.id)) continue; // already reconciled (live path or prior run)
|
|
291
|
+
const prov = (ind.attributes || []).find((a) => a?.prop === "mgx:factProvenance")?.value || "";
|
|
292
|
+
if (!prov) continue;
|
|
293
|
+
syncFactSources(payload, ind, now);
|
|
294
|
+
changed = true;
|
|
295
|
+
}
|
|
296
|
+
if (changed) recountClasses(payload);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** Upsert an individual by id (replace-in-place keeps ordering stable). */
|
|
300
|
+
function upsertIndividual(payload, ind) {
|
|
301
|
+
const i = payload.individuals.findIndex((x) => x?.id === ind.id);
|
|
302
|
+
if (i >= 0) payload.individuals[i] = ind;
|
|
303
|
+
else payload.individuals.push(ind);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** Upsert one edge into the named relation group (dedupe by subject>object). */
|
|
307
|
+
function upsertEdge(payload, { predicate, prop }, edge) {
|
|
308
|
+
let group = payload.objectProperties.find((g) => g?.prop === prop);
|
|
309
|
+
if (!group) {
|
|
310
|
+
group = { predicate, prop, count: 0, examples: [] };
|
|
311
|
+
payload.objectProperties.push(group);
|
|
312
|
+
}
|
|
313
|
+
group.examples = (group.examples || []).filter(
|
|
314
|
+
(e) => !(e?.subject === edge.subject && e?.object === edge.object),
|
|
315
|
+
);
|
|
316
|
+
group.examples.push(edge);
|
|
317
|
+
group.count = group.examples.length;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** Recount `classes[]` from the individuals — every memory class stays counted
|
|
321
|
+
* and sampled the way graph-build.mjs counts the code classes. */
|
|
322
|
+
function recountClasses(payload) {
|
|
323
|
+
const names = [MEMORY_SESSION_CLASS, UTTERANCE_CLASS, FACT_CLASS, SOURCE_CLASS];
|
|
324
|
+
payload.classes = payload.classes.filter((c) => !names.includes(c?.name));
|
|
325
|
+
for (const name of names) {
|
|
326
|
+
const of = payload.individuals.filter((i) => i?.class === name);
|
|
327
|
+
if (of.length) payload.classes.push({ name, count: of.length, sample: of.slice(0, 3).map((i) => i.label) });
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** Make sure the Session anchor individual exists (edges never dangle). */
|
|
332
|
+
function ensureSession(payload, sessionId, started = "") {
|
|
333
|
+
const sid = `session:${sessionId}`;
|
|
334
|
+
if (payload.individuals.some((i) => i?.id === sid)) return sid;
|
|
335
|
+
payload.individuals.push({
|
|
336
|
+
id: sid, label: String(sessionId).slice(0, 8), class: MEMORY_SESSION_CLASS,
|
|
337
|
+
derived_from: [], mentions: [],
|
|
338
|
+
attributes: [
|
|
339
|
+
{ prop: "rdf:type", key: "type", value: "owl:NamedIndividual" },
|
|
340
|
+
{ prop: CREATED_AT_PROP, key: "createdAt", value: started || nowIso() },
|
|
341
|
+
...(started ? [{ prop: "mgx:sessionStarted", key: "started", value: started }] : []),
|
|
342
|
+
],
|
|
343
|
+
});
|
|
344
|
+
return sid;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** Build (don't write) one Utterance individual + its edges; shared by the
|
|
348
|
+
* single and batch append paths. Returns the utterance id. */
|
|
349
|
+
function putUtterance(payload, { role, text, ts, sessionId, sessionStarted = "", parsed = null, replyTo = null, createdAt = "" }) {
|
|
350
|
+
if (!ROLES.has(role)) throw new Error(`utterance role must be "visitor" or "tmct", got ${JSON.stringify(role)}`);
|
|
351
|
+
if (!sessionId) throw new Error("utterance needs a sessionId");
|
|
352
|
+
const cleanTs = String(ts || "");
|
|
353
|
+
const cleanText = normText(text);
|
|
354
|
+
const id = `utt:${sessionId}#${cleanTs}#${role}`;
|
|
355
|
+
const label = labelOf(cleanText) || (role === "visitor" ? "a-visitor-said" : "a-tmct-said");
|
|
356
|
+
const tokens = proseTokensFor({ doc: cleanText });
|
|
357
|
+
const prior = payload.individuals.find((x) => x?.id === id);
|
|
358
|
+
const createdAtVal = firstWriteCreatedAt(prior, createdAt || cleanTs); // first-write-wins
|
|
359
|
+
const ind = {
|
|
360
|
+
id, label, class: UTTERANCE_CLASS,
|
|
361
|
+
derived_from: [], mentions: [],
|
|
362
|
+
attributes: [
|
|
363
|
+
{ prop: "rdf:type", key: "type", value: "owl:NamedIndividual" },
|
|
364
|
+
{ prop: "mgx:utteranceRole", key: "role", value: role },
|
|
365
|
+
{ prop: "mgx:utteranceText", key: "text", value: cleanText },
|
|
366
|
+
{ prop: "mgx:utteranceTs", key: "ts", value: cleanTs },
|
|
367
|
+
{ prop: CREATED_AT_PROP, key: "createdAt", value: createdAtVal },
|
|
368
|
+
...(parsed != null ? [{ prop: "mgx:utteranceParsed", key: "parsed", value: JSON.stringify(parsed) }] : []),
|
|
369
|
+
...(tokens.length ? [{ prop: "mgx:hasProseTokens", key: "prose_tokens", value: tokens.join(" ") }] : []),
|
|
370
|
+
],
|
|
371
|
+
};
|
|
372
|
+
upsertIndividual(payload, ind);
|
|
373
|
+
const sid = ensureSession(payload, sessionId, sessionStarted);
|
|
374
|
+
upsertEdge(payload, { predicate: "saidInSession", prop: SAID_IN_SESSION_PROP }, {
|
|
375
|
+
subject: id, object: sid, subjectLabel: label, objectLabel: String(sessionId).slice(0, 8),
|
|
376
|
+
});
|
|
377
|
+
if (replyTo) {
|
|
378
|
+
const target = payload.individuals.find((i) => i?.id === replyTo);
|
|
379
|
+
if (target) { // never a dangling reply edge — honest drop, like sessions.mjs
|
|
380
|
+
upsertEdge(payload, { predicate: "inReplyTo", prop: IN_REPLY_TO_PROP }, {
|
|
381
|
+
subject: id, object: replyTo, subjectLabel: label, objectLabel: target.label,
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
if (cleanTs && cleanTs > String(payload.generated_at || "")) payload.generated_at = cleanTs;
|
|
386
|
+
return id;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** Append ONE utterance (visitor request or tmct response) to the memory graph.
|
|
390
|
+
* { role, text, ts, sessionId, sessionStarted?, parsed?, replyTo? } — `parsed`
|
|
391
|
+
* is the interpretation pipeline's parse of the request (stored as JSON);
|
|
392
|
+
* `replyTo` a prior utterance id (Q/A pairing). Deterministic id → idempotent.
|
|
393
|
+
* Returns { id }. */
|
|
394
|
+
export async function appendUtterance(dir, utterance) {
|
|
395
|
+
let id;
|
|
396
|
+
await mutateMemory(dir, (payload) => {
|
|
397
|
+
id = putUtterance(payload, utterance);
|
|
398
|
+
recountClasses(payload);
|
|
399
|
+
});
|
|
400
|
+
return { id };
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/** Batch append — ONE read-modify-write for a whole turn (or session) worth of
|
|
404
|
+
* utterances; what sessions.mjs's per-turn wiring calls. Returns { ids }. */
|
|
405
|
+
export async function appendUtterances(dir, utterances) {
|
|
406
|
+
const ids = [];
|
|
407
|
+
if (!utterances?.length) return { ids };
|
|
408
|
+
await mutateMemory(dir, (payload) => {
|
|
409
|
+
for (const u of utterances) ids.push(putUtterance(payload, u));
|
|
410
|
+
recountClasses(payload);
|
|
411
|
+
});
|
|
412
|
+
return { ids };
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/** Normalize a fact TERM (subject/object) so every writer converges on one
|
|
416
|
+
* spelling and the graph stays queryable: ConceptNet's /c/en/foo_bar, a
|
|
417
|
+
* grammar's tmct:Foo_bar and a bare "Foo bar" all become "foo bar". The
|
|
418
|
+
* PREDICATE is deliberately NOT normalized this way - it is a controlled
|
|
419
|
+
* vocabulary term (rdfs:subClassOf) whose casing is meaningful. */
|
|
420
|
+
export function normFactTerm(t) {
|
|
421
|
+
let s = normText(t);
|
|
422
|
+
s = s.replace(/^\/c\/[a-z]{2,3}\//i, "");
|
|
423
|
+
s = s.replace(/^[a-z][\w.-]*:/i, "");
|
|
424
|
+
s = s.replace(/_/g, " ").replace(/\s+/g, " ").trim();
|
|
425
|
+
return s.toLowerCase();
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/** Append one grammar-derived OWL triple, RDF-reified: a `Fact` individual
|
|
429
|
+
* carrying rdf:subject / rdf:predicate / rdf:object (+ provenance). The
|
|
430
|
+
* Phase-2 ACE parser's write point. Same (s,p,o) → same id → upsert, never a
|
|
431
|
+
* duplicate. Returns { id }. */
|
|
432
|
+
export async function appendFact(dir, { subject, predicate, object, provenance = "", createdAt = "" } = {}) {
|
|
433
|
+
const s = normFactTerm(subject);
|
|
434
|
+
const p = normText(predicate);
|
|
435
|
+
const o = normFactTerm(object);
|
|
436
|
+
if (!s || !p || !o) throw new Error("a fact needs subject, predicate and object");
|
|
437
|
+
const id = `fact:${fnv1aHex(`${s}${p}${o}`)}`;
|
|
438
|
+
const text = `${s} ${p} ${o}`;
|
|
439
|
+
const tokens = proseTokensFor({ doc: text });
|
|
440
|
+
await mutateMemory(dir, (payload) => {
|
|
441
|
+
const prior = payload.individuals.find((x) => x?.id === id);
|
|
442
|
+
const priorProv = prior?.attributes?.find((a) => a?.prop === "mgx:factProvenance")?.value || "";
|
|
443
|
+
// The mgx:factProvenance union stays BYTE-IDENTICAL (a compat shim readers
|
|
444
|
+
// still key on); the Source edges below are DERIVED from it, purely additive.
|
|
445
|
+
const provs = [...new Set([...priorProv.split(" | "), normText(provenance)].filter(Boolean))];
|
|
446
|
+
const createdAtVal = firstWriteCreatedAt(prior, createdAt); // first-write-wins
|
|
447
|
+
upsertIndividual(payload, {
|
|
448
|
+
id, label: labelOf(text), class: FACT_CLASS,
|
|
449
|
+
derived_from: [], mentions: [],
|
|
450
|
+
attributes: [
|
|
451
|
+
{ prop: "rdf:type", key: "type", value: "rdf:Statement" },
|
|
452
|
+
{ prop: "rdf:subject", key: "subject", value: s },
|
|
453
|
+
{ prop: "rdf:predicate", key: "predicate", value: p },
|
|
454
|
+
{ prop: "rdf:object", key: "object", value: o },
|
|
455
|
+
{ prop: CREATED_AT_PROP, key: "createdAt", value: createdAtVal },
|
|
456
|
+
...(provs.length ? [{ prop: "mgx:factProvenance", key: "provenance", value: provs.join(" | ") }] : []),
|
|
457
|
+
...(tokens.length ? [{ prop: "mgx:hasProseTokens", key: "prose_tokens", value: tokens.join(" ") }] : []),
|
|
458
|
+
],
|
|
459
|
+
});
|
|
460
|
+
// Derive Source individuals + statedBy edges from the provenance union and
|
|
461
|
+
// (re)materialise this fact's trust — the live half of steps (b)/(c).
|
|
462
|
+
syncFactSources(payload, payload.individuals.find((x) => x?.id === id));
|
|
463
|
+
recountClasses(payload);
|
|
464
|
+
});
|
|
465
|
+
return { id };
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// ---- Chat-facing seams (W4 fact lookup + contradiction) ---------------------
|
|
469
|
+
// The W4 fact-lookup THREADING lives in chat.mjs (NOT here); these pure readers
|
|
470
|
+
// are the seam it calls so the answer layer ranks candidates by relevance ×
|
|
471
|
+
// trust and cites provenance WITHOUT re-walking the graph shape.
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* Resolve every reified Fact in a loaded memory payload into a row carrying its
|
|
475
|
+
* Source ids + source-type multiset, the legacy provenance string (compat), and
|
|
476
|
+
* the cached trust score. Pure. The exported seam the chat/answer layer consumes
|
|
477
|
+
* for trust-weighted fact ranking.
|
|
478
|
+
*/
|
|
479
|
+
export function readFactRows(memory) {
|
|
480
|
+
const individuals = memory?.individuals || [];
|
|
481
|
+
const sourcesById = new Map(individuals.filter((i) => i?.class === SOURCE_CLASS).map((i) => [i.id, i]));
|
|
482
|
+
const statedGroup = (memory?.objectProperties || []).find((g) => g?.prop === STATED_BY_PROP);
|
|
483
|
+
const byFact = new Map();
|
|
484
|
+
for (const e of statedGroup?.examples || []) {
|
|
485
|
+
if (!byFact.has(e.subject)) byFact.set(e.subject, []);
|
|
486
|
+
byFact.get(e.subject).push(e.object);
|
|
487
|
+
}
|
|
488
|
+
const rows = [];
|
|
489
|
+
for (const ind of individuals) {
|
|
490
|
+
if (ind?.class !== FACT_CLASS) continue;
|
|
491
|
+
const get = (k) => (ind.attributes || []).find((a) => a?.key === k)?.value || "";
|
|
492
|
+
const sourceIds = byFact.get(ind.id) || [];
|
|
493
|
+
const sourceTypes = sourceIds
|
|
494
|
+
.map((id) => (sourcesById.get(id)?.attributes || []).find((a) => a?.prop === "mgx:sourceType")?.value)
|
|
495
|
+
.filter(Boolean);
|
|
496
|
+
rows.push({
|
|
497
|
+
id: ind.id,
|
|
498
|
+
subject: get("subject"), predicate: get("predicate"), object: get("object"),
|
|
499
|
+
provenance: get("provenance"), // legacy compat string, verbatim
|
|
500
|
+
sourceIds, sourceTypes,
|
|
501
|
+
trust: Number((ind.attributes || []).find((a) => a?.prop === TRUST_SCORE_PROP)?.value) || 0,
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
return rows;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/** The trust floor a fact must clear before a differing object counts as a real
|
|
508
|
+
* contradiction (below it the fact is too weak to contradict anything). */
|
|
509
|
+
export const CONTRADICTION_TRUST_FLOOR = 0.5;
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* Facts that CONTRADICT: same (subject, predicate), DIFFERENT object, each above
|
|
513
|
+
* the trust floor. Returns groups (each a [rows] sorted by trust desc) so the
|
|
514
|
+
* answer/inspection layer surfaces BOTH with their provenance and NEVER silently
|
|
515
|
+
* picks the higher-trust one. Same (s,p,o) from two writers is corroboration,
|
|
516
|
+
* not contradiction — one Fact id, N statedBy edges — so it never appears here.
|
|
517
|
+
*/
|
|
518
|
+
export function findContradictions(memory, { floor = CONTRADICTION_TRUST_FLOOR } = {}) {
|
|
519
|
+
const rows = readFactRows(memory).filter((r) => r.trust >= floor);
|
|
520
|
+
const byKey = new Map();
|
|
521
|
+
for (const r of rows) {
|
|
522
|
+
const key = `${r.subject} ${r.predicate}`;
|
|
523
|
+
if (!byKey.has(key)) byKey.set(key, []);
|
|
524
|
+
byKey.get(key).push(r);
|
|
525
|
+
}
|
|
526
|
+
const out = [];
|
|
527
|
+
for (const group of byKey.values()) {
|
|
528
|
+
if (new Set(group.map((r) => r.object)).size > 1) {
|
|
529
|
+
out.push(group.slice().sort((a, b) => b.trust - a.trust || a.object.localeCompare(b.object)));
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
return out.sort((a, b) => `${a[0].subject} ${a[0].predicate}`.localeCompare(`${b[0].subject} ${b[0].predicate}`));
|
|
533
|
+
}
|
|
Binary file
|