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.
- package/CHANGELOG.md +32 -0
- package/README.md +14 -9
- package/dist/core/consolidate.d.ts +23 -8
- package/dist/core/consolidate.js +52 -25
- package/dist/core/episodes.d.ts +1 -1
- package/dist/core/episodes.js +2 -8
- package/dist/core/extract.js +5 -5
- package/dist/core/gather.d.ts +3 -0
- package/dist/core/gather.js +12 -8
- package/dist/core/model.d.ts +8 -4
- package/dist/core/model.js +29 -40
- package/dist/core/profiles.d.ts +1 -1
- package/dist/core/profiles.js +7 -6
- package/dist/core/qdrant-binary.d.ts +39 -5
- package/dist/core/qdrant-binary.js +185 -48
- package/dist/core/qdrant-client.d.ts +6 -18
- package/dist/core/qdrant-client.js +38 -90
- package/dist/core/qdrant-process.d.ts +22 -1
- package/dist/core/qdrant-process.js +190 -22
- package/dist/core/rebuild.d.ts +5 -1
- package/dist/core/rebuild.js +19 -4
- package/dist/core/recall-log.d.ts +3 -0
- package/dist/core/recall-log.js +6 -1
- package/dist/core/recall.js +14 -5
- package/dist/core/redact.d.ts +8 -0
- package/dist/core/redact.js +48 -9
- package/dist/core/sleep.d.ts +4 -3
- package/dist/core/sleep.js +19 -11
- package/dist/core/store.d.ts +7 -1
- package/dist/core/store.js +101 -33
- package/dist/core/text.d.ts +4 -2
- package/dist/core/text.js +17 -5
- package/dist/core/words.js +6 -2
- package/dist/core/writer.d.ts +2 -1
- package/dist/core/writer.js +9 -3
- package/dist/importers/files.js +53 -10
- package/dist/importers/git.js +3 -1
- package/dist/importers/mbox.js +22 -7
- package/dist/importers/slack.js +5 -2
- package/dist/importers/text.js +85 -14
- package/dist/llm.d.ts +1 -8
- package/dist/llm.js +11 -5
- package/dist/memory.d.ts +6 -2
- package/dist/memory.js +215 -47
- package/dist/records.js +6 -3
- package/dist/tools/agent-tools.d.ts +2 -5
- package/dist/tools/agent-tools.js +37 -21
- 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: '
|
|
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.
|
|
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: '
|
|
71
|
+
thread: 'proxy-cert', // records that belong together
|
|
71
72
|
author: 'Ana Popescu',
|
|
72
|
-
title: '
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
summary: string;
|
|
142
|
-
body: string;
|
|
143
|
-
}> | null>;
|
|
156
|
+
}>): Promise<Map<string, Decision> | {
|
|
157
|
+
paused: string;
|
|
158
|
+
}>;
|
|
144
159
|
}
|
package/dist/core/consolidate.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
234
|
-
|
|
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
|
-
|
|
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[
|
|
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 =
|
|
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 (
|
|
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: [...
|
|
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
|
|
676
|
+
return { paused: 'the fast model is busy' };
|
|
652
677
|
const out = new Map();
|
|
653
678
|
for (const p of a.answer.pairs ?? []) {
|
|
654
|
-
|
|
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
|
|
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
|
}
|
package/dist/core/episodes.d.ts
CHANGED
|
@@ -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' | '
|
|
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,
|
package/dist/core/episodes.js
CHANGED
|
@@ -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
|
|
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)
|
package/dist/core/extract.js
CHANGED
|
@@ -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 ("
|
|
43
|
-
|
|
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 ("
|
|
48
|
-
"
|
|
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 ("
|
|
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).
|
package/dist/core/gather.d.ts
CHANGED
|
@@ -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
|
package/dist/core/gather.js
CHANGED
|
@@ -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
|
|
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 "
|
|
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 ("
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/core/model.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
11
|
-
|
|
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>;
|