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 +7 -0
- package/README.md +7 -5
- package/dist/core/gather.d.ts +4 -0
- package/dist/core/gather.js +31 -11
- package/dist/memory.d.ts +3 -1
- package/dist/memory.js +1 -1
- package/package.json +1 -1
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,
|
|
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 | **
|
|
193
|
-
| the same, Mem0's own lenient judge | **91.
|
|
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
|
|
200
|
-
needs) memtrails leads Mem0 beyond chance: +
|
|
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
|
|
package/dist/core/gather.d.ts
CHANGED
|
@@ -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>;
|
package/dist/core/gather.js
CHANGED
|
@@ -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
|
-
|
|
55
|
-
|
|
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),
|
|
60
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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",
|