memtrails 0.0.0-stage → 0.1.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.
Files changed (76) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/LICENSE +21 -0
  3. package/README.md +217 -2
  4. package/dist/core/consolidate.d.ts +144 -0
  5. package/dist/core/consolidate.js +660 -0
  6. package/dist/core/embedder.d.ts +30 -0
  7. package/dist/core/embedder.js +137 -0
  8. package/dist/core/episodes.d.ts +30 -0
  9. package/dist/core/episodes.js +49 -0
  10. package/dist/core/extract.d.ts +139 -0
  11. package/dist/core/extract.js +447 -0
  12. package/dist/core/gather.d.ts +17 -0
  13. package/dist/core/gather.js +98 -0
  14. package/dist/core/graph.d.ts +48 -0
  15. package/dist/core/graph.js +208 -0
  16. package/dist/core/local-embedder.d.ts +40 -0
  17. package/dist/core/local-embedder.js +98 -0
  18. package/dist/core/model.d.ts +16 -0
  19. package/dist/core/model.js +74 -0
  20. package/dist/core/paths.d.ts +1 -0
  21. package/dist/core/paths.js +7 -0
  22. package/dist/core/profiles.d.ts +30 -0
  23. package/dist/core/profiles.js +147 -0
  24. package/dist/core/qdrant-binary.d.ts +25 -0
  25. package/dist/core/qdrant-binary.js +108 -0
  26. package/dist/core/qdrant-client.d.ts +115 -0
  27. package/dist/core/qdrant-client.js +319 -0
  28. package/dist/core/qdrant-process.d.ts +30 -0
  29. package/dist/core/qdrant-process.js +160 -0
  30. package/dist/core/rebuild.d.ts +31 -0
  31. package/dist/core/rebuild.js +109 -0
  32. package/dist/core/recall-log.d.ts +29 -0
  33. package/dist/core/recall-log.js +82 -0
  34. package/dist/core/recall.d.ts +91 -0
  35. package/dist/core/recall.js +172 -0
  36. package/dist/core/redact.d.ts +2 -0
  37. package/dist/core/redact.js +48 -0
  38. package/dist/core/reranker.d.ts +26 -0
  39. package/dist/core/reranker.js +67 -0
  40. package/dist/core/room.d.ts +20 -0
  41. package/dist/core/room.js +63 -0
  42. package/dist/core/sleep.d.ts +44 -0
  43. package/dist/core/sleep.js +199 -0
  44. package/dist/core/store.d.ts +198 -0
  45. package/dist/core/store.js +190 -0
  46. package/dist/core/text.d.ts +31 -0
  47. package/dist/core/text.js +101 -0
  48. package/dist/core/words.d.ts +32 -0
  49. package/dist/core/words.js +127 -0
  50. package/dist/core/writer.d.ts +36 -0
  51. package/dist/core/writer.js +146 -0
  52. package/dist/importers/files.d.ts +31 -0
  53. package/dist/importers/files.js +157 -0
  54. package/dist/importers/git.d.ts +30 -0
  55. package/dist/importers/git.js +118 -0
  56. package/dist/importers/index.d.ts +14 -0
  57. package/dist/importers/index.js +26 -0
  58. package/dist/importers/jsonl.d.ts +20 -0
  59. package/dist/importers/jsonl.js +65 -0
  60. package/dist/importers/mbox.d.ts +20 -0
  61. package/dist/importers/mbox.js +347 -0
  62. package/dist/importers/slack.d.ts +21 -0
  63. package/dist/importers/slack.js +134 -0
  64. package/dist/importers/text.d.ts +31 -0
  65. package/dist/importers/text.js +191 -0
  66. package/dist/index.d.ts +28 -0
  67. package/dist/index.js +22 -0
  68. package/dist/llm.d.ts +60 -0
  69. package/dist/llm.js +60 -0
  70. package/dist/memory.d.ts +106 -0
  71. package/dist/memory.js +262 -0
  72. package/dist/records.d.ts +62 -0
  73. package/dist/records.js +107 -0
  74. package/dist/tools/agent-tools.d.ts +52 -0
  75. package/dist/tools/agent-tools.js +142 -0
  76. package/package.json +78 -4
@@ -0,0 +1,660 @@
1
+ /**
2
+ * Consolidation — LONG-TERM memory. Like the brain: short-term memory keeps everything as it came; long-term memory
3
+ * filters, stores, reorganises and reprioritises.
4
+ *
5
+ * It reads the queue (the records log) a thread at a time — one that has settled (quiet for an hour, or a day old),
6
+ * newest first — in windows of whole records (extract.ts: one language-model ask a window). What survives the gate is
7
+ * RECONCILED with what long-term memory already holds before it is written, so nothing is stored twice:
8
+ *
9
+ * - cosine >= 0.93 to a known item (0.97 for episodes) → the same memory: its provenance and `seen` grow, it is
10
+ * refreshed — no new item;
11
+ * - 0.80–0.93 → one fast-model decision a batch: same / updates / contradicts / related / different → merge,
12
+ * rewrite, supersede (the old one stays in the log, out of recall), add and link the two, or add;
13
+ * - below → added.
14
+ *
15
+ * Then the window's entries are marked done and leave the short-term index: what mattered of them is long-term now.
16
+ */
17
+ import { redactString } from './redact.js';
18
+ import { defaultAsk } from '../llm.js';
19
+ import { episodeBody } from './episodes.js';
20
+ import { docText } from './embedder.js';
21
+ import { cleanConcepts, cleanCues, extractWindow, observeWindow, windowsOf } from './extract.js';
22
+ import { memorySearch } from './recall.js';
23
+ import { fingerprint } from './store.js';
24
+ import { conceptKey, cut } from './text.js';
25
+ import { embedBudget, writeConcepts, writeCues, writeLtm } from './writer.js';
26
+ export const SAME = 0.93;
27
+ export const SAME_EPISODE = 0.97;
28
+ /** An observation is the same only when it says the same thing (this close) about the same date: two dinners on two
29
+ * days are two observations, and a list question needs both. */
30
+ export const SAME_OBSERVATION = 0.97;
31
+ export const RELATED = 0.80;
32
+ /** A conversation is consolidated once it has been quiet this long — or its entries are a day old. */
33
+ export const SETTLED_MS = 60 * 60_000;
34
+ /** Extraction calls in flight: they are independent, and one at a time made a backfill take days (~86 s a window). */
35
+ export const EXTRACT_CONCURRENCY = 8;
36
+ const DAY_MS = 24 * 60 * 60_000;
37
+ /** Requests of a window searched for, and the passages of other conversations kept to name what it refers to. */
38
+ const RESOLVE_QUERIES = 4;
39
+ const RESOLVE_KEPT = 8;
40
+ /** Items given their cues and concepts after a run's windows (the backlog: saved notes, what was learned before
41
+ * them), and how many go in one ask. */
42
+ export const CUE_BACKLOG = 40;
43
+ const CUE_BATCH = 10;
44
+ const CUE_SYSTEM = `You index a person's long-term memory of their work with AI agents. For EACH item:
45
+ - cues: 4 to 8 requests the person might type to an agent LATER, in other work, when this item would be needed. Vary
46
+ them: short and long, direct and indirect (describe the task, not the memory), never copying the title. Write them in
47
+ the language the item is written in — when that is not English, make one or two of them English.
48
+ - concepts: the 3 to 12 most specific things it is about — files, folders, repositories, services, servers, tools,
49
+ commands, APIs, libraries, models, products, settings, error names, people or organisations, project-specific terms
50
+ — named the way the item names them (other terms in the item's language, never translated). No generic words.
51
+ Keep code, identifiers, paths and commands exactly as written. Return every id with its cues and concepts.`;
52
+ const CUE_SCHEMA = {
53
+ type: 'object', additionalProperties: false, required: ['items'],
54
+ properties: {
55
+ items: {
56
+ type: 'array',
57
+ items: {
58
+ type: 'object', additionalProperties: false, required: ['id', 'cues', 'concepts'],
59
+ properties: { id: { type: 'string' }, cues: { type: 'array', items: { type: 'string' } }, concepts: { type: 'array', items: { type: 'string' } } },
60
+ },
61
+ },
62
+ },
63
+ };
64
+ const RELATIONS = ['same', 'updates', 'contradicts', 'related', 'different'];
65
+ /** Links an item keeps to others about the same subject (exploration walks them), newest last. */
66
+ export const MAX_LINKS = 24;
67
+ const RELATE_SYSTEM = `You keep a person's long-term memory free of duplicates and stale knowledge without ever losing what it
68
+ knows. For EACH pair, compare the NEW memory with the KNOWN one and answer:
69
+ - same: they say the same thing (one may be more detailed) — give the merged version
70
+ - updates: NEW refines or extends KNOWN without contradicting it — give the merged version
71
+ - contradicts: NEW replaces KNOWN — the text shows KNOWN stopped being true (a decision reversed, a value changed, a
72
+ thing removed or renamed)
73
+ - related: the same subject or system, different facts that belong together — reading one, you would want the other
74
+ - different: they are about different things
75
+ Rules:
76
+ - Different numbers, dates, versions, names, paths, commands or qualifiers mean they are NOT the same: updates when one
77
+ extends the other, related otherwise.
78
+ - contradicts needs explicit evidence in the texts that KNOWN was replaced or ended; being newer is not evidence. When
79
+ unsure between contradicts and related, answer related.
80
+ - For same and updates, write title (at most 12 words), summary (about 150 words, standing alone) and body. The merged
81
+ body keeps EVERY fact of both — every value, path, command, error, code, reason and caveat, exactly as written —
82
+ never shortened or dropped to be brief. For contradicts, related and different leave them empty.
83
+ - Never include secret values. Never translate.`;
84
+ const RELATE_SCHEMA = {
85
+ type: 'object', additionalProperties: false, required: ['pairs'],
86
+ properties: {
87
+ pairs: {
88
+ type: 'array',
89
+ items: {
90
+ type: 'object', additionalProperties: false, required: ['id', 'relation', 'title', 'summary', 'body'],
91
+ properties: {
92
+ id: { type: 'string' }, relation: { type: 'string', enum: RELATIONS },
93
+ title: { type: 'string' }, summary: { type: 'string' }, body: { type: 'string' },
94
+ },
95
+ },
96
+ },
97
+ },
98
+ };
99
+ /** Pairs asked about together: at most 8, and at most RELATE_CHARS of their texts — a long pair on its own. */
100
+ export const RELATE_CHARS = 60_000;
101
+ export function relateBatches(pairs) {
102
+ const size = (p) => p.it.summary.length + p.it.body.length + p.known.summary.length + p.known.body.length;
103
+ const out = [];
104
+ let cur = [];
105
+ let chars = 0;
106
+ for (const p of pairs) {
107
+ if (cur.length && (cur.length >= 8 || chars + size(p) > RELATE_CHARS)) {
108
+ out.push(cur);
109
+ cur = [];
110
+ chars = 0;
111
+ }
112
+ cur.push(p);
113
+ chars += size(p);
114
+ }
115
+ if (cur.length)
116
+ out.push(cur);
117
+ return out;
118
+ }
119
+ /** One remembered episode or candidate as a long-term item (not yet reconciled). */
120
+ function itemOf(c, src, now, sources = [src]) {
121
+ const project = c.scope === 'project' ? src.project : c.scope;
122
+ // The files its requests changed, from their recorded steps — by code, not the model: paths are what summaries lose.
123
+ const files = [...new Set(sources.flatMap((e) => e.files))];
124
+ return {
125
+ uid: `mem:${fingerprint(c.kind, c.scope, project, c.title, c.summary)}`, kind: c.kind, scope: c.scope, project: src.project,
126
+ engine: src.engine, session: src.session, cwd: src.cwd, where: src.title, ts: src.ts || now, firstTs: src.ts || now,
127
+ title: c.title, summary: c.summary, body: c.body, files, appliesWhen: c.appliesWhen, confidence: c.confidence,
128
+ importance: c.importance, status: 'active', provenance: c.provenance, seen: 1,
129
+ fingerprint: fingerprint(c.kind, c.title, c.summary, c.body), cues: c.cues, concepts: c.concepts,
130
+ ...(c.origin ? { origin: c.origin } : {}),
131
+ };
132
+ }
133
+ function episodeItem(e, key) {
134
+ return {
135
+ uid: `ep:${e.uid}`, kind: 'episode', scope: 'project', project: e.project, engine: e.engine, session: e.session, cwd: e.cwd,
136
+ where: e.title, ts: e.ts, firstTs: e.ts, title: key.title || e.title, summary: key.summary, body: episodeBody(e), files: e.files,
137
+ appliesWhen: '', confidence: 'observed', importance: e.files.length ? 3 : 2, status: 'active', provenance: [e.uid], seen: 1,
138
+ fingerprint: fingerprint(e.request, e.reply, e.files), cues: key.cues ?? [], concepts: key.concepts ?? [],
139
+ ...(key.outcome ? { outcome: key.outcome } : {}), ...(key.origin ? { origin: key.origin } : {}),
140
+ };
141
+ }
142
+ /** An observation as a long-term item: its text is its key, its date beside when it was said. */
143
+ function observationItem(o, src) {
144
+ return {
145
+ uid: `obs:${fingerprint(o.text, o.eventAt, o.provenance[0] ?? src.uid)}`, kind: 'observation', scope: 'person', project: src.project,
146
+ engine: src.engine, session: src.session, cwd: src.cwd, where: src.title, ts: src.ts, firstTs: src.ts,
147
+ title: cut(o.text, 100), summary: o.text, body: '', files: [], appliesWhen: '', confidence: 'stated', importance: 2,
148
+ status: 'active', provenance: o.provenance, seen: 1, fingerprint: fingerprint('observation', o.text, o.eventAt),
149
+ cues: [], concepts: o.concepts, ...(o.eventAt ? { eventAt: o.eventAt } : {}),
150
+ ...(o.startAt ? { startAt: o.startAt } : {}), ...(o.endAt ? { endAt: o.endAt } : {}), phase: o.phase, ...(o.about ? { about: o.about } : {}),
151
+ ...(o.origin ? { origin: o.origin } : {}),
152
+ };
153
+ }
154
+ function cosine(a, b) {
155
+ let ab = 0;
156
+ let aa = 0;
157
+ let bb = 0;
158
+ for (let i = 0; i < a.length; i++) {
159
+ ab += a[i] * b[i];
160
+ aa += a[i] * a[i];
161
+ bb += b[i] * b[i];
162
+ }
163
+ return aa && bb ? ab / Math.sqrt(aa * bb) : 0;
164
+ }
165
+ /** Where an item's duplicates can be: its project for project knowledge, anywhere for the global and the personal. */
166
+ function scopeFilter(it) {
167
+ const must = [{ key: 'tier', match: { value: 'ltm' } }, { key: 'status', match: { value: 'active' } }];
168
+ if (it.scope === 'project')
169
+ must.push({ key: 'project', match: { value: it.project } });
170
+ else
171
+ must.push({ key: 'scope', match: { any: ['global', 'person'] } });
172
+ // Observations are reconciled among themselves (observe): a fact is never merged into a dated event — nor into a
173
+ // profile, which is written again from its items (profiles.ts).
174
+ return { must, must_not: [{ key: 'kind', match: { any: ['observation', 'profile'] } }] };
175
+ }
176
+ export class Consolidator {
177
+ d;
178
+ vecs = new Map();
179
+ /** Windows whose extraction failed, by their first request: how many times in a row. */
180
+ failed = new Map();
181
+ /** Windows read in smaller parts from now on, by their first request: at most this many requests a part. */
182
+ narrowed = new Map();
183
+ /** An item changed by one window at a time (windows reconciled at once): the last holder's turn, by uid. */
184
+ locks = new Map();
185
+ constructor(d) {
186
+ this.d = d;
187
+ }
188
+ /** Runs with the items locked — taken in uid order, so two windows never wait on each other (sleep's answers too). */
189
+ async locked(uids, run) {
190
+ const release = [];
191
+ for (const uid of [...new Set(uids)].sort()) {
192
+ const before = this.locks.get(uid) ?? Promise.resolve();
193
+ let done;
194
+ const gate = new Promise((ok) => { done = ok; });
195
+ const mine = before.then(() => gate);
196
+ this.locks.set(uid, mine);
197
+ await before;
198
+ release.push(() => { done(); if (this.locks.get(uid) === mine)
199
+ this.locks.delete(uid); });
200
+ }
201
+ try {
202
+ return await run();
203
+ }
204
+ finally {
205
+ for (const r of release)
206
+ r();
207
+ }
208
+ }
209
+ /** The item as it is now, or null when it stopped being active (merged or superseded by a window beside this one). */
210
+ latest(it) {
211
+ const now = this.d.ltm.get(it.uid);
212
+ return now && now.status === 'active' ? now : null;
213
+ }
214
+ /** Embed with a per-run cache: the key is embedded once for reconcile and reused when it is written. */
215
+ embed = async (texts, timeoutMs) => {
216
+ const missing = [...new Set(texts.filter((t) => !this.vecs.has(t)))];
217
+ if (missing.length)
218
+ (await this.d.embed(missing, timeoutMs)).forEach((v, i) => this.vecs.set(missing[i], v));
219
+ return texts.map((t) => this.vecs.get(t));
220
+ };
221
+ get writer() { return { client: this.d.client, embed: this.embed }; }
222
+ /** Conversations with requests waiting, settled, newest first. */
223
+ pending(now = (this.d.now ?? Date.now)()) {
224
+ const by = new Map();
225
+ for (const [uid, m] of this.d.stm.entries()) {
226
+ if (m.done || m.synthetic)
227
+ continue;
228
+ const s = by.get(m.session) ?? { uids: [], last: 0 };
229
+ s.uids.push(uid);
230
+ s.last = Math.max(s.last, m.ts);
231
+ by.set(m.session, s);
232
+ }
233
+ return [...by.entries()].map(([session, s]) => ({ session, ...s }))
234
+ .filter((s) => now - s.last >= SETTLED_MS || s.uids.every((u) => now - (this.d.stm.meta(u)?.ts ?? now) >= DAY_MS))
235
+ .sort((a, b) => b.last - a.last);
236
+ }
237
+ /** Consolidate up to `maxWindows` windows. Stops early when the model pauses or the worker is stopped.
238
+ * The fast model extracts several windows at once (they are independent); each is reconciled in order, newest
239
+ * conversation first, so a later item still sees what an earlier one wrote. */
240
+ async run(maxWindows = 20) {
241
+ const r = { windows: 0, added: 0, merged: 0, updated: 0, superseded: 0, linked: 0, dropped: 0, episodes: 0, observed: 0, cued: 0 };
242
+ const wins = [];
243
+ for (const conv of this.pending()) {
244
+ const entries = conv.uids.map((u) => this.d.stm.get(u)).filter((e) => !!e).sort((a, b) => a.turn - b.turn);
245
+ for (const win of windowsOf(entries, this.d.windowChars).flatMap((w) => this.narrow(w)))
246
+ if (wins.length < maxWindows)
247
+ wins.push(win);
248
+ if (wins.length >= maxWindows)
249
+ break;
250
+ }
251
+ const limit = Math.max(1, this.d.concurrency ?? EXTRACT_CONCURRENCY);
252
+ const extracting = [];
253
+ // A pool: the next window starts when ANY one is read, not when the oldest is — one slow answer (measured: an
254
+ // answer that ran on for 19,000 lines) held every other place idle while the windows after it waited their turn.
255
+ // They are still reconciled in order.
256
+ let next = 0;
257
+ let reading = 0;
258
+ let stopped = false;
259
+ const launch = () => {
260
+ while (!stopped && reading < limit && next < wins.length && !this.d.aborted()) {
261
+ const win = wins[next];
262
+ const head = win[0];
263
+ const p = this.read(win, { cwd: head.cwd, engine: head.engine, title: head.title });
264
+ reading++;
265
+ // One left behind by a pause or a stop is not an unhandled rejection; the awaited one still throws.
266
+ p.then(() => { reading--; launch(); }, () => { reading--; });
267
+ extracting[next++] = p;
268
+ }
269
+ };
270
+ launch();
271
+ const together = Math.max(1, Math.min(limit, this.d.reconcileConcurrency ?? 1));
272
+ /** Window i, once read: reconciled, observed, marked done — or 'stop' when the run pauses. */
273
+ const settle = async (i) => {
274
+ const x = await extracting[i].catch((err) => { stopped = true; throw err; });
275
+ const win = wins[i];
276
+ const head = win[0];
277
+ if ('unavailable' in x || 'busy' in x) {
278
+ // A window that failed before fails for itself (its answer too long to finish, say), not because the model is
279
+ // away: it is read in halves from now on, and the windows after it go on — one window must never hold up the
280
+ // rest, and none is marked done unread. A first failure before any window was read pauses the run, as when the
281
+ // model is unreachable; after others were read it is the window's own, and it waits for the next run — the
282
+ // windows already read after it are not thrown away.
283
+ const times = 'busy' in x ? 0 : (this.failed.get(head.uid) ?? 0) + 1;
284
+ if (times < 2) {
285
+ if (times)
286
+ this.failed.set(head.uid, times);
287
+ if (r.windows)
288
+ return undefined;
289
+ stopped = true;
290
+ r.paused = 'busy' in x ? 'the fast model is busy' : x.unavailable;
291
+ return 'stop';
292
+ }
293
+ this.failed.delete(head.uid);
294
+ if (win.length > 1)
295
+ this.narrowed.set(head.uid, Math.ceil(win.length / 2));
296
+ this.d.log(`[memory] a window of ${win.length} requests failed again (${'unavailable' in x ? x.unavailable : 'busy'}); ${win.length > 1 ? 'reading it in halves' : 'it waits for the next run'}`);
297
+ return undefined;
298
+ }
299
+ this.failed.delete(head.uid);
300
+ const now = (this.d.now ?? Date.now)();
301
+ const byUid = new Map(win.map((e) => [e.uid, e]));
302
+ // A request the window's typed memories already came from is not kept again as an episode: measured on the full
303
+ // backfill, leaving those out took 4,631 records to 2,782 and raised hit@5.
304
+ const learned = new Set(x.candidates.flatMap((c) => c.provenance));
305
+ const kept = x.episodes.filter((k) => byUid.has(k.uid) && !learned.has(k.uid));
306
+ const fresh = [
307
+ ...kept.map((k) => episodeItem(byUid.get(k.uid), k)),
308
+ ...x.candidates.map((c) => itemOf(c, byUid.get(c.provenance.at(-1) ?? '') ?? head, now, c.provenance.flatMap((u) => byUid.get(u) ?? []))),
309
+ ];
310
+ await this.reconcile(fresh, r);
311
+ await this.observe(x.observations.map((o) => observationItem(o, byUid.get(o.provenance.at(-1) ?? '') ?? head)), r);
312
+ r.dropped += x.dropped;
313
+ r.episodes += kept.length;
314
+ // Read: marked done in the queue. The requests themselves stay searchable — the raw record is the truth and the
315
+ // memories learned from them are keys to it; dropping them lost every fact the extraction did not keep (measured on
316
+ // LongMemEval: 47 of 60 misses were facts said in passing inside a request).
317
+ this.d.stm.put(win.map((e) => ({ ...e, done: true })));
318
+ r.windows++;
319
+ // One at a time, each window's keys are dropped after it; windows at once share the cache until the run ends.
320
+ if (together === 1)
321
+ this.vecs.clear();
322
+ return undefined;
323
+ };
324
+ let at = 0;
325
+ let halt = false;
326
+ const lane = async () => {
327
+ while (!halt && at < wins.length && extracting[at] && !this.d.aborted()) {
328
+ try {
329
+ if (await settle(at++) === 'stop')
330
+ halt = true;
331
+ }
332
+ catch (err) {
333
+ halt = true;
334
+ throw err;
335
+ }
336
+ }
337
+ };
338
+ await Promise.all(Array.from({ length: together }, lane));
339
+ if (halt && r.paused !== undefined)
340
+ return r;
341
+ this.vecs.clear();
342
+ if (!this.d.aborted()) {
343
+ const c = await this.fillCues(CUE_BACKLOG);
344
+ r.cued = c.cued;
345
+ if (c.paused)
346
+ r.paused = c.paused;
347
+ }
348
+ this.d.ltm.compact();
349
+ this.d.stm.compact();
350
+ return r;
351
+ }
352
+ /** One window's reading, and what the guard (when there is one) lets it keep. */
353
+ async read(win, meta) {
354
+ const x = await this.extract(win, meta);
355
+ return this.d.guard && 'candidates' in x ? this.d.guard(win, x) : x;
356
+ }
357
+ /** One window's extraction: the one ask — and, when observations are read apart, the window's small windows too, each
358
+ * with the one before it as context. Any part not answered leaves the whole window for the next run. */
359
+ async extract(win, meta) {
360
+ const chars = this.d.observeChars;
361
+ const known = this.d.resolve ? await this.knownFor(win) : undefined;
362
+ if (!chars)
363
+ return extractWindow(win, meta, { ask: this.d.ask, known });
364
+ const subs = windowsOf(win, chars);
365
+ const [x, ...seen] = await Promise.all([
366
+ extractWindow(win, meta, { ask: this.d.ask, observations: false, known }),
367
+ ...subs.map((sub, k) => observeWindow(sub, k ? subs[k - 1] : [], meta, { ask: this.d.ask, chars, known })),
368
+ ]);
369
+ if (!('candidates' in x))
370
+ return x;
371
+ const failed = seen.find((o) => !('observations' in o));
372
+ if (failed)
373
+ return failed;
374
+ return { ...x, observations: seen.flatMap((o) => ('observations' in o ? o.observations : [])) };
375
+ }
376
+ /** What other conversations say that this window may refer to: a few of its requests searched for (memory_search —
377
+ * the raw requests of every conversation, captured before any is consolidated, and what was learned), the best
378
+ * passages outside this conversation, dated. The order conversations are consolidated in does not matter: the raw
379
+ * record holds them all. Nothing found, or a search that fails, reads the window without it. */
380
+ async knownFor(win) {
381
+ const step = Math.max(1, Math.ceil(win.length / RESOLVE_QUERIES));
382
+ const asks = win.filter((_, i) => i % step === 0).slice(0, RESOLVE_QUERIES).map((e) => cut(e.request, 600)).filter(Boolean);
383
+ const session = win[0].session;
384
+ const deps = { client: this.d.client, embed: this.d.embed, now: this.d.now };
385
+ try {
386
+ const found = (await Promise.all(asks.map((query) => memorySearch(deps, { query, limit: 6 })))).flat();
387
+ const best = new Map();
388
+ for (const h of found) {
389
+ const uid = h.id.startsWith('stm:') ? h.id.slice(4) : h.id.startsWith('ep:') ? h.id.slice(3) : null;
390
+ if (uid && this.d.stm.meta(uid)?.session === session)
391
+ continue;
392
+ if ((best.get(h.id)?.score ?? -1) < h.score)
393
+ best.set(h.id, h);
394
+ }
395
+ return [...best.values()].sort((a, b) => b.score - a.score).slice(0, RESOLVE_KEPT)
396
+ .map((h) => `(${h.eventAt ?? new Date(h.ts).toISOString().slice(0, 10)}) ${cut(h.match ?? h.summary, 400)}`);
397
+ }
398
+ catch {
399
+ return [];
400
+ }
401
+ }
402
+ /** A window as it is read now: in parts of at most the size its earlier failures narrowed it to, each part narrowed
403
+ * again by its own failures. */
404
+ narrow(win) {
405
+ const n = this.narrowed.get(win[0].uid);
406
+ if (!n || n >= win.length)
407
+ return [win];
408
+ const parts = [];
409
+ for (let i = 0; i < win.length; i += n)
410
+ parts.push(...this.narrow(win.slice(i, i + n)));
411
+ return parts;
412
+ }
413
+ /** Encoding's backlog: active items without cues or concepts — saved notes (they come in curated, without the
414
+ * consolidation call) and what was learned before either existed — get them, newest first, a few an ask, several
415
+ * asks at once. Cues an item already has stay. */
416
+ async fillCues(max = CUE_BACKLOG) {
417
+ // An observation is its own key (a sentence the person's question matches), with the concepts it was extracted with;
418
+ // a profile has its subject as its concept, and no cues (as measured).
419
+ const todo = [...this.d.ltm.entries()].filter(([, m]) => m.status === 'active' && m.kind !== 'observation' && m.kind !== 'profile' && (!m.cued || !m.mapped))
420
+ .sort((a, b) => b[1].ts - a[1].ts).slice(0, max)
421
+ .map(([uid]) => this.d.ltm.get(uid)).filter((x) => !!x);
422
+ const batches = [];
423
+ for (let i = 0; i < todo.length; i += CUE_BATCH)
424
+ batches.push(todo.slice(i, i + CUE_BATCH));
425
+ const limit = Math.max(1, this.d.concurrency ?? EXTRACT_CONCURRENCY);
426
+ let cued = 0;
427
+ for (let i = 0; i < batches.length; i += limit) {
428
+ if (this.d.aborted())
429
+ break;
430
+ const answers = await Promise.all(batches.slice(i, i + limit).map((b) => this.cueBatch(b)));
431
+ const paused = answers.find((a) => 'paused' in a);
432
+ const got = answers.flatMap((a) => ('items' in a ? a.items : []));
433
+ if (got.length) {
434
+ const recued = got.filter((it) => !this.d.ltm.meta(it.uid)?.cued);
435
+ if (recued.length)
436
+ await writeCues(this.writer, recued);
437
+ await writeConcepts(this.writer, got);
438
+ this.d.ltm.put(got);
439
+ cued += got.length;
440
+ }
441
+ if (paused)
442
+ return { cued, paused: paused.paused };
443
+ }
444
+ return { cued };
445
+ }
446
+ async cueBatch(items) {
447
+ const prompt = items.map((it, n) => `[i${n + 1}] (${it.kind}) ${it.title}\n${it.summary}${it.appliesWhen ? `\nNeeded when: ${it.appliesWhen}` : ''}`).join('\n\n');
448
+ const a = await (this.d.ask ?? defaultAsk)({
449
+ purpose: 'memory', system: CUE_SYSTEM, prompt: redactString(prompt), schema: CUE_SCHEMA,
450
+ name: 'memory_cues', maxTokens: 550 * items.length + 200, timeoutMs: 120_000, consequence: 'so these memories wait for their cues',
451
+ });
452
+ if ('unavailable' in a)
453
+ return { paused: a.unavailable };
454
+ if ('busy' in a)
455
+ return { paused: 'the fast model is busy' };
456
+ const out = [];
457
+ for (const x of a.answer.items ?? []) {
458
+ const it = typeof x.id === 'string' ? items[Number(x.id.slice(1)) - 1] : undefined;
459
+ const cues = it?.cues?.length ? it.cues : cleanCues(x.cues);
460
+ const concepts = it?.concepts?.length ? it.concepts : cleanConcepts(x.concepts);
461
+ if (it && (cues.length || concepts.length))
462
+ out.push({ ...it, cues, concepts }); // what is missing is asked again next time
463
+ }
464
+ return { items: out };
465
+ }
466
+ /** Each item against what long-term memory holds — written as it goes, so a later item sees an earlier one. */
467
+ async reconcile(items, r) {
468
+ const ask = [];
469
+ // Every key of the window in one embedding call (the cache serves each below and again when it is written).
470
+ await this.embed(items.map((it) => docText(it.title, it.summary)), embedBudget(items.length));
471
+ for (const it of items) {
472
+ const [v] = await this.embed([docText(it.title, it.summary)]);
473
+ const near = await this.d.client.neighbors(Array.from(v), scopeFilter(it), 3);
474
+ const top = near.find((n) => n.item !== it.uid);
475
+ const known = top ? this.d.ltm.get(top.item) : null;
476
+ const same = it.kind === 'episode' ? SAME_EPISODE : SAME;
477
+ if (known && top.score >= same) {
478
+ const merged = await this.locked([known.uid], async () => {
479
+ const now = this.latest(known);
480
+ if (now)
481
+ await this.merge(now, it, null);
482
+ return !!now;
483
+ });
484
+ if (merged) {
485
+ r.merged++;
486
+ continue;
487
+ }
488
+ }
489
+ else if (known && top.score >= RELATED && it.kind !== 'episode' && known.kind !== 'episode') {
490
+ ask.push({ it, known });
491
+ continue;
492
+ }
493
+ await this.add(it);
494
+ r.added++;
495
+ }
496
+ // The pairs are fixed before any is asked about: every batch asked at once, the answers applied in order.
497
+ const batches = relateBatches(ask);
498
+ const answers = await Promise.all(batches.map((b) => this.relate(b)));
499
+ for (const [b, batch] of batches.entries()) {
500
+ for (const [n, { it, known }] of batch.entries()) {
501
+ const d = answers[b]?.get(`p${n + 1}`);
502
+ if (!d || d.relation === 'different') {
503
+ await this.add(it);
504
+ r.added++;
505
+ continue;
506
+ }
507
+ await this.locked([it.uid, known.uid], async () => {
508
+ // Changed by a window beside this one since it was asked about: the answer was about another text — kept
509
+ // beside it, linked, for the sleep pass to read again.
510
+ const now = this.latest(known);
511
+ const moved = !now || now.fingerprint !== known.fingerprint;
512
+ if (d.relation === 'related' || (moved && d.relation !== 'contradicts')) {
513
+ await this.add(it);
514
+ r.added++;
515
+ if (now) {
516
+ await this.link(it, now);
517
+ r.linked++;
518
+ }
519
+ return;
520
+ }
521
+ if (d.relation === 'contradicts') {
522
+ await this.add(it);
523
+ r.added++;
524
+ if (now) {
525
+ await this.retire(now, it.uid);
526
+ r.superseded++;
527
+ }
528
+ return;
529
+ }
530
+ await this.merge(now, it, d.title && d.summary ? { title: d.title, summary: d.summary, body: d.body || now.body } : null);
531
+ if (d.relation === 'updates')
532
+ r.updated++;
533
+ else
534
+ r.merged++;
535
+ });
536
+ }
537
+ }
538
+ }
539
+ /** Observations are only ever added: one is the same as a known one only when it says the same (SAME_OBSERVATION)
540
+ * about the same date — then the known one is told again (provenance, `seen`). Never asked about, never merged into
541
+ * another text, never superseded: an older one stays, dated, beside the newer. */
542
+ async observe(items, r) {
543
+ if (!items.length)
544
+ return;
545
+ // The window's observations looked up together and written together: one embedding call, one batched query, one
546
+ // write — and each also against the ones before it in this window, as when each was written before the next looked.
547
+ const vecs = await this.embed(items.map((it) => docText(it.title, it.summary)), embedBudget(items.length));
548
+ const filter = { must: [{ key: 'tier', match: { value: 'ltm' } }, { key: 'status', match: { value: 'active' } }, { key: 'kind', match: { value: 'observation' } }, { key: 'part', match: { value: 'key' } }] };
549
+ const near = await this.d.client.nearestMany(vecs.map((v) => Array.from(v)), filter, 3, 30_000);
550
+ const fresh = [];
551
+ /** Told again in this window: how many times, by whom, the newest time. */
552
+ const told = new Map();
553
+ for (const [n, it] of items.entries()) {
554
+ const same = (eventAt) => (eventAt ?? '') === (it.eventAt ?? '');
555
+ const fromWindow = fresh.find((f) => f.it.uid !== it.uid && same(f.it.eventAt) && cosine(f.v, vecs[n]) >= SAME_OBSERVATION);
556
+ if (fromWindow) {
557
+ fromWindow.it = { ...fromWindow.it, seen: fromWindow.it.seen + 1, ts: Math.max(fromWindow.it.ts, it.ts), provenance: [...new Set([...fromWindow.it.provenance, ...it.provenance])] };
558
+ r.merged++;
559
+ continue;
560
+ }
561
+ const hit = (near[n] ?? []).find((h) => h.item !== it.uid);
562
+ const known = hit && hit.score >= SAME_OBSERVATION ? this.d.ltm.get(hit.item) : null;
563
+ if (known && same(known.eventAt)) {
564
+ const t = told.get(known.uid) ?? { times: 0, ts: 0, provenance: [] };
565
+ told.set(known.uid, { times: t.times + 1, ts: Math.max(t.ts, it.ts), provenance: [...new Set([...t.provenance, ...it.provenance])] });
566
+ r.merged++;
567
+ continue;
568
+ }
569
+ fresh.push({ it, v: vecs[n] });
570
+ r.observed++;
571
+ }
572
+ // Told again: what this window adds (times, sources, the newest time) put onto the item as it is by now — a window
573
+ // beside this one may have told it too.
574
+ for (const [uid, t] of told) {
575
+ await this.locked([uid], async () => {
576
+ const was = this.d.ltm.get(uid);
577
+ if (!was)
578
+ return;
579
+ const now = { ...was, ts: Math.max(was.ts, t.ts), seen: was.seen + t.times, provenance: [...new Set([...was.provenance, ...t.provenance])] };
580
+ await this.d.client.setPayload([uid], { ts: now.ts });
581
+ this.d.ltm.put([now]);
582
+ });
583
+ }
584
+ if (fresh.length) {
585
+ await writeLtm(this.writer, fresh.map((f) => f.it));
586
+ this.d.ltm.put(fresh.map((f) => f.it));
587
+ }
588
+ }
589
+ async add(it) {
590
+ await writeLtm(this.writer, [it]);
591
+ this.d.ltm.put([it]);
592
+ }
593
+ /** The known item absorbs the new one: provenance, `seen`, the newest time, the higher importance — and, when the
594
+ * model merged the texts, the merged key (embedded again). Nothing of either is lost: the new item's own text, and the
595
+ * known one's before a rewrite, stay in the log as `merged` records the item lists (`absorbed`). */
596
+ async merge(known, it, text) {
597
+ const kept = [{ ...it, status: 'merged', mergedInto: known.uid }];
598
+ if (text)
599
+ kept.push({ ...known, uid: `${known.uid}@${known.fingerprint}`, status: 'merged', mergedInto: known.uid });
600
+ const merged = {
601
+ ...known,
602
+ ...(text ? { title: redactString(text.title), summary: redactString(text.summary), body: redactString(text.body) } : {}),
603
+ ts: Math.max(known.ts, it.ts), importance: Math.max(known.importance, it.importance), seen: known.seen + 1,
604
+ provenance: [...new Set([...known.provenance, ...it.provenance])],
605
+ files: [...new Set([...known.files, ...it.files])],
606
+ confidence: known.confidence === 'verified' || it.confidence === 'verified' ? 'verified' : known.confidence,
607
+ cues: [...new Set([...(known.cues ?? []), ...(it.cues ?? [])])],
608
+ related: [...new Set([...(known.related ?? []), ...(it.related ?? [])])].filter((u) => u !== known.uid),
609
+ concepts: [...new Map([...(known.concepts ?? []), ...(it.concepts ?? [])].map((c) => [conceptKey(c), c])).values()],
610
+ absorbed: [...new Set([...(known.absorbed ?? []), ...kept.map((k) => k.uid)])],
611
+ };
612
+ merged.fingerprint = fingerprint(merged.kind, merged.title, merged.summary, merged.body);
613
+ if (text)
614
+ await writeLtm(this.writer, [merged]);
615
+ else {
616
+ await this.d.client.setPayload([known.uid], { ts: merged.ts, importance: merged.importance });
617
+ if ((merged.related?.length ?? 0) > (known.related?.length ?? 0))
618
+ await this.d.client.setPayload([known.uid], { related: merged.related }, true);
619
+ if ((merged.concepts?.length ?? 0) > (known.concepts?.length ?? 0))
620
+ await writeConcepts(this.writer, [merged]);
621
+ // Learned again in other words: its new cues are found from now on.
622
+ if ((merged.cues?.length ?? 0) > (known.cues?.length ?? 0))
623
+ await writeCues(this.writer, [merged]);
624
+ }
625
+ this.d.ltm.put([...kept, merged]);
626
+ }
627
+ /** Two items about the same subject that say different things: each lists the other (memory_explore walks these). */
628
+ async link(a, b) {
629
+ const add = (x, y) => ({ ...x, related: [...new Set([...(x.related ?? []), y.uid])] });
630
+ const [one, two] = [add(a, b), add(b, a)];
631
+ this.d.ltm.put([one, two]);
632
+ await this.d.client.setPayload([one.uid], { related: one.related }, true);
633
+ await this.d.client.setPayload([two.uid], { related: two.related }, true);
634
+ }
635
+ /** Superseded: out of the index and of recall, kept in the log with what replaced it. */
636
+ async retire(known, by) {
637
+ const old = { ...known, status: 'superseded', supersededBy: by, supersededAt: (this.d.now ?? Date.now)() };
638
+ await writeLtm(this.writer, [old]);
639
+ this.d.ltm.put([old]);
640
+ }
641
+ async relate(batch) {
642
+ // Whole texts: a merge the model writes from a shortened body loses the rest of it.
643
+ const prompt = batch.map(({ it, known }, n) => `[p${n + 1}]\nNEW (${it.kind}): ${it.title}\n${it.summary}\n${it.body}\n\nKNOWN (${known.kind}): ${known.title}\n${known.summary}\n${known.body}`).join('\n\n');
644
+ // Room for every merged text in full (≈ 2.5 characters a token, the two sides together), with the decisions.
645
+ const room = batch.reduce((n, { it, known }) => n + Math.ceil((it.summary.length + it.body.length + known.summary.length + known.body.length) / 2.5), 0);
646
+ const a = await (this.d.ask ?? defaultAsk)({
647
+ purpose: 'memory', system: RELATE_SYSTEM, prompt: redactString(prompt), schema: RELATE_SCHEMA,
648
+ name: 'memory_reconcile', maxTokens: 900 * batch.length + 200 + room, timeoutMs: 300_000, consequence: 'so these memories are kept apart for now',
649
+ });
650
+ if (!('answer' in a))
651
+ return null;
652
+ const out = new Map();
653
+ for (const p of a.answer.pairs ?? []) {
654
+ if (typeof p.id !== 'string' || !RELATIONS.includes(String(p.relation)))
655
+ continue;
656
+ out.set(p.id, { relation: p.relation, title: (p.title ?? '').trim(), summary: (p.summary ?? '').trim(), body: (p.body ?? '').trim() });
657
+ }
658
+ return out;
659
+ }
660
+ }