memtrails 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/CHANGELOG.md CHANGED
@@ -3,6 +3,13 @@
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
+ ## [0.3.0] — 2026-11-02
7
+
8
+ - `gather(q, { notes: true })`: the same model call also writes what the kept memories say that answers the question,
9
+ every instance with its date and only what they state, reading all 80 candidates at once. Shown before the
10
+ memories, LoCoMo 79.5 → 80.9 (Mem0 76.5);
11
+ `bench/locomo.ts --gather 40 --notes` reproduces it.
12
+
6
13
  ## [0.2.0] — 2026-11-01
7
14
 
8
15
  - Consolidation reads "last week" as the seven days before it was said, and the calendar line names the last weekend
package/README.md CHANGED
@@ -41,7 +41,7 @@ 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, 79.5 — ahead beyond chance. [Benchmarks](docs/BENCHMARKS.md).
44
+ found and noting what it says, 80.9 — ahead beyond chance. [Benchmarks](docs/BENCHMARKS.md).
45
45
  - **Explorable.** A concept graph links what records share — people, places, systems, names — so an agent can walk
46
46
  from what it found to what a search for the words does not find.
47
47
  - **Built for agents.** `memory.tools()` gives `memory_search`, `memory_open` and `memory_explore` with zod and JSON
@@ -189,15 +189,17 @@ the [API reference](docs/API.md), and [what was tried and why](docs/RESEARCH.md)
189
189
 
190
190
  | | memtrails | Mem0 | Graphiti |
191
191
  |---|---|---|---|
192
- | LoCoMo, all 1,307 held-out questions, 6,000 characters, strict judge, `gather` at 40 | **79.5** | 76.5 | — |
193
- | the same, Mem0's own lenient judge | **91.4** | 87.8 | — |
192
+ | LoCoMo, all 1,307 held-out questions, 6,000 characters, strict judge, `gather` at 40 with notes | **80.9** | 76.5 | — |
193
+ | the same, Mem0's own lenient judge | **91.9** | 87.8 | — |
194
+ | the same, `gather` without notes, strict / lenient | 79.5 / 91.4 | 76.5 / 87.8 | — |
194
195
  | the same with `search` alone, no model when searching, strict / lenient | 77.4 / 89.3 | 76.5 / 87.8 | — |
195
196
  | LoCoMo, 1,307 held-out questions, 28,000 characters, one search¹ | 79.9 | 80.6 | 42.1 |
196
197
  | LoCoMo, the same with an agent using each system's own tools¹ | **79.0** | 77.1 | 47.0 |
197
198
  | LongMemEval-S retrieval, strict all sessions @5 (467)² | **96.15** | — | — |
198
199
 
199
- The same reader and judges for every system. With `gather` (a model reads what search found and keeps what the answer
200
- needs) memtrails leads Mem0 beyond chance: +110/−71 questions strict (p 0.005), +78/−32 lenient. With `search` alone,
200
+ The same reader and judges for every system. With `gather` (a model reads what search found, keeps what the answer
201
+ needs and notes what it says) memtrails leads Mem0 beyond chance: +113/−55 questions strict, +78/−25 lenient (both
202
+ p < 0.001). With `search` alone,
201
203
  the small-room and one-search comparisons are ties; the agent lead is
202
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).
203
205
 
@@ -7,6 +7,9 @@ export interface Gathered {
7
7
  kept: string[];
8
8
  /** The searches the model asked for. */
9
9
  searches: string[];
10
+ /** With `notes`: what the kept memories say that answers the question, a few short lines (empty otherwise). Show them
11
+ * before the memories, as notes on them: on LoCoMo this took 79.5 to 80.9 (docs/BENCHMARKS.md). */
12
+ notes: string[];
10
13
  }
11
14
  /** `from`/`to`: the time the question asks about ("on 10 April 2022", "in October 2023"). What happened then is searched
12
15
  * for besides everything else and read first — not instead: a question also names dates it only counts from ("as of
@@ -14,4 +17,5 @@ export interface Gathered {
14
17
  export declare function memoryGather(d: RecallDeps, q: SearchQuery, o: {
15
18
  ask?: Ask;
16
19
  limit?: number;
20
+ notes?: boolean;
17
21
  }): Promise<Gathered>;
@@ -20,6 +20,9 @@ import { memorySearch, MAX_SEARCH } from './recall.js';
20
20
  import { cut } from './text.js';
21
21
  /** Candidates the model reads in one ask. */
22
22
  const PART = 40;
23
+ /** With notes, all of them in one ask, so a question's notes are written once: on LoCoMo the same accuracy as two parts
24
+ * of 40 (80.9 against 80.6), at 58% of the cost and 2.9 s against 7.5 s median. */
25
+ const NOTES_PART = 80;
23
26
  /** Searches it may ask for. */
24
27
  const MORE = 6;
25
28
  const SYSTEM = `You pick, from someone's memories, every one needed to answer their question.
@@ -40,6 +43,19 @@ const SCHEMA = {
40
43
  searches: { type: 'array', items: { type: 'string' } },
41
44
  },
42
45
  };
46
+ /** With notes: the same reading also writes down what the kept memories say that answers the question, so a list spread
47
+ * over many memories reaches the reader as one line. */
48
+ const NOTES_RULE = `
49
+
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)"),
52
+ names, numbers and words as written. Only what they state, as plain statements: never write that something is not said,
53
+ not given, unclear or unknown — leave it out instead, since other memories may hold it — and never a guess. Leave notes
54
+ empty when the memories you kept say nothing about it. In the language of the question.`;
55
+ const NOTES_SCHEMA = {
56
+ type: 'object', additionalProperties: false, required: ['keep', 'searches', 'notes'],
57
+ properties: { ...SCHEMA.properties, notes: { type: 'array', items: { type: 'string' } } },
58
+ };
43
59
  const day = (ts) => (ts ? new Date(ts).toISOString().slice(0, 10) : '');
44
60
  /** One candidate as the model reads it: when it happened (from when to when, for what lasted) and when it was told, its
45
61
  * text, and the passage that matched. */
@@ -49,25 +65,29 @@ function line(h, n) {
49
65
  const text = h.kind === 'observation' ? h.summary : `${h.title} — ${cut(h.summary, h.kind === 'profile' ? PROFILE_SHOWN : 600)}`;
50
66
  return `[m${n}] (${when}) ${text}${h.match ? `\n … ${cut(h.match, 600)}` : ''}`;
51
67
  }
52
- async function pick(ask, question, list) {
68
+ async function pick(ask, question, list, notes = false) {
53
69
  const parts = [];
54
- for (let i = 0; i < list.length; i += PART)
55
- parts.push(list.slice(i, i + PART));
70
+ const size = notes ? NOTES_PART : PART;
71
+ for (let i = 0; i < list.length; i += size)
72
+ parts.push(list.slice(i, i + size));
56
73
  const answers = await Promise.all(parts.map(async (part) => {
57
74
  const prompt = `Question: ${question}\n\nMemories:\n${part.map((h, n) => line(h, n + 1)).join('\n')}`;
58
75
  const a = await ask({
59
- purpose: 'memory', system: SYSTEM, prompt: redactString(prompt), schema: SCHEMA,
60
- name: 'memory_gather', maxTokens: 40 * part.length + 600, timeoutMs: 120_000, consequence: 'so these memories are kept unread',
76
+ purpose: 'memory', system: notes ? SYSTEM + NOTES_RULE : SYSTEM, prompt: redactString(prompt),
77
+ schema: (notes ? NOTES_SCHEMA : SCHEMA),
78
+ name: 'memory_gather', maxTokens: 40 * part.length + (notes ? 1400 : 600), timeoutMs: 120_000, consequence: 'so these memories are kept unread',
61
79
  });
62
80
  if (!('answer' in a))
63
- return { keep: part.map((h) => h.id), searches: [] };
81
+ return { keep: part.map((h) => h.id), searches: [], notes: [] };
64
82
  const got = a.answer;
65
83
  const ids = Array.isArray(got.keep) ? got.keep.filter((x) => typeof x === 'string') : [];
66
84
  const keep = ids.map((x) => part[Number(x.replace(/^m/, '')) - 1]?.id).filter((x) => !!x);
67
85
  const searches = Array.isArray(got.searches) ? got.searches.filter((x) => typeof x === 'string' && !!x.trim()) : [];
68
- return { keep, searches };
86
+ 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 };
69
88
  }));
70
- return { keep: new Set(answers.flatMap((a) => a.keep)), searches: [...new Set(answers.flatMap((a) => a.searches.map((s) => s.trim())))] };
89
+ 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) };
71
91
  }
72
92
  /** `from`/`to`: the time the question asks about ("on 10 April 2022", "in October 2023"). What happened then is searched
73
93
  * for besides everything else and read first — not instead: a question also names dates it only counts from ("as of
@@ -79,7 +99,7 @@ export async function memoryGather(d, q, o) {
79
99
  const [inDates, wide] = await Promise.all([from || to ? memorySearch(d, { ...q, limit }) : [], memorySearch(d, { ...open, limit })]);
80
100
  const dated = new Set(inDates.map((h) => h.id));
81
101
  const first = [...inDates, ...wide.filter((h) => !dated.has(h.id))];
82
- const one = await pick(ask, q.query, first);
102
+ const one = await pick(ask, q.query, first, o.notes);
83
103
  const searches = one.searches.slice(0, MORE);
84
104
  const have = new Set(first.map((h) => h.id));
85
105
  const more = [];
@@ -90,9 +110,9 @@ export async function memoryGather(d, q, o) {
90
110
  more.push(h);
91
111
  }
92
112
  }
93
- const two = more.length ? await pick(ask, q.query, more) : { keep: new Set(), searches: [] };
113
+ const two = more.length ? await pick(ask, q.query, more, o.notes) : { keep: new Set(), searches: [], notes: [] };
94
114
  const all = [...first, ...more];
95
115
  const kept = all.filter((h) => one.keep.has(h.id) || two.keep.has(h.id));
96
116
  const keptIds = new Set(kept.map((h) => h.id));
97
- return { found: [...kept, ...all.filter((h) => !keptIds.has(h.id))], kept: kept.map((h) => h.id), searches };
117
+ return { found: [...kept, ...all.filter((h) => !keptIds.has(h.id))], kept: kept.map((h) => h.id), searches, notes: [...one.notes, ...two.notes] };
98
118
  }
package/dist/memory.d.ts CHANGED
@@ -84,7 +84,9 @@ export interface Memory {
84
84
  limit?: number;
85
85
  }): Promise<Explored>;
86
86
  /** A set for a question that counts, lists or totals: a wide search, then the language model keeps every instance. */
87
- gather(q: SearchQuery): Promise<Gathered>;
87
+ gather(q: SearchQuery, o?: {
88
+ notes?: boolean;
89
+ }): Promise<Gathered>;
88
90
  /** Read what was added since last time and keep typed knowledge, dated observations and kept episodes. Needs a
89
91
  * language model. Returns how many windows were read. */
90
92
  consolidate(): Promise<ConsolidationReport>;
package/dist/memory.js CHANGED
@@ -201,7 +201,7 @@ export async function openMemory(o) {
201
201
  return sources.length ? { ...p, sources } : p;
202
202
  },
203
203
  explore: (s) => memoryExplore(deps, s),
204
- gather: (s) => memoryGather(deps, s, { ask, ...(s.limit ? { limit: s.limit } : {}) }),
204
+ gather: (s, g) => memoryGather(deps, s, { ask, ...(s.limit ? { limit: s.limit } : {}), ...(g?.notes ? { notes: true } : {}) }),
205
205
  async consolidate() {
206
206
  const worker = new Consolidator({ ...base, concurrency: o.concurrency ?? 4 });
207
207
  const total = { windows: 0, added: 0, merged: 0, updated: 0, superseded: 0, linked: 0, dropped: 0, episodes: 0, observed: 0, cued: 0 };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memtrails",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Retrieval and exploration for any text — e-mail, chat, notes, documents, news, commits, conversations. Searchable at once, on your own machine, in any language; optional consolidation into typed knowledge with a language model.",
5
5
  "keywords": [
6
6
  "retrieval",