memtrails 0.3.0 → 0.4.1

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 (53) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/README.md +25 -9
  3. package/dist/core/consolidate.d.ts +24 -9
  4. package/dist/core/consolidate.js +101 -49
  5. package/dist/core/episodes.d.ts +1 -1
  6. package/dist/core/episodes.js +2 -8
  7. package/dist/core/extract.d.ts +3 -0
  8. package/dist/core/extract.js +12 -5
  9. package/dist/core/gather.d.ts +3 -0
  10. package/dist/core/gather.js +16 -10
  11. package/dist/core/model.d.ts +9 -4
  12. package/dist/core/model.js +39 -40
  13. package/dist/core/profiles.d.ts +1 -1
  14. package/dist/core/profiles.js +12 -9
  15. package/dist/core/qdrant-binary.d.ts +52 -5
  16. package/dist/core/qdrant-binary.js +197 -48
  17. package/dist/core/qdrant-client.d.ts +6 -21
  18. package/dist/core/qdrant-client.js +36 -100
  19. package/dist/core/qdrant-process.d.ts +30 -2
  20. package/dist/core/qdrant-process.js +217 -27
  21. package/dist/core/rebuild.d.ts +5 -1
  22. package/dist/core/rebuild.js +23 -5
  23. package/dist/core/recall-log.d.ts +3 -0
  24. package/dist/core/recall-log.js +6 -1
  25. package/dist/core/recall.js +19 -8
  26. package/dist/core/redact.d.ts +8 -0
  27. package/dist/core/redact.js +48 -9
  28. package/dist/core/sleep.d.ts +4 -3
  29. package/dist/core/sleep.js +27 -16
  30. package/dist/core/store.d.ts +7 -1
  31. package/dist/core/store.js +101 -33
  32. package/dist/core/text.d.ts +6 -2
  33. package/dist/core/text.js +19 -6
  34. package/dist/core/words.js +6 -2
  35. package/dist/core/writer.d.ts +2 -1
  36. package/dist/core/writer.js +9 -3
  37. package/dist/importers/files.js +96 -15
  38. package/dist/importers/git.js +3 -1
  39. package/dist/importers/jsonl.d.ts +6 -0
  40. package/dist/importers/jsonl.js +10 -5
  41. package/dist/importers/lines.d.ts +13 -0
  42. package/dist/importers/lines.js +70 -0
  43. package/dist/importers/mbox.js +32 -12
  44. package/dist/importers/slack.js +5 -2
  45. package/dist/importers/text.js +85 -14
  46. package/dist/llm.d.ts +10 -8
  47. package/dist/llm.js +53 -7
  48. package/dist/memory.d.ts +7 -2
  49. package/dist/memory.js +284 -57
  50. package/dist/records.js +6 -3
  51. package/dist/tools/agent-tools.d.ts +2 -5
  52. package/dist/tools/agent-tools.js +37 -21
  53. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -3,6 +3,62 @@
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.1] — 2026-11-05
9
+
10
+ - Native Python (`pip install memtrails`, asyncio) and Rust (`cargo add memtrails`, tokio) versions, with the same
11
+ results and the same files as this library: held to shared answers (`conformance/`), and a memory folder written by
12
+ any of the three is searched the same by the others (`interop/`).
13
+ - Fixes: search no longer shows a short record's own summary again as the passage that matched (`match`, and after
14
+ " … " in `searchText`); a match is now only ever a passage beyond the start of what is shown.
15
+ - Fixes: the downloaded Qdrant binary and embedding model are only run or loaded from folders that are this user's
16
+ alone. The cache folder (and the model's folder) is made private when it is created or found open to others, and
17
+ refused with a clear error when it belongs to someone else or is a link. `fromFiles` no longer reads through a note
18
+ that was replaced by a link after the folder was listed, and `fromJsonl` and `fromMbox` skip a line longer than
19
+ 64 MiB (with the reason) instead of holding it in memory.
20
+ - Fixes: a rebuild that is stopped (a close, a newer rebuild) returns only once the batches it was writing are written,
21
+ so none of them lands in the index after it.
22
+ - Fixes: `close()` while memory's own Qdrant is being restarted (after it died) stops that restart and waits for the
23
+ Qdrant it began; one could come up after the close and stay running.
24
+ - Fixes: an add no longer runs beside a rebuild (a record could be logged but never indexed, then skipped as unchanged
25
+ when added again); `close()` waits for the adds and searches in flight, and every call after it is refused with
26
+ "memory is closed".
27
+ - `LlmRequest.signal` (optional): aborted when no one will read the answer any more; `openAiAsk` stops its call and its
28
+ retries. Asks made side by side (consolidation's windows, cues and reconcile batches, gathering's parts, sleep's
29
+ relate batches, profiles) stop the others when one fails. Consolidation that fails or is stopped starts no further
30
+ extraction and returns only once the ones being read are over; before, they ran on, paid for, after it returned.
31
+
32
+ ## [0.4.0] — 2026-11-04
33
+
34
+ - Prompt examples are invented now. The extraction and gathering prompts had a few example sentences taken from LoCoMo
35
+ conversations, one of them (in `gather`'s notes rule) from a held-out conversation. Their effect on the published
36
+ results, measured on the judged answers: leaving out every held-out question that mentions any of those words or
37
+ 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
38
+ the example came from was answered wrong by both systems.
39
+ - The search index layout is 7: `kinds` now selects records by their kind on every point and learned items by theirs,
40
+ so an existing index is rebuilt from the logs on the next open.
41
+ - Fixes: a crash in the middle of a write no longer loses the records added after it; consolidation no longer
42
+ overwrites records changed or removed while the model answers; a title cut through an emoji no longer fails a write;
43
+ a thread that never goes quiet is consolidated; imported history is related and kept like new knowledge (`learnedAt`);
44
+ a malformed `from`/`to` is an error; `gather` and `sleep` report `paused` when the model did not read.
45
+ - `bench/audited.ts`: judged LoCoMo answers without the questions whose golden answer an independent audit
46
+ ([locomo-audit](https://github.com/dial481/locomo-audit)) found wrong, dropped for every system alike. As shipped,
47
+ 84.8 against Mem0's 79.9 (+109/−49).
48
+ - `gather`: a memory a small model keeps by quoting its whole line (`[m3] (2023-06-03) …`) now counts; it read as nothing
49
+ kept before.
50
+
51
+ - Security: the secret filter catches common key formats it missed (GITHUB_TOKEN=, AWS_SECRET_ACCESS_KEY=, JSON-style
52
+ secrets, GitLab, Hugging Face, npm, SendGrid, Telegram, the whole password of a URL); crafted e-mail and HTML can no
53
+ longer make the importers or the filter take quadratic time; downloads are verified on disk in a temp folder of their
54
+ own under a lock, with a stall timeout, and the Qdrant binary runs only while it is this user's alone; Qdrant gets a
55
+ minimal environment and must prove it is ours before it is given the key; an OpenAI key is sent to OpenAI only; the
56
+ memory folder is private; Slack channel folders cannot lead out of the export.
57
+ - One process at a time in a memory folder (a lock); consolidate, sleep, rebuild and remove run one at a time; `close()`
58
+ stops the running job and waits for it; an open that fails stops what it started; `remove()` takes the record's kept
59
+ episode out of the long-term log too; `MEMTRAILS_EMBED_URL` and `MEMTRAILS_RERANK_URL` are used.
60
+ - The release is built and tested in a job without publishing rights; actions are pinned to commits.
61
+
6
62
  ## [0.3.0] — 2026-11-02
7
63
 
8
64
  - `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
@@ -57,6 +58,17 @@ Node.js 22 or newer, on macOS or Linux (x64 or arm64). Windows has the pieces it
57
58
  (218 MB, from Hugging Face) and the Qdrant binary (~30 MB, from Qdrant's GitHub release) into `~/.cache/memtrails`,
58
59
  each checked against a pinned SHA-256. Set `MEMTRAILS_CACHE` to put them elsewhere.
59
60
 
61
+ ### Python and Rust
62
+
63
+ The same library, written natively in each language, fully async, with the same results and the same files: a memory
64
+ folder written by one opens in the others (checked on every change by [`interop/`](https://github.com/cmaftuleac/memtrails/tree/main/interop) and held to the shared
65
+ answers in [`conformance/`](https://github.com/cmaftuleac/memtrails/tree/main/conformance)).
66
+
67
+ ```bash
68
+ pip install memtrails # Python 3.10–3.13, asyncio — see python/README.md
69
+ cargo add memtrails # Rust 1.88+, tokio — see rust/README.md
70
+ ```
71
+
60
72
  ## Adding data
61
73
 
62
74
  A **record** is anything with text. Only `id` and `text` are required.
@@ -67,9 +79,9 @@ await memory.add({
67
79
  source: 'work-mailbox', // part of the identity
68
80
  kind: 'email', // any word: email, message, note, memo, article, commit, ticket, …
69
81
  collection: 'ops', // search can be scoped to collections
70
- thread: 'gateway-cert', // records that belong together
82
+ thread: 'proxy-cert', // records that belong together
71
83
  author: 'Ana Popescu',
72
- title: 'Gateway certificate expired',
84
+ title: 'Proxy certificate expired',
73
85
  text: '…',
74
86
  at: '2026-04-02T09:14:00Z', // ms, ISO string or Date
75
87
  url: 'https://mail.example/…',
@@ -111,7 +123,7 @@ await memory.search({ query: 'who renewed the certificate?', limit: 8 });
111
123
  await memory.search({ query: 'standup notes', collections: ['ops'], kinds: ['message'], from: '2026-04', to: '2026-04' });
112
124
 
113
125
  // 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' });
126
+ await memory.searchText({ query: 'what happened with the proxy?', order: 'time' });
115
127
 
116
128
  // The best sentences of what was found, for a small context window.
117
129
  await memory.room({ query: 'what made the renewal stop?', chars: 6000 });
@@ -120,7 +132,7 @@ await memory.room({ query: 'what made the renewal stop?', chars: 6000 });
120
132
  await memory.open(hits[0].id);
121
133
 
122
134
  // What the concept graph reaches from a topic or from items, that search did not find.
123
- await memory.explore({ topic: 'the gateway' });
135
+ await memory.explore({ topic: 'the proxy' });
124
136
  ```
125
137
 
126
138
  With dates, what happened within them comes first and the rest follows: a question that names a time is rarely only
@@ -141,7 +153,7 @@ const memory = await openMemory({
141
153
  });
142
154
  await memory.consolidate(); // after adding
143
155
  await memory.sleep(); // once a day
144
- await memory.gather({ query: 'every doctor I saw this year' }); // a set, for counting and listing questions
156
+ await memory.gather({ query: 'every plumber we called this year' }); // a set, for counting and listing questions
145
157
  ```
146
158
 
147
159
  Any provider works: pass `llm: { baseUrl, model, apiKey }` for an OpenAI-compatible server (a hosted model, vLLM,
@@ -174,7 +186,7 @@ for (const t of tools) server.tool(t.name, t.description, t.inputSchema, t.handl
174
186
  | Environment | |
175
187
  |---|---|
176
188
  | `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. |
189
+ | `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
190
  | `MEMTRAILS_EMBED_URL`, `MEMTRAILS_RERANK_URL` (+ `_KEY`, `_MODEL`) | Servers, when not given in options. |
179
191
 
180
192
  ## How it works
@@ -191,17 +203,21 @@ the [API reference](docs/API.md), and [what was tried and why](docs/RESEARCH.md)
191
203
  |---|---|---|---|
192
204
  | LoCoMo, all 1,307 held-out questions, 6,000 characters, strict judge, `gather` at 40 with notes | **80.9** | 76.5 | — |
193
205
  | the same, Mem0's own lenient judge | **91.9** | 87.8 | — |
206
+ | the same, strict, without the 80 questions whose golden answer is wrong³ | **84.8** | 79.9 | — |
194
207
  | the same, `gather` without notes, strict / lenient | 79.5 / 91.4 | 76.5 / 87.8 | — |
195
208
  | the same with `search` alone, no model when searching, strict / lenient | 77.4 / 89.3 | 76.5 / 87.8 | — |
196
209
  | LoCoMo, 1,307 held-out questions, 28,000 characters, one search¹ | 79.9 | 80.6 | 42.1 |
197
210
  | LoCoMo, the same with an agent using each system's own tools¹ | **79.0** | 77.1 | 47.0 |
211
+ | LongMemEval-S, 60-question sample, 6,000 characters, strict judge, `gather` at 40 with notes (a tie) | 90.0 | 88.3 | — |
198
212
  | LongMemEval-S retrieval, strict all sessions @5 (467)² | **96.15** | — | — |
199
213
 
200
214
  The same reader and judges for every system. With `gather` (a model reads what search found, keeps what the answer
201
215
  needs and notes what it says) memtrails leads Mem0 beyond chance: +113/−55 questions strict, +78/−25 lenient (both
202
216
  p < 0.001). With `search` alone,
203
217
  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).
218
+ 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
219
+ independent audit of LoCoMo ([locomo-audit](https://github.com/dial481/locomo-audit)), dropped for both systems;
220
+ +109/−49 (p < 0.001), `bench/audited.ts` reproduces it. [Full results, protocol and limitations](docs/BENCHMARKS.md).
205
221
 
206
222
  ## Limitations
207
223
 
@@ -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;
@@ -34,7 +34,7 @@ export interface ConsolidateDeps extends WriterDeps {
34
34
  /** What a window's reading may keep, checked before anything is reconciled or written (e.g. facts only
35
35
  * from the person's own words and results the agent confirmed, each with where it came from). An answer it cannot
36
36
  * give leaves the window for the next run, as an extraction that was not answered does. */
37
- guard?: (win: StmEntry[], x: Extraction) => Promise<Extraction | {
37
+ guard?: (win: StmEntry[], x: Extraction, signal?: AbortSignal) => Promise<Extraction | {
38
38
  unavailable: string;
39
39
  } | {
40
40
  busy: true;
@@ -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
+ }>, signal?: AbortSignal): Promise<Map<string, Decision> | {
157
+ paused: string;
158
+ }>;
144
159
  }
@@ -15,7 +15,7 @@
15
15
  * Then the window's entries are marked done and leave the short-term index: what mattered of them is long-term now.
16
16
  */
17
17
  import { redactString } from './redact.js';
18
- import { defaultAsk } from '../llm.js';
18
+ import { allOrAbort, defaultAsk } from '../llm.js';
19
19
  import { episodeBody } from './episodes.js';
20
20
  import { docText } from './embedder.js';
21
21
  import { cleanConcepts, cleanCues, extractWindow, observeWindow, windowsOf } from './extract.js';
@@ -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.
@@ -250,6 +262,8 @@ export class Consolidator {
250
262
  }
251
263
  const limit = Math.max(1, this.d.concurrency ?? EXTRACT_CONCURRENCY);
252
264
  const extracting = [];
265
+ /** Aborted once the lanes end — failed, paused, stopped or done: what is still being read is read for no one. */
266
+ const stop = new AbortController();
253
267
  // A pool: the next window starts when ANY one is read, not when the oldest is — one slow answer (measured: an
254
268
  // answer that ran on for 19,000 lines) held every other place idle while the windows after it waited their turn.
255
269
  // They are still reconciled in order.
@@ -260,17 +274,18 @@ export class Consolidator {
260
274
  while (!stopped && reading < limit && next < wins.length && !this.d.aborted()) {
261
275
  const win = wins[next];
262
276
  const head = win[0];
263
- const p = this.read(win, { cwd: head.cwd, engine: head.engine, title: head.title });
277
+ const p = this.read(win, { cwd: head.cwd, engine: head.engine, title: head.title }, stop.signal);
264
278
  reading++;
265
- // One left behind by a pause or a stop is not an unhandled rejection; the awaited one still throws.
266
- p.then(() => { reading--; launch(); }, () => { reading--; });
279
+ // One left behind by a pause or a stop is not an unhandled rejection; the awaited one still throws. One that
280
+ // throws starts no other: the run throws when it is reached, and no one would read their answers.
281
+ p.then(() => { reading--; launch(); }, () => { reading--; stopped = true; });
267
282
  extracting[next++] = p;
268
283
  }
269
284
  };
270
285
  launch();
271
286
  const together = Math.max(1, Math.min(limit, this.d.reconcileConcurrency ?? 1));
272
287
  /** Window i, once read: reconciled, observed, marked done — or 'stop' when the run pauses. */
273
- const settle = async (i) => {
288
+ const settle = async (i, signal) => {
274
289
  const x = await extracting[i].catch((err) => { stopped = true; throw err; });
275
290
  const win = wins[i];
276
291
  const head = win[0];
@@ -304,17 +319,23 @@ export class Consolidator {
304
319
  const learned = new Set(x.candidates.flatMap((c) => c.provenance));
305
320
  const kept = x.episodes.filter((k) => byUid.has(k.uid) && !learned.has(k.uid));
306
321
  const fresh = [
307
- ...kept.map((k) => episodeItem(byUid.get(k.uid), k)),
322
+ ...kept.map((k) => episodeItem(byUid.get(k.uid), k, now)),
308
323
  ...x.candidates.map((c) => itemOf(c, byUid.get(c.provenance.at(-1) ?? '') ?? head, now, c.provenance.flatMap((u) => byUid.get(u) ?? []))),
309
324
  ];
310
- await this.reconcile(fresh, r);
311
- await this.observe(x.observations.map((o) => observationItem(o, byUid.get(o.provenance.at(-1) ?? '') ?? head)), r);
325
+ await this.reconcile(fresh, r, signal);
326
+ await this.observe(x.observations.map((o) => observationItem(o, byUid.get(o.provenance.at(-1) ?? '') ?? head, now)), r);
312
327
  r.dropped += x.dropped;
313
328
  r.episodes += kept.length;
314
329
  // Read: marked done in the queue. The requests themselves stay searchable — the raw record is the truth and the
315
330
  // 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 })));
331
+ // LongMemEval: 47 of 60 misses were facts said in passing inside a request). Each as it is now, not as it was read
332
+ // before the model's long answer: one removed meanwhile stays removed, one changed meanwhile is read again.
333
+ this.d.stm.put(win.flatMap((e) => {
334
+ if (this.d.stm.meta(e.uid)?.fp !== e.fingerprint)
335
+ return [];
336
+ const cur = this.d.stm.get(e.uid);
337
+ return cur ? [{ ...cur, done: true }] : [];
338
+ }));
318
339
  r.windows++;
319
340
  // One at a time, each window's keys are dropped after it; windows at once share the cache until the run ends.
320
341
  if (together === 1)
@@ -323,19 +344,30 @@ export class Consolidator {
323
344
  };
324
345
  let at = 0;
325
346
  let halt = false;
326
- const lane = async () => {
327
- while (!halt && at < wins.length && extracting[at] && !this.d.aborted()) {
347
+ const lane = async (signal) => {
348
+ while (!halt && at < wins.length && extracting[at] && !this.d.aborted() && !signal.aborted) {
349
+ // No window is started after a failure: the run throws, and no one would read its answer.
328
350
  try {
329
- if (await settle(at++) === 'stop')
351
+ if (await settle(at++, signal) === 'stop')
330
352
  halt = true;
331
353
  }
332
354
  catch (err) {
333
355
  halt = true;
356
+ stopped = true;
334
357
  throw err;
335
358
  }
336
359
  }
337
360
  };
338
- await Promise.all(Array.from({ length: together }, lane));
361
+ try {
362
+ await allOrAbort(Array.from({ length: together }, () => lane), stop.signal);
363
+ }
364
+ finally {
365
+ // What is still being read once the lanes end is aborted, and over before the run goes on: no paid ask outlives
366
+ // it, and none starts after it (`stopped`).
367
+ stopped = true;
368
+ stop.abort();
369
+ await Promise.allSettled(extracting.filter((p) => p !== undefined));
370
+ }
339
371
  if (halt && r.paused !== undefined)
340
372
  return r;
341
373
  this.vecs.clear();
@@ -350,22 +382,27 @@ export class Consolidator {
350
382
  return r;
351
383
  }
352
384
  /** One window's reading, and what the guard (when there is one) lets it keep. */
353
- async read(win, meta) {
354
- const x = await this.extract(win, meta);
355
- return this.d.guard && 'candidates' in x ? this.d.guard(win, x) : x;
385
+ async read(win, meta, signal) {
386
+ const x = await this.extract(win, meta, signal);
387
+ if (!this.d.guard || !('candidates' in x))
388
+ return x;
389
+ // Read for no one once the run stopped: the guard (it may ask a model too) is not asked.
390
+ if (signal?.aborted)
391
+ return { unavailable: 'the run stopped' };
392
+ return this.d.guard(win, x, signal);
356
393
  }
357
394
  /** One window's extraction: the one ask — and, when observations are read apart, the window's small windows too, each
358
395
  * with the one before it as context. Any part not answered leaves the whole window for the next run. */
359
- async extract(win, meta) {
396
+ async extract(win, meta, signal) {
360
397
  const chars = this.d.observeChars;
361
398
  const known = this.d.resolve ? await this.knownFor(win) : undefined;
362
399
  if (!chars)
363
- return extractWindow(win, meta, { ask: this.d.ask, known });
400
+ return extractWindow(win, meta, { ask: this.d.ask, known, ...(signal ? { signal } : {}) });
364
401
  const subs = windowsOf(win, chars);
365
- const [x, ...seen] = await Promise.all([
366
- extractWindow(win, meta, { ask: this.d.ask, observations: false, known }),
367
- ...subs.map((sub, k) => observeWindow(sub, k ? subs[k - 1] : [], meta, { ask: this.d.ask, chars, known })),
368
- ]);
402
+ const [x, ...seen] = await allOrAbort([
403
+ (s) => extractWindow(win, meta, { ask: this.d.ask, observations: false, known, signal: s }),
404
+ ...subs.map((sub, k) => (s) => observeWindow(sub, k ? subs[k - 1] : [], meta, { ask: this.d.ask, chars, known, signal: s })),
405
+ ], signal);
369
406
  if (!('candidates' in x))
370
407
  return x;
371
408
  const failed = seen.find((o) => !('observations' in o));
@@ -427,7 +464,8 @@ export class Consolidator {
427
464
  for (let i = 0; i < batches.length; i += limit) {
428
465
  if (this.d.aborted())
429
466
  break;
430
- const answers = await Promise.all(batches.slice(i, i + limit).map((b) => this.cueBatch(b)));
467
+ // One that throws stops the others (allOrAbort).
468
+ const answers = await allOrAbort(batches.slice(i, i + limit).map((b) => (signal) => this.cueBatch(b, signal)));
431
469
  const paused = answers.find((a) => 'paused' in a);
432
470
  const got = answers.flatMap((a) => ('items' in a ? a.items : []));
433
471
  if (got.length) {
@@ -443,11 +481,12 @@ export class Consolidator {
443
481
  }
444
482
  return { cued };
445
483
  }
446
- async cueBatch(items) {
484
+ async cueBatch(items, signal) {
447
485
  const prompt = items.map((it, n) => `[i${n + 1}] (${it.kind}) ${it.title}\n${it.summary}${it.appliesWhen ? `\nNeeded when: ${it.appliesWhen}` : ''}`).join('\n\n');
448
486
  const a = await (this.d.ask ?? defaultAsk)({
449
487
  purpose: 'memory', system: CUE_SYSTEM, prompt: redactString(prompt), schema: CUE_SCHEMA,
450
488
  name: 'memory_cues', maxTokens: 550 * items.length + 200, timeoutMs: 120_000, consequence: 'so these memories wait for their cues',
489
+ ...(signal ? { signal } : {}),
451
490
  });
452
491
  if ('unavailable' in a)
453
492
  return { paused: a.unavailable };
@@ -455,7 +494,7 @@ export class Consolidator {
455
494
  return { paused: 'the fast model is busy' };
456
495
  const out = [];
457
496
  for (const x of a.answer.items ?? []) {
458
- const it = typeof x.id === 'string' ? items[Number(x.id.slice(1)) - 1] : undefined;
497
+ const it = typeof x.id === 'string' ? items[labelNo(x.id, 'i') - 1] : undefined;
459
498
  const cues = it?.cues?.length ? it.cues : cleanCues(x.cues);
460
499
  const concepts = it?.concepts?.length ? it.concepts : cleanConcepts(x.concepts);
461
500
  if (it && (cues.length || concepts.length))
@@ -464,7 +503,7 @@ export class Consolidator {
464
503
  return { items: out };
465
504
  }
466
505
  /** Each item against what long-term memory holds — written as it goes, so a later item sees an earlier one. */
467
- async reconcile(items, r) {
506
+ async reconcile(items, r, signal) {
468
507
  const ask = [];
469
508
  // Every key of the window in one embedding call (the cache serves each below and again when it is written).
470
509
  await this.embed(items.map((it) => docText(it.title, it.summary)), embedBudget(items.length));
@@ -495,10 +534,14 @@ export class Consolidator {
495
534
  }
496
535
  // The pairs are fixed before any is asked about: every batch asked at once, the answers applied in order.
497
536
  const batches = relateBatches(ask);
498
- const answers = await Promise.all(batches.map((b) => this.relate(b)));
537
+ // One that throws stops the others (allOrAbort); a run stopped meanwhile (a lane beside this one failed) applies none.
538
+ const answers = await allOrAbort(batches.map((b) => (s) => this.relate(b, s)), signal);
539
+ if (signal?.aborted)
540
+ throw new Error('consolidation stopped');
499
541
  for (const [b, batch] of batches.entries()) {
542
+ const got = answers[b];
500
543
  for (const [n, { it, known }] of batch.entries()) {
501
- const d = answers[b]?.get(`p${n + 1}`);
544
+ const d = got instanceof Map ? got.get(`p${n + 1}`) : undefined;
502
545
  if (!d || d.relation === 'different') {
503
546
  await this.add(it);
504
547
  r.added++;
@@ -605,16 +648,18 @@ export class Consolidator {
605
648
  files: [...new Set([...known.files, ...it.files])],
606
649
  confidence: known.confidence === 'verified' || it.confidence === 'verified' ? 'verified' : known.confidence,
607
650
  cues: [...new Set([...(known.cues ?? []), ...(it.cues ?? [])])],
608
- related: [...new Set([...(known.related ?? []), ...(it.related ?? [])])].filter((u) => u !== known.uid),
651
+ related: [...new Set([...(known.related ?? []), ...(it.related ?? [])])].filter((u) => u !== known.uid).slice(-MAX_LINKS),
609
652
  concepts: [...new Map([...(known.concepts ?? []), ...(it.concepts ?? [])].map((c) => [conceptKey(c), c])).values()],
610
653
  absorbed: [...new Set([...(known.absorbed ?? []), ...kept.map((k) => k.uid)])],
611
654
  };
612
655
  merged.fingerprint = fingerprint(merged.kind, merged.title, merged.summary, merged.body);
656
+ // Changed now: what sleep relates next.
657
+ merged.learnedAt = Math.max(learnedAt(known), learnedAt(it));
613
658
  if (text)
614
659
  await writeLtm(this.writer, [merged]);
615
660
  else {
616
661
  await this.d.client.setPayload([known.uid], { ts: merged.ts, importance: merged.importance });
617
- if ((merged.related?.length ?? 0) > (known.related?.length ?? 0))
662
+ if (merged.related?.join('\n') !== (known.related ?? []).join('\n'))
618
663
  await this.d.client.setPayload([known.uid], { related: merged.related }, true);
619
664
  if ((merged.concepts?.length ?? 0) > (known.concepts?.length ?? 0))
620
665
  await writeConcepts(this.writer, [merged]);
@@ -624,9 +669,10 @@ export class Consolidator {
624
669
  }
625
670
  this.d.ltm.put([...kept, merged]);
626
671
  }
627
- /** Two items about the same subject that say different things: each lists the other (memory_explore walks these). */
672
+ /** Two items about the same subject that say different things: each lists the other (memory_explore walks these) —
673
+ * the newest MAX_LINKS, so an item on a busy subject does not grow its list, and its payload, without end. */
628
674
  async link(a, b) {
629
- const add = (x, y) => ({ ...x, related: [...new Set([...(x.related ?? []), y.uid])] });
675
+ const add = (x, y) => ({ ...x, related: [...(x.related ?? []).filter((u) => u !== y.uid), y.uid].slice(-MAX_LINKS) });
630
676
  const [one, two] = [add(a, b), add(b, a)];
631
677
  this.d.ltm.put([one, two]);
632
678
  await this.d.client.setPayload([one.uid], { related: one.related }, true);
@@ -638,7 +684,8 @@ export class Consolidator {
638
684
  await writeLtm(this.writer, [old]);
639
685
  this.d.ltm.put([old]);
640
686
  }
641
- async relate(batch) {
687
+ /** The model's decision for each pair, by its label (`p1`, …) — or why it did not answer. */
688
+ async relate(batch, signal) {
642
689
  // Whole texts: a merge the model writes from a shortened body loses the rest of it.
643
690
  const prompt = batch.map(({ it, known }, n) => `[p${n + 1}]\nNEW (${it.kind}): ${it.title}\n${it.summary}\n${it.body}\n\nKNOWN (${known.kind}): ${known.title}\n${known.summary}\n${known.body}`).join('\n\n');
644
691
  // Room for every merged text in full (≈ 2.5 characters a token, the two sides together), with the decisions.
@@ -646,14 +693,19 @@ export class Consolidator {
646
693
  const a = await (this.d.ask ?? defaultAsk)({
647
694
  purpose: 'memory', system: RELATE_SYSTEM, prompt: redactString(prompt), schema: RELATE_SCHEMA,
648
695
  name: 'memory_reconcile', maxTokens: 900 * batch.length + 200 + room, timeoutMs: 300_000, consequence: 'so these memories are kept apart for now',
696
+ ...(signal ? { signal } : {}),
649
697
  });
698
+ if ('unavailable' in a)
699
+ return { paused: a.unavailable };
650
700
  if (!('answer' in a))
651
- return null;
701
+ return { paused: 'the fast model is busy' };
652
702
  const out = new Map();
653
703
  for (const p of a.answer.pairs ?? []) {
654
- if (typeof p.id !== 'string' || !RELATIONS.includes(String(p.relation)))
704
+ // A small model may quote the label's line rather than the label: the number is looked for.
705
+ const n = typeof p.id === 'string' ? labelNo(p.id, 'p') : 0;
706
+ if (!n || !RELATIONS.includes(String(p.relation)))
655
707
  continue;
656
- out.set(p.id, { relation: p.relation, title: (p.title ?? '').trim(), summary: (p.summary ?? '').trim(), body: (p.body ?? '').trim() });
708
+ out.set(`p${n}`, { relation: p.relation, title: (p.title ?? '').trim(), summary: (p.summary ?? '').trim(), body: (p.body ?? '').trim() });
657
709
  }
658
710
  return out;
659
711
  }
@@ -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)