@polycode-projects/the-mechanical-code-talker 0.2.0 → 0.3.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/ROADMAP.md +5 -2
- package/bin/tmct.mjs +253 -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/responses.jsonl +55 -0
- package/package.json +12 -3
- package/src/ask-nlp.mjs +14 -0
- package/src/ask-vocab.mjs +13 -1
- package/src/ask.mjs +92 -493
- package/src/chat.mjs +147 -45
- package/src/corpus/conceptnet-map.toml +251 -0
- package/src/corpus/conceptnet.mjs +155 -0
- package/src/corpus/templates.mjs +104 -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/index.mjs +21 -5
- package/src/interpret/fuzzy.mjs +89 -0
- package/src/interpret/merge.mjs +148 -0
- package/src/interpret/normalize.mjs +117 -0
- package/src/interpret/pipeline.mjs +112 -0
- package/src/interpret/strategies/grammar.mjs +137 -0
- package/src/interpret/strategies/keywords.mjs +185 -0
- package/src/interpret/strategies/noise-strip.mjs +114 -0
- package/src/memory/blocks.mjs +201 -0
- package/src/memory/core.mjs +292 -0
- package/src/memory/fold.mjs +105 -0
- package/src/sessions.mjs +125 -3
- package/src/source.mjs +44 -5
- package/src/tui/app.mjs +173 -0
- package/bin/cli.mjs +0 -226
|
@@ -0,0 +1,292 @@
|
|
|
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
|
+
|
|
35
|
+
export const MEMORY_DIR_REL = join(".tmct", "memory");
|
|
36
|
+
export const MEMORY_GRAPH_REL = join(MEMORY_DIR_REL, "graph.json");
|
|
37
|
+
|
|
38
|
+
export const UTTERANCE_CLASS = "Utterance";
|
|
39
|
+
export const FACT_CLASS = "Fact";
|
|
40
|
+
export const MEMORY_SESSION_CLASS = "Session";
|
|
41
|
+
|
|
42
|
+
export const SAID_IN_SESSION_PROP = "mgx:saidInSession";
|
|
43
|
+
export const IN_REPLY_TO_PROP = "mgx:inReplyTo";
|
|
44
|
+
|
|
45
|
+
const ROLES = new Set(["visitor", "tmct"]);
|
|
46
|
+
const LABEL_CAP = 48; // utterance/fact labels stay skimmable in renders
|
|
47
|
+
const TEXT_CAP = 2000; // an utterance's stored text (a whole answer fits; a pasted book doesn't)
|
|
48
|
+
|
|
49
|
+
/** The memory graph's vocabulary — documented in-payload exactly like
|
|
50
|
+
* graph-build.mjs documents the code graph's. */
|
|
51
|
+
const MEMORY_VOCABULARY = [
|
|
52
|
+
{ prop: "rdf:type", note: "rdf-ish typing attribute: owl:NamedIndividual (utterances/sessions) or rdf:Statement (reified facts)" },
|
|
53
|
+
{ prop: "mgx:utteranceRole", note: "who said it: visitor (an a-visitor-said item) or tmct (the response alongside it)" },
|
|
54
|
+
{ prop: "mgx:utteranceText", note: "the utterance's normalized text (capped)" },
|
|
55
|
+
{ prop: "mgx:utteranceTs", note: "when it was said, ISO-8601 (the chat turn timestamp)" },
|
|
56
|
+
{ prop: "mgx:utteranceParsed", note: "optional JSON of the parse the interpretation pipeline produced for this request" },
|
|
57
|
+
{ prop: SAID_IN_SESSION_PROP, predicate: "saidInSession", note: "Utterance → Session it was said in; runtime observation, owned (no SEON term)" },
|
|
58
|
+
{ prop: IN_REPLY_TO_PROP, predicate: "inReplyTo", note: "tmct Utterance → the visitor Utterance it answers (the Q/A pairing)" },
|
|
59
|
+
{ prop: "rdf:subject", note: "reified fact: the triple's subject term" },
|
|
60
|
+
{ prop: "rdf:predicate", note: "reified fact: the triple's predicate term" },
|
|
61
|
+
{ prop: "rdf:object", note: "reified fact: the triple's object term" },
|
|
62
|
+
{ prop: "mgx:factProvenance", note: "where a fact came from (a session/turn ref, a corpus block id, an ACE parse)" },
|
|
63
|
+
{ prop: "mgx:hasProseTokens", note: "prose tokens (prose.mjs tokenizer) backing the payload's proseIndex" },
|
|
64
|
+
{ prop: "mgx:sessionStarted", note: "session anchor: when the session started, ISO-8601" },
|
|
65
|
+
];
|
|
66
|
+
|
|
67
|
+
/** A fresh, empty memory payload — the buildEntities shape, plus the OWL/RDF
|
|
68
|
+
* prefixes the memory vocabulary uses. `memory: true` marks it as tmct's own
|
|
69
|
+
* store (never a provider artifact). */
|
|
70
|
+
export function emptyMemory() {
|
|
71
|
+
return {
|
|
72
|
+
generated_at: "",
|
|
73
|
+
memory: true,
|
|
74
|
+
prefixes: {
|
|
75
|
+
owl: "http://www.w3.org/2002/07/owl#",
|
|
76
|
+
rdf: "http://www.w3.org/1999/02/22-rdf-syntax-ns#",
|
|
77
|
+
rdfs: "http://www.w3.org/2000/01/rdf-schema#",
|
|
78
|
+
mgx: "urn:tmct:mgx#",
|
|
79
|
+
},
|
|
80
|
+
vocabulary: MEMORY_VOCABULARY.map((v) => ({ ...v })),
|
|
81
|
+
classes: [],
|
|
82
|
+
objectProperties: [],
|
|
83
|
+
individuals: [],
|
|
84
|
+
proseIndex: {},
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const memoryGraphFile = (dir) => join(dir, MEMORY_GRAPH_REL);
|
|
89
|
+
|
|
90
|
+
/** Atomic JSON write (temp in the same dir + rename) — same discipline as
|
|
91
|
+
* sessions.mjs's graph append: a crash never destroys the previous store. */
|
|
92
|
+
async function atomicWriteJson(file, obj) {
|
|
93
|
+
const tmp = `${file}.tmp-${process.pid}-${Math.random().toString(36).slice(2, 10)}`;
|
|
94
|
+
await writeFile(tmp, JSON.stringify(obj));
|
|
95
|
+
await rename(tmp, file);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Load the memory graph for a repo dir. A missing store is the bootstrap:
|
|
99
|
+
* return the empty payload (uncached — the first append creates the file).
|
|
100
|
+
* The result is a raw entities payload; parseEntities() loads it. */
|
|
101
|
+
export async function loadMemory(dir) {
|
|
102
|
+
let text;
|
|
103
|
+
try {
|
|
104
|
+
text = await readFile(memoryGraphFile(dir), "utf8");
|
|
105
|
+
} catch (e) {
|
|
106
|
+
if (e?.code === "ENOENT") return emptyMemory();
|
|
107
|
+
throw e;
|
|
108
|
+
}
|
|
109
|
+
return JSON.parse(text);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Fresh read → mutate → atomic write. Serialized per call; every public append
|
|
113
|
+
* goes through here so a concurrent reader never sees a torn store. */
|
|
114
|
+
async function mutateMemory(dir, fn) {
|
|
115
|
+
const payload = await loadMemory(dir);
|
|
116
|
+
const out = fn(payload) ?? payload;
|
|
117
|
+
out.proseIndex = buildProseIndex(out.individuals);
|
|
118
|
+
await mkdir(dirname(memoryGraphFile(dir)), { recursive: true });
|
|
119
|
+
await atomicWriteJson(memoryGraphFile(dir), out);
|
|
120
|
+
return out;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const normText = (t) => String(t ?? "").replace(/\s+/g, " ").trim().slice(0, TEXT_CAP);
|
|
124
|
+
const labelOf = (text) => (text.length > LABEL_CAP ? text.slice(0, LABEL_CAP - 1) + "…" : text);
|
|
125
|
+
|
|
126
|
+
/** Upsert an individual by id (replace-in-place keeps ordering stable). */
|
|
127
|
+
function upsertIndividual(payload, ind) {
|
|
128
|
+
const i = payload.individuals.findIndex((x) => x?.id === ind.id);
|
|
129
|
+
if (i >= 0) payload.individuals[i] = ind;
|
|
130
|
+
else payload.individuals.push(ind);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Upsert one edge into the named relation group (dedupe by subject>object). */
|
|
134
|
+
function upsertEdge(payload, { predicate, prop }, edge) {
|
|
135
|
+
let group = payload.objectProperties.find((g) => g?.prop === prop);
|
|
136
|
+
if (!group) {
|
|
137
|
+
group = { predicate, prop, count: 0, examples: [] };
|
|
138
|
+
payload.objectProperties.push(group);
|
|
139
|
+
}
|
|
140
|
+
group.examples = (group.examples || []).filter(
|
|
141
|
+
(e) => !(e?.subject === edge.subject && e?.object === edge.object),
|
|
142
|
+
);
|
|
143
|
+
group.examples.push(edge);
|
|
144
|
+
group.count = group.examples.length;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Recount `classes[]` from the individuals — every memory class stays counted
|
|
148
|
+
* and sampled the way graph-build.mjs counts the code classes. */
|
|
149
|
+
function recountClasses(payload) {
|
|
150
|
+
const names = [MEMORY_SESSION_CLASS, UTTERANCE_CLASS, FACT_CLASS];
|
|
151
|
+
payload.classes = payload.classes.filter((c) => !names.includes(c?.name));
|
|
152
|
+
for (const name of names) {
|
|
153
|
+
const of = payload.individuals.filter((i) => i?.class === name);
|
|
154
|
+
if (of.length) payload.classes.push({ name, count: of.length, sample: of.slice(0, 3).map((i) => i.label) });
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Make sure the Session anchor individual exists (edges never dangle). */
|
|
159
|
+
function ensureSession(payload, sessionId, started = "") {
|
|
160
|
+
const sid = `session:${sessionId}`;
|
|
161
|
+
if (payload.individuals.some((i) => i?.id === sid)) return sid;
|
|
162
|
+
payload.individuals.push({
|
|
163
|
+
id: sid, label: String(sessionId).slice(0, 8), class: MEMORY_SESSION_CLASS,
|
|
164
|
+
derived_from: [], mentions: [],
|
|
165
|
+
attributes: [
|
|
166
|
+
{ prop: "rdf:type", key: "type", value: "owl:NamedIndividual" },
|
|
167
|
+
...(started ? [{ prop: "mgx:sessionStarted", key: "started", value: started }] : []),
|
|
168
|
+
],
|
|
169
|
+
});
|
|
170
|
+
return sid;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Build (don't write) one Utterance individual + its edges; shared by the
|
|
174
|
+
* single and batch append paths. Returns the utterance id. */
|
|
175
|
+
function putUtterance(payload, { role, text, ts, sessionId, sessionStarted = "", parsed = null, replyTo = null }) {
|
|
176
|
+
if (!ROLES.has(role)) throw new Error(`utterance role must be "visitor" or "tmct", got ${JSON.stringify(role)}`);
|
|
177
|
+
if (!sessionId) throw new Error("utterance needs a sessionId");
|
|
178
|
+
const cleanTs = String(ts || "");
|
|
179
|
+
const cleanText = normText(text);
|
|
180
|
+
const id = `utt:${sessionId}#${cleanTs}#${role}`;
|
|
181
|
+
const label = labelOf(cleanText) || (role === "visitor" ? "a-visitor-said" : "a-tmct-said");
|
|
182
|
+
const tokens = proseTokensFor({ doc: cleanText });
|
|
183
|
+
const ind = {
|
|
184
|
+
id, label, class: UTTERANCE_CLASS,
|
|
185
|
+
derived_from: [], mentions: [],
|
|
186
|
+
attributes: [
|
|
187
|
+
{ prop: "rdf:type", key: "type", value: "owl:NamedIndividual" },
|
|
188
|
+
{ prop: "mgx:utteranceRole", key: "role", value: role },
|
|
189
|
+
{ prop: "mgx:utteranceText", key: "text", value: cleanText },
|
|
190
|
+
{ prop: "mgx:utteranceTs", key: "ts", value: cleanTs },
|
|
191
|
+
...(parsed != null ? [{ prop: "mgx:utteranceParsed", key: "parsed", value: JSON.stringify(parsed) }] : []),
|
|
192
|
+
...(tokens.length ? [{ prop: "mgx:hasProseTokens", key: "prose_tokens", value: tokens.join(" ") }] : []),
|
|
193
|
+
],
|
|
194
|
+
};
|
|
195
|
+
upsertIndividual(payload, ind);
|
|
196
|
+
const sid = ensureSession(payload, sessionId, sessionStarted);
|
|
197
|
+
upsertEdge(payload, { predicate: "saidInSession", prop: SAID_IN_SESSION_PROP }, {
|
|
198
|
+
subject: id, object: sid, subjectLabel: label, objectLabel: String(sessionId).slice(0, 8),
|
|
199
|
+
});
|
|
200
|
+
if (replyTo) {
|
|
201
|
+
const target = payload.individuals.find((i) => i?.id === replyTo);
|
|
202
|
+
if (target) { // never a dangling reply edge — honest drop, like sessions.mjs
|
|
203
|
+
upsertEdge(payload, { predicate: "inReplyTo", prop: IN_REPLY_TO_PROP }, {
|
|
204
|
+
subject: id, object: replyTo, subjectLabel: label, objectLabel: target.label,
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
if (cleanTs && cleanTs > String(payload.generated_at || "")) payload.generated_at = cleanTs;
|
|
209
|
+
return id;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Append ONE utterance (visitor request or tmct response) to the memory graph.
|
|
213
|
+
* { role, text, ts, sessionId, sessionStarted?, parsed?, replyTo? } — `parsed`
|
|
214
|
+
* is the interpretation pipeline's parse of the request (stored as JSON);
|
|
215
|
+
* `replyTo` a prior utterance id (Q/A pairing). Deterministic id → idempotent.
|
|
216
|
+
* Returns { id }. */
|
|
217
|
+
export async function appendUtterance(dir, utterance) {
|
|
218
|
+
let id;
|
|
219
|
+
await mutateMemory(dir, (payload) => {
|
|
220
|
+
id = putUtterance(payload, utterance);
|
|
221
|
+
recountClasses(payload);
|
|
222
|
+
});
|
|
223
|
+
return { id };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Batch append — ONE read-modify-write for a whole turn (or session) worth of
|
|
227
|
+
* utterances; what sessions.mjs's per-turn wiring calls. Returns { ids }. */
|
|
228
|
+
export async function appendUtterances(dir, utterances) {
|
|
229
|
+
const ids = [];
|
|
230
|
+
if (!utterances?.length) return { ids };
|
|
231
|
+
await mutateMemory(dir, (payload) => {
|
|
232
|
+
for (const u of utterances) ids.push(putUtterance(payload, u));
|
|
233
|
+
recountClasses(payload);
|
|
234
|
+
});
|
|
235
|
+
return { ids };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** FNV-1a 32-bit — a stable little content hash for fact ids (dedupe by triple). */
|
|
239
|
+
function fnv1a(s) {
|
|
240
|
+
let h = 0x811c9dc5;
|
|
241
|
+
for (let i = 0; i < s.length; i += 1) {
|
|
242
|
+
h ^= s.charCodeAt(i);
|
|
243
|
+
h = Math.imul(h, 0x01000193);
|
|
244
|
+
}
|
|
245
|
+
return (h >>> 0).toString(16).padStart(8, "0");
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Normalize a fact TERM (subject/object) so every writer converges on one
|
|
249
|
+
* spelling and the graph stays queryable: ConceptNet's /c/en/foo_bar, a
|
|
250
|
+
* grammar's tmct:Foo_bar and a bare "Foo bar" all become "foo bar". The
|
|
251
|
+
* PREDICATE is deliberately NOT normalized this way - it is a controlled
|
|
252
|
+
* vocabulary term (rdfs:subClassOf) whose casing is meaningful. */
|
|
253
|
+
export function normFactTerm(t) {
|
|
254
|
+
let s = normText(t);
|
|
255
|
+
s = s.replace(/^\/c\/[a-z]{2,3}\//i, "");
|
|
256
|
+
s = s.replace(/^[a-z][\w.-]*:/i, "");
|
|
257
|
+
s = s.replace(/_/g, " ").replace(/\s+/g, " ").trim();
|
|
258
|
+
return s.toLowerCase();
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** Append one grammar-derived OWL triple, RDF-reified: a `Fact` individual
|
|
262
|
+
* carrying rdf:subject / rdf:predicate / rdf:object (+ provenance). The
|
|
263
|
+
* Phase-2 ACE parser's write point. Same (s,p,o) → same id → upsert, never a
|
|
264
|
+
* duplicate. Returns { id }. */
|
|
265
|
+
export async function appendFact(dir, { subject, predicate, object, provenance = "" } = {}) {
|
|
266
|
+
const s = normFactTerm(subject);
|
|
267
|
+
const p = normText(predicate);
|
|
268
|
+
const o = normFactTerm(object);
|
|
269
|
+
if (!s || !p || !o) throw new Error("a fact needs subject, predicate and object");
|
|
270
|
+
const id = `fact:${fnv1a(`${s}${p}${o}`)}`;
|
|
271
|
+
const text = `${s} ${p} ${o}`;
|
|
272
|
+
const tokens = proseTokensFor({ doc: text });
|
|
273
|
+
await mutateMemory(dir, (payload) => {
|
|
274
|
+
const prior = payload.individuals.find((x) => x?.id === id);
|
|
275
|
+
const priorProv = prior?.attributes?.find((a) => a?.prop === "mgx:factProvenance")?.value || "";
|
|
276
|
+
const provs = [...new Set([...priorProv.split(" | "), normText(provenance)].filter(Boolean))];
|
|
277
|
+
upsertIndividual(payload, {
|
|
278
|
+
id, label: labelOf(text), class: FACT_CLASS,
|
|
279
|
+
derived_from: [], mentions: [],
|
|
280
|
+
attributes: [
|
|
281
|
+
{ prop: "rdf:type", key: "type", value: "rdf:Statement" },
|
|
282
|
+
{ prop: "rdf:subject", key: "subject", value: s },
|
|
283
|
+
{ prop: "rdf:predicate", key: "predicate", value: p },
|
|
284
|
+
{ prop: "rdf:object", key: "object", value: o },
|
|
285
|
+
...(provs.length ? [{ prop: "mgx:factProvenance", key: "provenance", value: provs.join(" | ") }] : []),
|
|
286
|
+
...(tokens.length ? [{ prop: "mgx:hasProseTokens", key: "prose_tokens", value: tokens.join(" ") }] : []),
|
|
287
|
+
],
|
|
288
|
+
});
|
|
289
|
+
recountClasses(payload);
|
|
290
|
+
});
|
|
291
|
+
return { id };
|
|
292
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// memory/fold.mjs — session-log cleaning → corpus folding (ROADMAP item 9).
|
|
2
|
+
//
|
|
3
|
+
// A raw chat session is noisy: slash-commands and their dumps, greetings,
|
|
4
|
+
// thanks, honest misses. foldSessionLogs() reads the structured sidecars
|
|
5
|
+
// (.tmct/sessions/*.jsonl), pairs each turn with its answer PROSE from the
|
|
6
|
+
// human transcript (.tmct/session-<id>.log — the only artifact carrying answer
|
|
7
|
+
// text), cleans the turns, and writes ONE text block per session into
|
|
8
|
+
// blocks.mjs's store. Cleaning rules:
|
|
9
|
+
// - slash-command turns (and therefore their outputs) are dropped;
|
|
10
|
+
// - conversational filler is dropped — both turns chat.mjs already flagged
|
|
11
|
+
// `conversational` and unflagged greeting/thanks/bye one-liners;
|
|
12
|
+
// - miss turns are dropped (their answer is the honest-miss boilerplate —
|
|
13
|
+
// the memory GRAPH still records them as utterances; the corpus doesn't);
|
|
14
|
+
// - surviving Q/A pairs are whitespace-normalized into "Q: …\nA: …" lines.
|
|
15
|
+
//
|
|
16
|
+
// Idempotent by construction: the block id IS the session id, and
|
|
17
|
+
// blocks.saveBlock() replaces on the same id — re-folding a session updates
|
|
18
|
+
// its block, never duplicates it. A session that cleans down to nothing writes
|
|
19
|
+
// no block (and removes a stale one), so the corpus never holds empty blocks.
|
|
20
|
+
|
|
21
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
22
|
+
import { join } from "node:path";
|
|
23
|
+
import { SESSIONS_DIR_REL, parseSessionJsonl, parseSessionLog, turnKey } from "../sessions.mjs";
|
|
24
|
+
import { removeBlock, saveBlock } from "./blocks.mjs";
|
|
25
|
+
|
|
26
|
+
// chat.mjs's SESSION_LOG_DIR — repeated here (a one-word constant) rather than
|
|
27
|
+
// importing the whole chat surface into the memory layer.
|
|
28
|
+
const LOG_DIR_REL = ".tmct";
|
|
29
|
+
|
|
30
|
+
/** Conversational filler a transcript doesn't need: greetings, thanks, byes,
|
|
31
|
+
* bare acknowledgements. Catches the one-liners chat.mjs did NOT flag
|
|
32
|
+
* `conversational` (e.g. typed straight at an older tmct). */
|
|
33
|
+
const FILLER_RE = new RegExp(
|
|
34
|
+
"^(?:(?:hi|hiya|hello|hey|yo|howdy)(?:\\s+there)?|good\\s*(?:morning|afternoon|evening)|" +
|
|
35
|
+
"thanks(?:\\s+(?:a\\s+lot|so\\s+much))?|thank\\s*you|thx|ty|cheers|" +
|
|
36
|
+
"ok(?:ay)?|cool|nice|great|sure|yes|no|yep|nope|" +
|
|
37
|
+
"bye(?:\\s+bye)?|goodbye|see\\s*(?:ya|you)|later)[\\s!.?,]*$",
|
|
38
|
+
"i",
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
const squash = (s) => String(s ?? "").replace(/\s+/g, " ").trim();
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Clean one parsed session record into corpus text (pure). `answers` is
|
|
45
|
+
* parseSessionLog()'s Map (may be empty — a vanished transcript degrades to
|
|
46
|
+
* question-only lines, honestly, rather than losing the session).
|
|
47
|
+
* Returns "" when nothing survives.
|
|
48
|
+
*/
|
|
49
|
+
export function cleanSessionText(record, answers = new Map()) {
|
|
50
|
+
const parts = [];
|
|
51
|
+
for (const t of record?.turns || []) {
|
|
52
|
+
const query = squash(t?.query);
|
|
53
|
+
if (!query) continue;
|
|
54
|
+
if (t.command || query.startsWith("/")) continue; // slash-commands + their outputs
|
|
55
|
+
if (t.conversational || FILLER_RE.test(query)) continue; // greetings/thanks/bye
|
|
56
|
+
if (t.miss) continue; // honest-miss boilerplate is not corpus content
|
|
57
|
+
const answer = squash(answers.get(turnKey(t.ts, t.query)));
|
|
58
|
+
parts.push(answer ? `Q: ${query}\nA: ${answer}` : `Q: ${query}`);
|
|
59
|
+
}
|
|
60
|
+
return parts.join("\n");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Fold recorded session logs into the text-block corpus. Reads every
|
|
65
|
+
* .tmct/sessions/*.jsonl sidecar under `repoDir` (or just `sessionId`'s),
|
|
66
|
+
* cleans it, and upserts one block per session (block id = session id).
|
|
67
|
+
* Best-effort per session — an unreadable sidecar or missing transcript never
|
|
68
|
+
* fails the fold. Returns { folded: [ids], removed: [ids], skipped: n }.
|
|
69
|
+
*/
|
|
70
|
+
export async function foldSessionLogs(repoDir, { sessionId = null } = {}) {
|
|
71
|
+
const dir = join(repoDir, SESSIONS_DIR_REL);
|
|
72
|
+
let names;
|
|
73
|
+
try {
|
|
74
|
+
names = (await readdir(dir)).filter((n) => n.endsWith(".jsonl")).sort();
|
|
75
|
+
} catch {
|
|
76
|
+
return { folded: [], removed: [], skipped: 0 };
|
|
77
|
+
}
|
|
78
|
+
if (sessionId) names = names.filter((n) => n === `session-${sessionId}.jsonl`);
|
|
79
|
+
|
|
80
|
+
const folded = [];
|
|
81
|
+
const removed = [];
|
|
82
|
+
let skipped = 0;
|
|
83
|
+
for (const name of names) {
|
|
84
|
+
try {
|
|
85
|
+
const record = parseSessionJsonl(await readFile(join(dir, name), "utf8"));
|
|
86
|
+
if (!record) { skipped += 1; continue; }
|
|
87
|
+
let answers = new Map();
|
|
88
|
+
try {
|
|
89
|
+
answers = parseSessionLog(await readFile(join(repoDir, LOG_DIR_REL, `session-${record.id}.log`), "utf8"));
|
|
90
|
+
} catch { /* transcript gone — fold question-only, honestly */ }
|
|
91
|
+
const text = cleanSessionText(record, answers);
|
|
92
|
+
if (text) {
|
|
93
|
+
await saveBlock(repoDir, { id: record.id, text });
|
|
94
|
+
folded.push(record.id);
|
|
95
|
+
} else if (await removeBlock(repoDir, record.id)) {
|
|
96
|
+
removed.push(record.id); // the session re-cleaned down to nothing — stale block gone
|
|
97
|
+
} else {
|
|
98
|
+
skipped += 1;
|
|
99
|
+
}
|
|
100
|
+
} catch {
|
|
101
|
+
skipped += 1; // one bad session never fails the fold
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return { folded, removed, skipped };
|
|
105
|
+
}
|
package/src/sessions.mjs
CHANGED
|
@@ -24,7 +24,8 @@
|
|
|
24
24
|
// rather than re-deriving them from source.
|
|
25
25
|
|
|
26
26
|
import { mkdir, readFile, readdir, rename, writeFile } from "node:fs/promises";
|
|
27
|
-
import { dirname, join } from "node:path";
|
|
27
|
+
import { basename, dirname, join } from "node:path";
|
|
28
|
+
import { appendUtterances } from "./memory/core.mjs";
|
|
28
29
|
|
|
29
30
|
export const SESSIONS_DIR_REL = join(".tmct", "sessions");
|
|
30
31
|
|
|
@@ -144,8 +145,13 @@ export function upsertSession(entities, record) {
|
|
|
144
145
|
* may have replaced it mid-session), upsert, write back atomically. A MISSING
|
|
145
146
|
* artifact is the empty-graph bootstrap: seed a minimal valid payload so the
|
|
146
147
|
* conversation itself becomes the first graph write. Still throws on an invalid
|
|
147
|
-
* (unparseable) artifact — the caller treats the append as best-effort.
|
|
148
|
-
|
|
148
|
+
* (unparseable) artifact — the caller treats the append as best-effort.
|
|
149
|
+
*
|
|
150
|
+
* Signature stays backward-compatible: chat.mjs passes (graphFile, record)
|
|
151
|
+
* exactly as before. The optional third param only tunes the MEMORY side-write
|
|
152
|
+
* (below): `repoDir` overrides the derived repo root, `memory: false` disables
|
|
153
|
+
* the side-write entirely. */
|
|
154
|
+
export async function appendSessionToGraph(graphFile, record, { memory = true, repoDir = null } = {}) {
|
|
149
155
|
let text = null;
|
|
150
156
|
try {
|
|
151
157
|
text = await readFile(graphFile, "utf8");
|
|
@@ -161,9 +167,82 @@ export async function appendSessionToGraph(graphFile, record) {
|
|
|
161
167
|
}
|
|
162
168
|
const res = upsertSession(entities, record);
|
|
163
169
|
await atomicWriteJson(graphFile, entities);
|
|
170
|
+
// ALSO record the turn(s) into tmct's OWN memory graph (.tmct/memory/ — item 9),
|
|
171
|
+
// and fold the transcript into the text-block corpus once the session has ended.
|
|
172
|
+
// Best-effort by design: memory must never degrade the graph append that already
|
|
173
|
+
// succeeded, so every failure here is swallowed (mirrors chat.mjs's own stance).
|
|
174
|
+
if (memory) {
|
|
175
|
+
try { await recordSessionMemory(graphFile, record, repoDir); } catch { /* best-effort */ }
|
|
176
|
+
}
|
|
164
177
|
return res;
|
|
165
178
|
}
|
|
166
179
|
|
|
180
|
+
/** Derive the repo root the memory store lives under from the graph artifact's
|
|
181
|
+
* location: the default layout is <repo>/.tmct/graph.json. A custom
|
|
182
|
+
* TMCT_GRAPH_FILE outside a .tmct dir has no discoverable repo (and no session
|
|
183
|
+
* transcript/sidecar layout to read), so the memory side-write is skipped —
|
|
184
|
+
* memory writes go ONLY under a real .tmct/, never beside arbitrary files. */
|
|
185
|
+
function repoDirFromGraphFile(graphFile) {
|
|
186
|
+
const tmctDir = dirname(graphFile);
|
|
187
|
+
return basename(tmctDir) === ".tmct" ? dirname(tmctDir) : null;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** The memory side-write for one session append (item 9's chat wiring, placed
|
|
191
|
+
* HERE so chat.mjs needs no change — it already calls appendSessionToGraph
|
|
192
|
+
* every turn). Each recorded turn becomes an a-visitor-said Utterance; the
|
|
193
|
+
* response prose is recovered from the human transcript (the only artifact
|
|
194
|
+
* that carries answer TEXT — the sidecar records ids) and recorded alongside
|
|
195
|
+
* as a tmct Utterance replying to it. Deterministic utterance ids make the
|
|
196
|
+
* per-turn replay idempotent. Once the sidecar carries its end marker (chat
|
|
197
|
+
* writes it before the final graph upsert), the session is folded into the
|
|
198
|
+
* text-block corpus (memory/fold.mjs). */
|
|
199
|
+
async function recordSessionMemory(graphFile, record, repoDirOverride = null) {
|
|
200
|
+
const repoDir = repoDirOverride ?? repoDirFromGraphFile(graphFile);
|
|
201
|
+
if (!repoDir || !record?.id) return;
|
|
202
|
+
|
|
203
|
+
let answers = new Map();
|
|
204
|
+
try {
|
|
205
|
+
answers = parseSessionLog(await readFile(join(repoDir, ".tmct", `session-${record.id}.log`), "utf8"));
|
|
206
|
+
} catch { /* no transcript (direct API callers) — record the requests alone */ }
|
|
207
|
+
|
|
208
|
+
const utterances = [];
|
|
209
|
+
for (const t of record.turns || []) {
|
|
210
|
+
const query = String(t?.query || "");
|
|
211
|
+
const ts = String(t?.ts || "");
|
|
212
|
+
if (!query || !ts) continue;
|
|
213
|
+
// the structured parse the turn produced — stored on the visitor utterance
|
|
214
|
+
const parsed = {};
|
|
215
|
+
if (t.resolvedIds?.length) parsed.resolvedIds = t.resolvedIds;
|
|
216
|
+
if (t.answeredIds?.length) parsed.answeredIds = t.answeredIds;
|
|
217
|
+
if (t.command) parsed.command = t.command;
|
|
218
|
+
if (t.miss) parsed.miss = true;
|
|
219
|
+
utterances.push({
|
|
220
|
+
role: "visitor", text: query, ts, sessionId: record.id, sessionStarted: record.started || "",
|
|
221
|
+
...(Object.keys(parsed).length ? { parsed } : {}),
|
|
222
|
+
});
|
|
223
|
+
const answer = answers.get(turnKey(ts, query));
|
|
224
|
+
if (answer) {
|
|
225
|
+
utterances.push({
|
|
226
|
+
role: "tmct", text: answer, ts, sessionId: record.id,
|
|
227
|
+
replyTo: `utt:${record.id}#${ts}#visitor`,
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
await appendUtterances(repoDir, utterances);
|
|
232
|
+
|
|
233
|
+
// Session over? The sidecar's end marker is authoritative (chat.mjs writes it
|
|
234
|
+
// before the final upsert). Fold THIS session's transcript into the corpus.
|
|
235
|
+
let ended = false;
|
|
236
|
+
try {
|
|
237
|
+
const sidecar = await readFile(join(repoDir, SESSIONS_DIR_REL, `session-${record.id}.jsonl`), "utf8");
|
|
238
|
+
ended = Boolean(parseSessionJsonl(sidecar)?.ended);
|
|
239
|
+
} catch { /* no sidecar — nothing to fold from */ }
|
|
240
|
+
if (ended) {
|
|
241
|
+
const { foldSessionLogs } = await import("./memory/fold.mjs"); // lazy: fold imports this module
|
|
242
|
+
await foldSessionLogs(repoDir, { sessionId: record.id });
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
167
246
|
/** Parse one sidecar .jsonl into a session record (null if no valid header).
|
|
168
247
|
* Torn/partial trailing lines (a killed session) are skipped, not fatal. */
|
|
169
248
|
export function parseSessionJsonl(text) {
|
|
@@ -181,6 +260,10 @@ export function parseSessionJsonl(text) {
|
|
|
181
260
|
turns.push({
|
|
182
261
|
ts: String(rec.ts || ""), query: String(rec.query || ""),
|
|
183
262
|
resolvedIds: arr(rec.resolvedIds), answeredIds: arr(rec.answeredIds), miss: !!rec.miss,
|
|
263
|
+
// preserved for the memory fold (memory/fold.mjs): slash-command turns and
|
|
264
|
+
// conversational filler are recorded but never folded into the corpus.
|
|
265
|
+
...(rec.command ? { command: String(rec.command) } : {}),
|
|
266
|
+
...(rec.conversational ? { conversational: true } : {}),
|
|
184
267
|
});
|
|
185
268
|
} else if (rec?.type === "end") ended = String(rec.ts || "") || ended;
|
|
186
269
|
}
|
|
@@ -188,6 +271,45 @@ export function parseSessionJsonl(text) {
|
|
|
188
271
|
return { id: String(header.id), started: String(header.started || ""), ended, turns };
|
|
189
272
|
}
|
|
190
273
|
|
|
274
|
+
// A transcript turn opens with an ISO-8601 ms timestamp line followed by the
|
|
275
|
+
// echoed "> <query>" line (chat.mjs's logLines shape).
|
|
276
|
+
const LOG_TS_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
|
|
277
|
+
|
|
278
|
+
/** Key a transcript answer by its turn: ts + query (ts alone can collide when
|
|
279
|
+
* two instant turns land in the same millisecond). */
|
|
280
|
+
export const turnKey = (ts, query) => `${ts}${query}`;
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Parse a human-readable session transcript (.tmct/session-<id>.log) into a
|
|
284
|
+
* Map of turnKey(ts, query) → answer text. The transcript is the ONLY session
|
|
285
|
+
* artifact that carries the answer PROSE (the structured sidecar records ids,
|
|
286
|
+
* not text), so the memory write-path recovers response text from here.
|
|
287
|
+
* Tolerant by design: a block is `ts` line + "> query" line + answer lines
|
|
288
|
+
* until the next block; header/footer and torn tails just don't match.
|
|
289
|
+
*/
|
|
290
|
+
export function parseSessionLog(text) {
|
|
291
|
+
const lines = String(text ?? "").split("\n");
|
|
292
|
+
const answers = new Map();
|
|
293
|
+
let open = null; // { ts, query, answerLines }
|
|
294
|
+
const close = () => {
|
|
295
|
+
if (!open) return;
|
|
296
|
+
while (open.answerLines.length && !open.answerLines.at(-1).trim()) open.answerLines.pop();
|
|
297
|
+
answers.set(turnKey(open.ts, open.query), open.answerLines.join("\n"));
|
|
298
|
+
open = null;
|
|
299
|
+
};
|
|
300
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
301
|
+
if (LOG_TS_RE.test(lines[i]) && lines[i + 1]?.startsWith("> ")) {
|
|
302
|
+
close();
|
|
303
|
+
open = { ts: lines[i], query: lines[i + 1].slice(2), answerLines: [] };
|
|
304
|
+
i += 1;
|
|
305
|
+
} else if (open) {
|
|
306
|
+
open.answerLines.push(lines[i]);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
close();
|
|
310
|
+
return answers;
|
|
311
|
+
}
|
|
312
|
+
|
|
191
313
|
/** All recorded sessions under <rootDir>/.tmct/sessions/*.jsonl, oldest first
|
|
192
314
|
* (uuidv7 filenames sort chronologically). Best-effort: no dir → []. */
|
|
193
315
|
export async function readSessionRecords(rootDir) {
|
package/src/source.mjs
CHANGED
|
@@ -2,16 +2,39 @@
|
|
|
2
2
|
// layer. The tool layer takes this as an injectable dependency (so tests can
|
|
3
3
|
// stub it); in production it reads the JSON artifact the deterministic indexer
|
|
4
4
|
// wrote to config.graphFile. No network, no model calls.
|
|
5
|
+
//
|
|
6
|
+
// This module is the PROVIDER SEAM (ROADMAP item 14, docs/adapter-contract.md):
|
|
7
|
+
// any graph producer can feed tmct either by writing the entities-payload JSON
|
|
8
|
+
// where config.graphFile points, or by registering a custom loader with
|
|
9
|
+
// registerProvider() — no indexer is ever imported here. tmct only READS
|
|
10
|
+
// through this seam; its own writes go to .tmct/memory/ (src/memory/), never
|
|
11
|
+
// back into a provider's artifact.
|
|
5
12
|
|
|
6
13
|
import { readFile } from "node:fs/promises";
|
|
7
14
|
import { ToolError } from "./config.mjs";
|
|
8
15
|
|
|
9
16
|
let cache = null; // { file, payload } — one artifact per process; cheap re-reads.
|
|
17
|
+
let provider = null; // registered custom loader (config) => entities payload | Promise
|
|
10
18
|
|
|
11
19
|
export function clearCache() {
|
|
12
20
|
cache = null;
|
|
13
21
|
}
|
|
14
22
|
|
|
23
|
+
/** Register a custom graph provider: an async (or sync) `(config) => payload`
|
|
24
|
+
* returning the entities-payload shape documented in docs/adapter-contract.md.
|
|
25
|
+
* Pass `null` to restore the default file loader. Returns the PREVIOUS
|
|
26
|
+
* provider (so a caller can wrap or restore it). The read cache is cleared
|
|
27
|
+
* either way — a provider swap must never serve the old source's payload. */
|
|
28
|
+
export function registerProvider(fn) {
|
|
29
|
+
if (fn != null && typeof fn !== "function") {
|
|
30
|
+
throw new TypeError("registerProvider expects a function (config) => entities payload, or null");
|
|
31
|
+
}
|
|
32
|
+
const prev = provider;
|
|
33
|
+
provider = fn ?? null;
|
|
34
|
+
cache = null;
|
|
35
|
+
return prev;
|
|
36
|
+
}
|
|
37
|
+
|
|
15
38
|
/** The empty-graph bootstrap payload: what a repo with no artifact "contains".
|
|
16
39
|
* Shaped exactly like a buildEntities payload so parseEntities and the session
|
|
17
40
|
* upsert treat it as a normal (just empty) graph. `bootstrap: true` marks it. */
|
|
@@ -27,12 +50,28 @@ export function emptyEntities() {
|
|
|
27
50
|
};
|
|
28
51
|
}
|
|
29
52
|
|
|
30
|
-
/**
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
53
|
+
/** Fetch the entities payload through the provider seam. With a registered
|
|
54
|
+
* provider, its result is returned as-is (uncached — a live provider owns its
|
|
55
|
+
* own caching/refresh policy); a non-object result is a clean ToolError.
|
|
56
|
+
* Default: read + parse the local graph artifact, cached per file for the
|
|
57
|
+
* process. A MISSING artifact (ENOENT) is not an error: the chat surface
|
|
58
|
+
* starts from an empty graph and the first session fold-in creates the file —
|
|
59
|
+
* so we return the bootstrap payload (uncached, so the freshly written file is
|
|
60
|
+
* picked up next fetch). Every other failure still throws a clean ToolError. */
|
|
35
61
|
export async function fetchEntities(config) {
|
|
62
|
+
if (provider) {
|
|
63
|
+
let payload;
|
|
64
|
+
try {
|
|
65
|
+
payload = await provider(config);
|
|
66
|
+
} catch (e) {
|
|
67
|
+
if (e instanceof ToolError) throw e;
|
|
68
|
+
throw new ToolError(`graph provider failed (${e?.message || e})`);
|
|
69
|
+
}
|
|
70
|
+
if (!payload || typeof payload !== "object") {
|
|
71
|
+
throw new ToolError("graph provider returned no entities payload");
|
|
72
|
+
}
|
|
73
|
+
return payload;
|
|
74
|
+
}
|
|
36
75
|
if (cache && cache.file === config.graphFile) return cache.payload;
|
|
37
76
|
let text;
|
|
38
77
|
try {
|