memtrails 0.3.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.
Files changed (48) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +14 -9
  3. package/dist/core/consolidate.d.ts +23 -8
  4. package/dist/core/consolidate.js +52 -25
  5. package/dist/core/episodes.d.ts +1 -1
  6. package/dist/core/episodes.js +2 -8
  7. package/dist/core/extract.js +5 -5
  8. package/dist/core/gather.d.ts +3 -0
  9. package/dist/core/gather.js +12 -8
  10. package/dist/core/model.d.ts +8 -4
  11. package/dist/core/model.js +29 -40
  12. package/dist/core/profiles.d.ts +1 -1
  13. package/dist/core/profiles.js +7 -6
  14. package/dist/core/qdrant-binary.d.ts +39 -5
  15. package/dist/core/qdrant-binary.js +185 -48
  16. package/dist/core/qdrant-client.d.ts +6 -18
  17. package/dist/core/qdrant-client.js +38 -90
  18. package/dist/core/qdrant-process.d.ts +22 -1
  19. package/dist/core/qdrant-process.js +190 -22
  20. package/dist/core/rebuild.d.ts +5 -1
  21. package/dist/core/rebuild.js +19 -4
  22. package/dist/core/recall-log.d.ts +3 -0
  23. package/dist/core/recall-log.js +6 -1
  24. package/dist/core/recall.js +14 -5
  25. package/dist/core/redact.d.ts +8 -0
  26. package/dist/core/redact.js +48 -9
  27. package/dist/core/sleep.d.ts +4 -3
  28. package/dist/core/sleep.js +19 -11
  29. package/dist/core/store.d.ts +7 -1
  30. package/dist/core/store.js +101 -33
  31. package/dist/core/text.d.ts +4 -2
  32. package/dist/core/text.js +17 -5
  33. package/dist/core/words.js +6 -2
  34. package/dist/core/writer.d.ts +2 -1
  35. package/dist/core/writer.js +9 -3
  36. package/dist/importers/files.js +53 -10
  37. package/dist/importers/git.js +3 -1
  38. package/dist/importers/mbox.js +22 -7
  39. package/dist/importers/slack.js +5 -2
  40. package/dist/importers/text.js +85 -14
  41. package/dist/llm.d.ts +1 -8
  42. package/dist/llm.js +11 -5
  43. package/dist/memory.d.ts +6 -2
  44. package/dist/memory.js +215 -47
  45. package/dist/records.js +6 -3
  46. package/dist/tools/agent-tools.d.ts +2 -5
  47. package/dist/tools/agent-tools.js +37 -21
  48. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -3,6 +3,38 @@
3
3
  All notable changes are listed here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
4
4
  versions follow [Semantic Versioning](https://semver.org/).
5
5
 
6
+ ## [Unreleased]
7
+
8
+ ## [0.4.0] — 2026-11-04
9
+
10
+ - Prompt examples are invented now. The extraction and gathering prompts had a few example sentences taken from LoCoMo
11
+ conversations, one of them (in `gather`'s notes rule) from a held-out conversation. Their effect on the published
12
+ results, measured on the judged answers: leaving out every held-out question that mentions any of those words or
13
+ people (48 questions) gives 81.1 against Mem0's 76.6 (+111/−55), against 80.9 and 76.5 on all 1,307. The question
14
+ the example came from was answered wrong by both systems.
15
+ - The search index layout is 7: `kinds` now selects records by their kind on every point and learned items by theirs,
16
+ so an existing index is rebuilt from the logs on the next open.
17
+ - Fixes: a crash in the middle of a write no longer loses the records added after it; consolidation no longer
18
+ overwrites records changed or removed while the model answers; a title cut through an emoji no longer fails a write;
19
+ a thread that never goes quiet is consolidated; imported history is related and kept like new knowledge (`learnedAt`);
20
+ a malformed `from`/`to` is an error; `gather` and `sleep` report `paused` when the model did not read.
21
+ - `bench/audited.ts`: judged LoCoMo answers without the questions whose golden answer an independent audit
22
+ ([locomo-audit](https://github.com/dial481/locomo-audit)) found wrong, dropped for every system alike. As shipped,
23
+ 84.8 against Mem0's 79.9 (+109/−49).
24
+ - `gather`: a memory a small model keeps by quoting its whole line (`[m3] (2023-06-03) …`) now counts; it read as nothing
25
+ kept before.
26
+
27
+ - Security: the secret filter catches common key formats it missed (GITHUB_TOKEN=, AWS_SECRET_ACCESS_KEY=, JSON-style
28
+ secrets, GitLab, Hugging Face, npm, SendGrid, Telegram, the whole password of a URL); crafted e-mail and HTML can no
29
+ longer make the importers or the filter take quadratic time; downloads are verified on disk in a temp folder of their
30
+ own under a lock, with a stall timeout, and the Qdrant binary runs only while it is this user's alone; Qdrant gets a
31
+ minimal environment and must prove it is ours before it is given the key; an OpenAI key is sent to OpenAI only; the
32
+ memory folder is private; Slack channel folders cannot lead out of the export.
33
+ - One process at a time in a memory folder (a lock); consolidate, sleep, rebuild and remove run one at a time; `close()`
34
+ stops the running job and waits for it; an open that fails stops what it started; `remove()` takes the record's kept
35
+ episode out of the long-term log too; `MEMTRAILS_EMBED_URL` and `MEMTRAILS_RERANK_URL` are used.
36
+ - The release is built and tested in a job without publishing rights; actions are pinned to commits.
37
+
6
38
  ## [0.3.0] — 2026-11-02
7
39
 
8
40
  - `gather(q, { notes: true })`: the same model call also writes what the kept memories say that answers the question,
package/README.md CHANGED
@@ -17,7 +17,7 @@ import { openMemory } from 'memtrails';
17
17
  const memory = await openMemory({ home: './memory' });
18
18
 
19
19
  await memory.add([
20
- { id: 'msg-1', kind: 'email', author: 'Ana', title: 'Gateway certificate expired', at: '2026-04-02',
20
+ { id: 'msg-1', kind: 'email', author: 'Ana', title: 'Proxy certificate expired', at: '2026-04-02',
21
21
  text: 'The TLS certificate expired on the 1st. Renewal stopped because the cron job was removed in March.' },
22
22
  { id: 'c9f1e2', kind: 'commit', author: 'Ion', title: 'Restore certificate renewal cron', at: '2026-04-03',
23
23
  text: 'Files: deploy/cron/renew.sh' },
@@ -41,7 +41,8 @@ await memory.close();
41
41
  against 50.3% for plain search, and most of a cross-encoder reranker's gain, in about 3 ms on one CPU thread.
42
42
  - **Measured, honestly.** On all 1,307 held-out LoCoMo questions with 6,000 characters to read, memtrails alone on a
43
43
  CPU scores 77.4 (strict judge) against Mem0's 76.5 — a tie, reported as one; with `gather` reading what search
44
- found and noting what it says, 80.9 — ahead beyond chance. [Benchmarks](docs/BENCHMARKS.md).
44
+ found and noting what it says, 80.9 — ahead beyond chance; 84.8 against 79.9 without the questions whose official
45
+ answer an independent audit found wrong. [Benchmarks](docs/BENCHMARKS.md).
45
46
  - **Explorable.** A concept graph links what records share — people, places, systems, names — so an agent can walk
46
47
  from what it found to what a search for the words does not find.
47
48
  - **Built for agents.** `memory.tools()` gives `memory_search`, `memory_open` and `memory_explore` with zod and JSON
@@ -67,9 +68,9 @@ await memory.add({
67
68
  source: 'work-mailbox', // part of the identity
68
69
  kind: 'email', // any word: email, message, note, memo, article, commit, ticket, …
69
70
  collection: 'ops', // search can be scoped to collections
70
- thread: 'gateway-cert', // records that belong together
71
+ thread: 'proxy-cert', // records that belong together
71
72
  author: 'Ana Popescu',
72
- title: 'Gateway certificate expired',
73
+ title: 'Proxy certificate expired',
73
74
  text: '…',
74
75
  at: '2026-04-02T09:14:00Z', // ms, ISO string or Date
75
76
  url: 'https://mail.example/…',
@@ -111,7 +112,7 @@ await memory.search({ query: 'who renewed the certificate?', limit: 8 });
111
112
  await memory.search({ query: 'standup notes', collections: ['ops'], kinds: ['message'], from: '2026-04', to: '2026-04' });
112
113
 
113
114
  // The same results as a reader is shown them: one line a memory, best first or oldest first.
114
- await memory.searchText({ query: 'what happened with the gateway?', order: 'time' });
115
+ await memory.searchText({ query: 'what happened with the proxy?', order: 'time' });
115
116
 
116
117
  // The best sentences of what was found, for a small context window.
117
118
  await memory.room({ query: 'what made the renewal stop?', chars: 6000 });
@@ -120,7 +121,7 @@ await memory.room({ query: 'what made the renewal stop?', chars: 6000 });
120
121
  await memory.open(hits[0].id);
121
122
 
122
123
  // What the concept graph reaches from a topic or from items, that search did not find.
123
- await memory.explore({ topic: 'the gateway' });
124
+ await memory.explore({ topic: 'the proxy' });
124
125
  ```
125
126
 
126
127
  With dates, what happened within them comes first and the rest follows: a question that names a time is rarely only
@@ -141,7 +142,7 @@ const memory = await openMemory({
141
142
  });
142
143
  await memory.consolidate(); // after adding
143
144
  await memory.sleep(); // once a day
144
- await memory.gather({ query: 'every doctor I saw this year' }); // a set, for counting and listing questions
145
+ await memory.gather({ query: 'every plumber we called this year' }); // a set, for counting and listing questions
145
146
  ```
146
147
 
147
148
  Any provider works: pass `llm: { baseUrl, model, apiKey }` for an OpenAI-compatible server (a hosted model, vLLM,
@@ -174,7 +175,7 @@ for (const t of tools) server.tool(t.name, t.description, t.inputSchema, t.handl
174
175
  | Environment | |
175
176
  |---|---|
176
177
  | `MEMTRAILS_CACHE` | Where downloads live (default `~/.cache/memtrails`). |
177
- | `MEMTRAILS_MODEL`, `MEMTRAILS_LLM_API_KEY` or `OPENAI_API_KEY`, `MEMTRAILS_LLM_BASE_URL` | The language model when `llm` is not given. |
178
+ | `MEMTRAILS_MODEL`, `MEMTRAILS_LLM_API_KEY` or `OPENAI_API_KEY`, `MEMTRAILS_LLM_BASE_URL` | The language model when `llm` is not given. `OPENAI_API_KEY` is sent only to OpenAI's own address. |
178
179
  | `MEMTRAILS_EMBED_URL`, `MEMTRAILS_RERANK_URL` (+ `_KEY`, `_MODEL`) | Servers, when not given in options. |
179
180
 
180
181
  ## How it works
@@ -191,17 +192,21 @@ the [API reference](docs/API.md), and [what was tried and why](docs/RESEARCH.md)
191
192
  |---|---|---|---|
192
193
  | LoCoMo, all 1,307 held-out questions, 6,000 characters, strict judge, `gather` at 40 with notes | **80.9** | 76.5 | — |
193
194
  | the same, Mem0's own lenient judge | **91.9** | 87.8 | — |
195
+ | the same, strict, without the 80 questions whose golden answer is wrong³ | **84.8** | 79.9 | — |
194
196
  | the same, `gather` without notes, strict / lenient | 79.5 / 91.4 | 76.5 / 87.8 | — |
195
197
  | the same with `search` alone, no model when searching, strict / lenient | 77.4 / 89.3 | 76.5 / 87.8 | — |
196
198
  | LoCoMo, 1,307 held-out questions, 28,000 characters, one search¹ | 79.9 | 80.6 | 42.1 |
197
199
  | LoCoMo, the same with an agent using each system's own tools¹ | **79.0** | 77.1 | 47.0 |
200
+ | LongMemEval-S, 60-question sample, 6,000 characters, strict judge, `gather` at 40 with notes (a tie) | 90.0 | 88.3 | — |
198
201
  | LongMemEval-S retrieval, strict all sessions @5 (467)² | **96.15** | — | — |
199
202
 
200
203
  The same reader and judges for every system. With `gather` (a model reads what search found, keeps what the answer
201
204
  needs and notes what it says) memtrails leads Mem0 beyond chance: +113/−55 questions strict, +78/−25 lenient (both
202
205
  p < 0.001). With `search` alone,
203
206
  the small-room and one-search comparisons are ties; the agent lead is
204
- small and was judged once. ¹ An earlier build (2026-10-27). ² gbrain-evals publishes 95.96 on the same count: a tie. [Full results, protocol and limitations](docs/BENCHMARKS.md).
207
+ small and was judged once. ¹ An earlier build (2026-10-27). ² gbrain-evals publishes 95.96 on the same count: a tie. ³ As found by an
208
+ independent audit of LoCoMo ([locomo-audit](https://github.com/dial481/locomo-audit)), dropped for both systems;
209
+ +109/−49 (p < 0.001), `bench/audited.ts` reproduces it. [Full results, protocol and limitations](docs/BENCHMARKS.md).
205
210
 
206
211
  ## Limitations
207
212
 
@@ -7,7 +7,7 @@ export declare const SAME_EPISODE = 0.97;
7
7
  * days are two observations, and a list question needs both. */
8
8
  export declare const SAME_OBSERVATION = 0.97;
9
9
  export declare const RELATED = 0.8;
10
- /** A conversation is consolidated once it has been quiet this long — or its entries are a day old. */
10
+ /** A conversation is consolidated once it has been quiet this long — or its oldest waiting entry is a day old. */
11
11
  export declare const SETTLED_MS: number;
12
12
  /** Extraction calls in flight: they are independent, and one at a time made a backfill take days (~86 s a window). */
13
13
  export declare const EXTRACT_CONCURRENCY = 8;
@@ -57,6 +57,12 @@ export interface ConsolidationReport {
57
57
  * them), and how many go in one ask. */
58
58
  export declare const CUE_BACKLOG = 40;
59
59
  export type Relation = 'same' | 'updates' | 'contradicts' | 'related' | 'different';
60
+ export type Decision = {
61
+ relation: Relation;
62
+ title: string;
63
+ summary: string;
64
+ body: string;
65
+ };
60
66
  /** Links an item keeps to others about the same subject (exploration walks them), newest last. */
61
67
  export declare const MAX_LINKS = 24;
62
68
  /** Pairs asked about together: at most 8, and at most RELATE_CHARS of their texts — a long pair on its own. */
@@ -65,6 +71,16 @@ export declare function relateBatches<T extends {
65
71
  it: MemoryItem;
66
72
  known: MemoryItem;
67
73
  }>(pairs: T[]): T[][];
74
+ /** When an item was written to long-term memory. `ts` is its source's own time — for history imported today, long ago —
75
+ * so "learned since the last sleep" and "unused for half a year" read this. An item written before the field existed
76
+ * has only `ts`. */
77
+ export declare const learnedAt: (m: {
78
+ ts: number;
79
+ learnedAt?: number;
80
+ }) => number;
81
+ export declare const stamped: (it: MemoryItem, at: number) => MemoryItem;
82
+ /** The number of a label the model wrote back: `p3`, or the line it labels quoted whole (`[p3] NEW …`) — 0 for none. */
83
+ export declare function labelNo(id: string, letter: 'p' | 'i'): number;
68
84
  export declare class Consolidator {
69
85
  private readonly d;
70
86
  private readonly vecs;
@@ -128,17 +144,16 @@ export declare class Consolidator {
128
144
  summary: string;
129
145
  body: string;
130
146
  } | null): Promise<void>;
131
- /** Two items about the same subject that say different things: each lists the other (memory_explore walks these). */
147
+ /** Two items about the same subject that say different things: each lists the other (memory_explore walks these) —
148
+ * the newest MAX_LINKS, so an item on a busy subject does not grow its list, and its payload, without end. */
132
149
  link(a: MemoryItem, b: MemoryItem): Promise<void>;
133
150
  /** Superseded: out of the index and of recall, kept in the log with what replaced it. */
134
151
  retire(known: MemoryItem, by: string): Promise<void>;
152
+ /** The model's decision for each pair, by its label (`p1`, …) — or why it did not answer. */
135
153
  relate(batch: Array<{
136
154
  it: MemoryItem;
137
155
  known: MemoryItem;
138
- }>): Promise<Map<string, {
139
- relation: Relation;
140
- title: string;
141
- summary: string;
142
- body: string;
143
- }> | null>;
156
+ }>): Promise<Map<string, Decision> | {
157
+ paused: string;
158
+ }>;
144
159
  }
@@ -29,7 +29,7 @@ export const SAME_EPISODE = 0.97;
29
29
  * days are two observations, and a list question needs both. */
30
30
  export const SAME_OBSERVATION = 0.97;
31
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. */
32
+ /** A conversation is consolidated once it has been quiet this long — or its oldest waiting entry is a day old. */
33
33
  export const SETTLED_MS = 60 * 60_000;
34
34
  /** Extraction calls in flight: they are independent, and one at a time made a backfill take days (~86 s a window). */
35
35
  export const EXTRACT_CONCURRENCY = 8;
@@ -116,32 +116,41 @@ export function relateBatches(pairs) {
116
116
  out.push(cur);
117
117
  return out;
118
118
  }
119
+ /** When an item was written to long-term memory. `ts` is its source's own time — for history imported today, long ago —
120
+ * so "learned since the last sleep" and "unused for half a year" read this. An item written before the field existed
121
+ * has only `ts`. */
122
+ export const learnedAt = (m) => m.learnedAt ?? m.ts;
123
+ export const stamped = (it, at) => ({ ...it, learnedAt: at });
124
+ /** The number of a label the model wrote back: `p3`, or the line it labels quoted whole (`[p3] NEW …`) — 0 for none. */
125
+ export function labelNo(id, letter) {
126
+ return Number(new RegExp(`\\b${letter}(\\d+)`).exec(id)?.[1] ?? 0);
127
+ }
119
128
  /** One remembered episode or candidate as a long-term item (not yet reconciled). */
120
129
  function itemOf(c, src, now, sources = [src]) {
121
130
  const project = c.scope === 'project' ? src.project : c.scope;
122
131
  // The files its requests changed, from their recorded steps — by code, not the model: paths are what summaries lose.
123
132
  const files = [...new Set(sources.flatMap((e) => e.files))];
124
- return {
133
+ return stamped({
125
134
  uid: `mem:${fingerprint(c.kind, c.scope, project, c.title, c.summary)}`, kind: c.kind, scope: c.scope, project: src.project,
126
135
  engine: src.engine, session: src.session, cwd: src.cwd, where: src.title, ts: src.ts || now, firstTs: src.ts || now,
127
136
  title: c.title, summary: c.summary, body: c.body, files, appliesWhen: c.appliesWhen, confidence: c.confidence,
128
137
  importance: c.importance, status: 'active', provenance: c.provenance, seen: 1,
129
138
  fingerprint: fingerprint(c.kind, c.title, c.summary, c.body), cues: c.cues, concepts: c.concepts,
130
139
  ...(c.origin ? { origin: c.origin } : {}),
131
- };
140
+ }, now);
132
141
  }
133
- function episodeItem(e, key) {
134
- return {
142
+ function episodeItem(e, key, now) {
143
+ return stamped({
135
144
  uid: `ep:${e.uid}`, kind: 'episode', scope: 'project', project: e.project, engine: e.engine, session: e.session, cwd: e.cwd,
136
145
  where: e.title, ts: e.ts, firstTs: e.ts, title: key.title || e.title, summary: key.summary, body: episodeBody(e), files: e.files,
137
146
  appliesWhen: '', confidence: 'observed', importance: e.files.length ? 3 : 2, status: 'active', provenance: [e.uid], seen: 1,
138
147
  fingerprint: fingerprint(e.request, e.reply, e.files), cues: key.cues ?? [], concepts: key.concepts ?? [],
139
148
  ...(key.outcome ? { outcome: key.outcome } : {}), ...(key.origin ? { origin: key.origin } : {}),
140
- };
149
+ }, now);
141
150
  }
142
151
  /** 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 {
152
+ function observationItem(o, src, now) {
153
+ return stamped({
145
154
  uid: `obs:${fingerprint(o.text, o.eventAt, o.provenance[0] ?? src.uid)}`, kind: 'observation', scope: 'person', project: src.project,
146
155
  engine: src.engine, session: src.session, cwd: src.cwd, where: src.title, ts: src.ts, firstTs: src.ts,
147
156
  title: cut(o.text, 100), summary: o.text, body: '', files: [], appliesWhen: '', confidence: 'stated', importance: 2,
@@ -149,7 +158,7 @@ function observationItem(o, src) {
149
158
  cues: o.cues ?? [], concepts: o.concepts, ...(o.eventAt ? { eventAt: o.eventAt } : {}),
150
159
  ...(o.startAt ? { startAt: o.startAt } : {}), ...(o.endAt ? { endAt: o.endAt } : {}), phase: o.phase, ...(o.about ? { about: o.about } : {}),
151
160
  ...(o.origin ? { origin: o.origin } : {}),
152
- };
161
+ }, now);
153
162
  }
154
163
  function cosine(a, b) {
155
164
  let ab = 0;
@@ -225,13 +234,16 @@ export class Consolidator {
225
234
  for (const [uid, m] of this.d.stm.entries()) {
226
235
  if (m.done || m.synthetic)
227
236
  continue;
228
- const s = by.get(m.session) ?? { uids: [], last: 0 };
237
+ const s = by.get(m.session) ?? { uids: [], first: Infinity, last: 0 };
229
238
  s.uids.push(uid);
239
+ s.first = Math.min(s.first, m.ts);
230
240
  s.last = Math.max(s.last, m.ts);
231
241
  by.set(m.session, s);
232
242
  }
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))
243
+ // Quiet for an hour, or waiting a day since its oldest unread entry: a thread that never goes quiet (a message every
244
+ // half hour) is still read.
245
+ return [...by.entries()].filter(([, s]) => now - s.last >= SETTLED_MS || now - s.first >= DAY_MS)
246
+ .map(([session, { uids, last }]) => ({ session, uids, last }))
235
247
  .sort((a, b) => b.last - a.last);
236
248
  }
237
249
  /** Consolidate up to `maxWindows` windows. Stops early when the model pauses or the worker is stopped.
@@ -304,17 +316,23 @@ export class Consolidator {
304
316
  const learned = new Set(x.candidates.flatMap((c) => c.provenance));
305
317
  const kept = x.episodes.filter((k) => byUid.has(k.uid) && !learned.has(k.uid));
306
318
  const fresh = [
307
- ...kept.map((k) => episodeItem(byUid.get(k.uid), k)),
319
+ ...kept.map((k) => episodeItem(byUid.get(k.uid), k, now)),
308
320
  ...x.candidates.map((c) => itemOf(c, byUid.get(c.provenance.at(-1) ?? '') ?? head, now, c.provenance.flatMap((u) => byUid.get(u) ?? []))),
309
321
  ];
310
322
  await this.reconcile(fresh, r);
311
- await this.observe(x.observations.map((o) => observationItem(o, byUid.get(o.provenance.at(-1) ?? '') ?? head)), r);
323
+ await this.observe(x.observations.map((o) => observationItem(o, byUid.get(o.provenance.at(-1) ?? '') ?? head, now)), r);
312
324
  r.dropped += x.dropped;
313
325
  r.episodes += kept.length;
314
326
  // Read: marked done in the queue. The requests themselves stay searchable — the raw record is the truth and the
315
327
  // 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 })));
328
+ // LongMemEval: 47 of 60 misses were facts said in passing inside a request). Each as it is now, not as it was read
329
+ // before the model's long answer: one removed meanwhile stays removed, one changed meanwhile is read again.
330
+ this.d.stm.put(win.flatMap((e) => {
331
+ if (this.d.stm.meta(e.uid)?.fp !== e.fingerprint)
332
+ return [];
333
+ const cur = this.d.stm.get(e.uid);
334
+ return cur ? [{ ...cur, done: true }] : [];
335
+ }));
318
336
  r.windows++;
319
337
  // One at a time, each window's keys are dropped after it; windows at once share the cache until the run ends.
320
338
  if (together === 1)
@@ -455,7 +473,7 @@ export class Consolidator {
455
473
  return { paused: 'the fast model is busy' };
456
474
  const out = [];
457
475
  for (const x of a.answer.items ?? []) {
458
- const it = typeof x.id === 'string' ? items[Number(x.id.slice(1)) - 1] : undefined;
476
+ const it = typeof x.id === 'string' ? items[labelNo(x.id, 'i') - 1] : undefined;
459
477
  const cues = it?.cues?.length ? it.cues : cleanCues(x.cues);
460
478
  const concepts = it?.concepts?.length ? it.concepts : cleanConcepts(x.concepts);
461
479
  if (it && (cues.length || concepts.length))
@@ -497,8 +515,9 @@ export class Consolidator {
497
515
  const batches = relateBatches(ask);
498
516
  const answers = await Promise.all(batches.map((b) => this.relate(b)));
499
517
  for (const [b, batch] of batches.entries()) {
518
+ const got = answers[b];
500
519
  for (const [n, { it, known }] of batch.entries()) {
501
- const d = answers[b]?.get(`p${n + 1}`);
520
+ const d = got instanceof Map ? got.get(`p${n + 1}`) : undefined;
502
521
  if (!d || d.relation === 'different') {
503
522
  await this.add(it);
504
523
  r.added++;
@@ -605,16 +624,18 @@ export class Consolidator {
605
624
  files: [...new Set([...known.files, ...it.files])],
606
625
  confidence: known.confidence === 'verified' || it.confidence === 'verified' ? 'verified' : known.confidence,
607
626
  cues: [...new Set([...(known.cues ?? []), ...(it.cues ?? [])])],
608
- related: [...new Set([...(known.related ?? []), ...(it.related ?? [])])].filter((u) => u !== known.uid),
627
+ related: [...new Set([...(known.related ?? []), ...(it.related ?? [])])].filter((u) => u !== known.uid).slice(-MAX_LINKS),
609
628
  concepts: [...new Map([...(known.concepts ?? []), ...(it.concepts ?? [])].map((c) => [conceptKey(c), c])).values()],
610
629
  absorbed: [...new Set([...(known.absorbed ?? []), ...kept.map((k) => k.uid)])],
611
630
  };
612
631
  merged.fingerprint = fingerprint(merged.kind, merged.title, merged.summary, merged.body);
632
+ // Changed now: what sleep relates next.
633
+ merged.learnedAt = Math.max(learnedAt(known), learnedAt(it));
613
634
  if (text)
614
635
  await writeLtm(this.writer, [merged]);
615
636
  else {
616
637
  await this.d.client.setPayload([known.uid], { ts: merged.ts, importance: merged.importance });
617
- if ((merged.related?.length ?? 0) > (known.related?.length ?? 0))
638
+ if (merged.related?.join('\n') !== (known.related ?? []).join('\n'))
618
639
  await this.d.client.setPayload([known.uid], { related: merged.related }, true);
619
640
  if ((merged.concepts?.length ?? 0) > (known.concepts?.length ?? 0))
620
641
  await writeConcepts(this.writer, [merged]);
@@ -624,9 +645,10 @@ export class Consolidator {
624
645
  }
625
646
  this.d.ltm.put([...kept, merged]);
626
647
  }
627
- /** Two items about the same subject that say different things: each lists the other (memory_explore walks these). */
648
+ /** Two items about the same subject that say different things: each lists the other (memory_explore walks these) —
649
+ * the newest MAX_LINKS, so an item on a busy subject does not grow its list, and its payload, without end. */
628
650
  async link(a, b) {
629
- const add = (x, y) => ({ ...x, related: [...new Set([...(x.related ?? []), y.uid])] });
651
+ const add = (x, y) => ({ ...x, related: [...(x.related ?? []).filter((u) => u !== y.uid), y.uid].slice(-MAX_LINKS) });
630
652
  const [one, two] = [add(a, b), add(b, a)];
631
653
  this.d.ltm.put([one, two]);
632
654
  await this.d.client.setPayload([one.uid], { related: one.related }, true);
@@ -638,6 +660,7 @@ export class Consolidator {
638
660
  await writeLtm(this.writer, [old]);
639
661
  this.d.ltm.put([old]);
640
662
  }
663
+ /** The model's decision for each pair, by its label (`p1`, …) — or why it did not answer. */
641
664
  async relate(batch) {
642
665
  // Whole texts: a merge the model writes from a shortened body loses the rest of it.
643
666
  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');
@@ -647,13 +670,17 @@ export class Consolidator {
647
670
  purpose: 'memory', system: RELATE_SYSTEM, prompt: redactString(prompt), schema: RELATE_SCHEMA,
648
671
  name: 'memory_reconcile', maxTokens: 900 * batch.length + 200 + room, timeoutMs: 300_000, consequence: 'so these memories are kept apart for now',
649
672
  });
673
+ if ('unavailable' in a)
674
+ return { paused: a.unavailable };
650
675
  if (!('answer' in a))
651
- return null;
676
+ return { paused: 'the fast model is busy' };
652
677
  const out = new Map();
653
678
  for (const p of a.answer.pairs ?? []) {
654
- if (typeof p.id !== 'string' || !RELATIONS.includes(String(p.relation)))
679
+ // A small model may quote the label's line rather than the label: the number is looked for.
680
+ const n = typeof p.id === 'string' ? labelNo(p.id, 'p') : 0;
681
+ if (!n || !RELATIONS.includes(String(p.relation)))
655
682
  continue;
656
- out.set(p.id, { relation: p.relation, title: (p.title ?? '').trim(), summary: (p.summary ?? '').trim(), body: (p.body ?? '').trim() });
683
+ out.set(`p${n}`, { relation: p.relation, title: (p.title ?? '').trim(), summary: (p.summary ?? '').trim(), body: (p.body ?? '').trim() });
657
684
  }
658
685
  return out;
659
686
  }
@@ -22,7 +22,7 @@ export declare const isConversation: (e: {
22
22
  engine?: string;
23
23
  }) => boolean;
24
24
  /** A record's body: what its key summarises, its chunks are cut from, and opening it shows — whole, never shortened. */
25
- export declare function episodeBody(e: Pick<ReadableRequest, 'request' | 'reply' | 'files'> & Partial<Pick<ReadableRequest, 'said' | 'steps' | 'engine'>>): string;
25
+ export declare function episodeBody(e: Pick<ReadableRequest, 'request' | 'reply' | 'files'> & Partial<Pick<ReadableRequest, 'said' | 'engine'>>): string;
26
26
  /** For each day a request was made, the days a time said relatively that day means — yesterday, the week before, the
27
27
  * last weekend, each of the six days before by name, this week, last month, last year (UTC as the requests' times) —
28
28
  * so the model reads "last week" or "on Friday" off a line instead of counting. "Last week" is the seven days before,
@@ -9,10 +9,7 @@ export const isConversation = (e) => e.engine === 'conversation';
9
9
  export function episodeBody(e) {
10
10
  if (e.engine !== undefined && !isConversation(e))
11
11
  return e.reply.trim() ? `${e.request}\n${e.reply}` : e.request;
12
- const parts = [`Asked: ${e.request}`];
13
- if (e.steps?.length)
14
- parts.push(`Steps:\n${e.steps.join('\n')}`);
15
- parts.push(`Done: ${e.said?.trim() ? e.said : e.reply}`);
12
+ const parts = [`Asked: ${e.request}`, `Done: ${e.said?.trim() ? e.said : e.reply}`];
16
13
  if (e.files.length)
17
14
  parts.push(`Files: ${e.files.join(', ')}`);
18
15
  return parts.join('\n');
@@ -41,10 +38,7 @@ export function renderForExtraction(e, n) {
41
38
  // Whole: a record too long for one window is read in parts and its parts joined (extract.ts), never shortened.
42
39
  if (e.engine !== undefined && !isConversation(e))
43
40
  return [`## Record ${n} · ${when} · ${e.engine}`, e.request, e.reply].filter(Boolean).join('\n');
44
- const who = e.typed === 'human' ? 'typed by the person' : 'sent to the agent';
45
- const parts = [`## Request ${n} · ${when} · ${who}`, e.request];
46
- if (e.steps.length)
47
- parts.push(`Steps:\n${e.steps.join('\n')}`);
41
+ const parts = [`## Request ${n} · ${when} · typed by the person`, e.request];
48
42
  if (e.said)
49
43
  parts.push(`Agent said:\n${e.said}`);
50
44
  if (e.files.length)
@@ -39,15 +39,15 @@ what was decided, done, built, changed, fixed, deployed or found, and when. One
39
39
  several in one, none left out to keep the list short. Facts said in passing inside another request count; a small or
40
40
  one-off event counts; when in doubt, keep it. In a conversation between people, the replies are another person's words:
41
41
  what they tell about themselves counts as much. For each:
42
- - text: 10 to 60 words that stand alone — the event or fact itself, as told ("Melanie took her family camping in the
43
- mountains"), never "On <date>, X said that …" (the date has its own field). Name who and what (names, never "he",
42
+ - text: 10 to 60 words that stand alone — the event or fact itself, as told ("Rosa took her nephews kayaking on Lake
43
+ Bled"), never "On <date>, X said that …" (the date has its own field). Name who and what (names, never "he",
44
44
  "she", "it", "they"); keep every proper noun, title, number and quoted phrase exactly. A time said relatively stays
45
45
  as said, with the date it means, read off the Calendar line of the day it was said: "last week (the week before 26 June
46
46
  2023)", "yesterday (7 May 2023)", "on Friday (12 April 2024)". When the part names something only by description
47
- ("the book you recommended", "my home country", "the kids") and KNOWN FROM OTHER CONVERSATIONS names it, write the name too ("Becoming Nicole",
48
- "Sweden", "her three children") — never a fact from KNOWN that this part does not tell. What the person or a speaker
47
+ ("the book you recommended", "my home country", "the kids") and KNOWN FROM OTHER CONVERSATIONS names it, write the name too ("The Overstory",
48
+ "Portugal", "her three children") — never a fact from KNOWN that this part does not tell. What the person or a speaker
49
49
  says about their own life is true as told: no commentary on what the conversation did or did not confirm. A plan is
50
- written as a plan ("Melanie planned to go swimming with the kids"). When it replaces something said before, say both
50
+ written as a plan ("Rosa planned to take the kids to the aquarium"). When it replaces something said before, say both
51
51
  (now X — before, Y).
52
52
  - about: who or what it is about, named as the conversation names them ("the person" for the one whose requests these
53
53
  are, unless the conversation names them).
@@ -10,6 +10,9 @@ export interface Gathered {
10
10
  /** With `notes`: what the kept memories say that answers the question, a few short lines (empty otherwise). Show them
11
11
  * before the memories, as notes on them: on LoCoMo this took 79.5 to 80.9 (docs/BENCHMARKS.md). */
12
12
  notes: string[];
13
+ /** Set when the model did not read some candidates (none configured, unreachable, busy): why. They are all kept, by
14
+ * rank — the result is then a wide search, not a gathered set. */
15
+ paused?: string;
13
16
  }
14
17
  /** `from`/`to`: the time the question asks about ("on 10 April 2022", "in October 2023"). What happened then is searched
15
18
  * for besides everything else and read first — not instead: a question also names dates it only counts from ("as of
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Gathering: a SET of memories for a question, not a top-k.
3
3
  *
4
- * A question that counts, lists, totals or compares ("how many doctors did I see", "what did I spend on luxury items")
4
+ * A question that counts, lists, totals or compares ("how many plumbers did we call", "what did I spend on camera gear")
5
5
  * needs every instance, and one ranked below the cut is a wrong answer however good the rest are — measured on
6
6
  * LongMemEval's multi-session questions: every fact our answers missed was in the store, as a dated observation, and not
7
7
  * in the room. Mem0 and Graphiti hand the reader a fixed number by similarity; here the fast model reads them:
@@ -34,7 +34,7 @@ cancelled, sold, replaced, ended, a new amount) or dates it. When unsure, keep i
34
34
  wrong, an extra one does not.
35
35
 
36
36
  In "searches", write up to ${MORE} short searches for instances the question could count that none of these memories
37
- shows — other kinds, other words for the same thing (for "luxury items": "designer handbag", "jewelry", "watch") —
37
+ shows — other kinds, other words for the same thing (for "camera gear": "lens", "tripod", "memory card") —
38
38
  in the language of the question. Leave it empty when nothing is missing. Never translate.`;
39
39
  const SCHEMA = {
40
40
  type: 'object', additionalProperties: false, required: ['keep', 'searches'],
@@ -48,7 +48,7 @@ const SCHEMA = {
48
48
  const NOTES_RULE = `
49
49
 
50
50
  In "notes", write what the memories you kept say that answers the question, in a few short lines: every instance with
51
- its date as the memories give it ("Books Tim has read: Harry Potter (2023-08-02), The Name of the Wind (2023-08-11)"),
51
+ its date as the memories give it ("Concerts Lena went to: a jazz trio at the Blue Room (2024-03-02), the orchestra's Mahler night (2024-04-19)"),
52
52
  names, numbers and words as written. Only what they state, as plain statements: never write that something is not said,
53
53
  not given, unclear or unknown — leave it out instead, since other memories may hold it — and never a guess. Leave notes
54
54
  empty when the memories you kept say nothing about it. In the language of the question.`;
@@ -78,16 +78,18 @@ async function pick(ask, question, list, notes = false) {
78
78
  name: 'memory_gather', maxTokens: 40 * part.length + (notes ? 1400 : 600), timeoutMs: 120_000, consequence: 'so these memories are kept unread',
79
79
  });
80
80
  if (!('answer' in a))
81
- return { keep: part.map((h) => h.id), searches: [], notes: [] };
81
+ return { keep: part.map((h) => h.id), searches: [], notes: [], paused: 'unavailable' in a ? a.unavailable : 'the fast model is busy' };
82
82
  const got = a.answer;
83
83
  const ids = Array.isArray(got.keep) ? got.keep.filter((x) => typeof x === 'string') : [];
84
- const keep = ids.map((x) => part[Number(x.replace(/^m/, '')) - 1]?.id).filter((x) => !!x);
84
+ // A small model can quote the line it means ("[m3] (2023-06-03) …"), so the label is looked for, not assumed to be all of it.
85
+ const keep = ids.map((x) => part[Number(/\bm(\d+)/.exec(x)?.[1] ?? 0) - 1]?.id).filter((x) => !!x);
85
86
  const searches = Array.isArray(got.searches) ? got.searches.filter((x) => typeof x === 'string' && !!x.trim()) : [];
86
87
  const said = notes && Array.isArray(got.notes) ? got.notes.filter((x) => typeof x === 'string' && !!x.trim()).map((x) => redactString(x.trim())) : [];
87
- return { keep, searches, notes: said };
88
+ return { keep, searches, notes: said, paused: undefined };
88
89
  }));
90
+ const paused = answers.find((a) => a.paused !== undefined)?.paused;
89
91
  return { keep: new Set(answers.flatMap((a) => a.keep)), searches: [...new Set(answers.flatMap((a) => a.searches.map((s) => s.trim())))],
90
- notes: answers.flatMap((a) => a.notes) };
92
+ notes: answers.flatMap((a) => a.notes), ...(paused !== undefined ? { paused } : {}) };
91
93
  }
92
94
  /** `from`/`to`: the time the question asks about ("on 10 April 2022", "in October 2023"). What happened then is searched
93
95
  * for besides everything else and read first — not instead: a question also names dates it only counts from ("as of
@@ -114,5 +116,7 @@ export async function memoryGather(d, q, o) {
114
116
  const all = [...first, ...more];
115
117
  const kept = all.filter((h) => one.keep.has(h.id) || two.keep.has(h.id));
116
118
  const keptIds = new Set(kept.map((h) => h.id));
117
- return { found: [...kept, ...all.filter((h) => !keptIds.has(h.id))], kept: kept.map((h) => h.id), searches, notes: [...one.notes, ...two.notes] };
119
+ const paused = one.paused ?? two.paused;
120
+ return { found: [...kept, ...all.filter((h) => !keptIds.has(h.id))], kept: kept.map((h) => h.id), searches, notes: [...one.notes, ...two.notes],
121
+ ...(paused !== undefined ? { paused } : {}) };
118
122
  }
@@ -1,3 +1,4 @@
1
+ import { type DownloadOptions } from './qdrant-binary.js';
1
2
  export declare const MODEL_REPO = "onnx-community/embeddinggemma-300m-ONNX";
2
3
  export declare const MODEL_REVISION = "5090578d9565bb06545b4552f76e6bc2c93e4a66";
3
4
  export declare const MODEL_FILES: ReadonlyArray<{
@@ -6,11 +7,14 @@ export declare const MODEL_FILES: ReadonlyArray<{
6
7
  sha256: string;
7
8
  }>;
8
9
  export declare function defaultModelDir(): string;
10
+ /** Every file in place at its pinned size, and the marker written after the last of them was verified. */
9
11
  export declare function modelReady(dir?: string): boolean;
10
- /** The model in `dir` (fetched and checked unless it is already there); the directory, ready for `modelDir`. */
11
- export declare function ensureModel(opts?: {
12
+ /**
13
+ * The model in `dir` (fetched and checked unless it is already there); the directory, ready for `modelDir`. Each file
14
+ * is fetched to a temp of its own, checked on disk and renamed into place; one run at a time per `dir` (withLock), and
15
+ * the marker is taken away first, so a set left half-replaced is never taken as ready.
16
+ */
17
+ export declare function ensureModel(opts?: DownloadOptions & {
12
18
  dir?: string;
13
19
  log?: (line: string) => void;
14
- fetchImpl?: typeof fetch;
15
- signal?: AbortSignal;
16
20
  }): Promise<string>;