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
@@ -4,12 +4,24 @@ export interface QdrantEndpoint {
4
4
  }
5
5
  /** The config file: what environment variables cannot say (gRPC off is `grpc_port: null`). */
6
6
  export declare function qdrantConfig(storage: string, port: number): string;
7
+ /** When this machine started, in ms: a pid written down before a reboot names some other process now. */
8
+ export declare function bootTime(): number;
9
+ /** The same boot as `boot` (bootTime() of then): a minute apart at most, uptime being in whole seconds on some systems. */
10
+ export declare function sameBoot(boot: unknown): boolean;
11
+ /** A process with this pid exists — one of another user's too (EPERM). */
12
+ export declare function alive(pid: number): boolean;
7
13
  export declare class QdrantProcess {
8
14
  private child;
9
15
  private endpoint;
10
16
  private stopping;
11
17
  private deaths;
12
18
  private restartTimer;
19
+ /** A restart's start while it runs: stopAndWait waits for it, and for what it began to be gone. */
20
+ private restarting;
21
+ /** Counts stop()s: a start that sees it move while it starts gives up — before it spawns too, where `child` is null. */
22
+ private stops;
23
+ /** This instance's children that have not exited: stopAndWait waits for each, a restart's own included. */
24
+ private readonly children;
13
25
  private readonly opts;
14
26
  /** `dir` may be relative: Qdrant runs inside it, so every path it is given is made absolute first. */
15
27
  constructor(opts: {
@@ -20,11 +32,27 @@ export declare class QdrantProcess {
20
32
  });
21
33
  /** Where it answers, once it does; null while it starts or after it stopped. */
22
34
  current(): QdrantEndpoint | null;
35
+ /** Up and proven ours (owned), or it throws — and then nothing of this start keeps running or is started again: the
36
+ * caller was told, and may not be there to stop it. */
23
37
  start(): Promise<QdrantEndpoint>;
24
- /** Answering `/readyz` within 30 s, or the start failed. */
38
+ /** The pid file, and the child tracked for the host's exit; the file goes when that child does. */
39
+ private recordPid;
40
+ /** A Qdrant left running on this storage by a host that died without `close()` (a crash, a kill): stopped before
41
+ * another starts on it. Only when the pid is still the process written down — this boot, its command naming this
42
+ * folder's config — never an unrelated process that has the pid since. */
43
+ private reapLeftover;
44
+ /** Answering `/readyz` within 30 s, or the start failed. /readyz asks no key, so none is sent: what answers may not be
45
+ * ours (owned). */
25
46
  private ready;
47
+ /** The port was free when picked, not when Qdrant bound it: another local process may have taken it, answered
48
+ * /readyz, and would be handed the key and every document. Ours is still running, refuses a wrong key and takes
49
+ * this run's — the wrong key asked first, so a process that takes any key never sees the real one. */
50
+ private owned;
26
51
  private exited;
52
+ /** Started again after a growing pause; a restart that fails counts as another death. */
53
+ private restartLater;
27
54
  stop(): void;
28
- /** Stop and wait for the process to be gone — before its storage is deleted. */
55
+ /** Stop and wait for the process to be gone — before its storage is deleted. A restart that was starting one ends
56
+ * first, and the Qdrant it began is waited for too: none is left running after a close. */
29
57
  stopAndWait(ms?: number): Promise<void>;
30
58
  }
@@ -1,8 +1,9 @@
1
+ import { execFileSync, spawn } from 'node:child_process';
1
2
  import { randomBytes } from 'node:crypto';
2
- import { closeSync, mkdirSync, openSync, statSync, renameSync, writeFileSync } from 'node:fs';
3
+ import { chmodSync, closeSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
3
4
  import { createServer } from 'node:net';
5
+ import { uptime } from 'node:os';
4
6
  import { join, resolve } from 'node:path';
5
- import { spawn } from 'node:child_process';
6
7
  /** A port the OS says is free on loopback. */
7
8
  async function freePort() {
8
9
  return new Promise((resolve, reject) => {
@@ -39,7 +40,9 @@ export function qdrantConfig(storage, port) {
39
40
  ].join('\n');
40
41
  }
41
42
  const LOG_MAX = 4 * 1024 * 1024;
42
- /** The process's log beside its data, rotated once at start when it has grown past 4 MB. */
43
+ /** Beside the storage: the pid of the Qdrant running on it, with its config and the machine's boot (reapLeftover). */
44
+ const PID_FILE = 'qdrant.pid';
45
+ /** The process's log beside its data, rotated once at start when it has grown past 4 MB. Private: it names paths. */
43
46
  function openLog(dir) {
44
47
  const path = join(dir, 'qdrant.log');
45
48
  try {
@@ -47,8 +50,75 @@ function openLog(dir) {
47
50
  renameSync(path, `${path}.1`);
48
51
  }
49
52
  catch { /* none yet */ }
50
- mkdirSync(dir, { recursive: true });
51
- return openSync(path, 'a');
53
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
54
+ const fd = openSync(path, 'a', 0o600);
55
+ try {
56
+ chmodSync(path, 0o600);
57
+ }
58
+ catch { /* not ours to change: written all the same */ }
59
+ return fd;
60
+ }
61
+ /** When this machine started, in ms: a pid written down before a reboot names some other process now. */
62
+ export function bootTime() {
63
+ return Date.now() - uptime() * 1000;
64
+ }
65
+ /** The same boot as `boot` (bootTime() of then): a minute apart at most, uptime being in whole seconds on some systems. */
66
+ export function sameBoot(boot) {
67
+ return typeof boot === 'number' && Math.abs(bootTime() - boot) < 60_000;
68
+ }
69
+ /** A process with this pid exists — one of another user's too (EPERM). */
70
+ export function alive(pid) {
71
+ if (!Number.isInteger(pid) || pid <= 0)
72
+ return false;
73
+ try {
74
+ process.kill(pid, 0);
75
+ return true;
76
+ }
77
+ catch (err) {
78
+ return err.code === 'EPERM';
79
+ }
80
+ }
81
+ const pause = (ms) => new Promise((r) => setTimeout(r, ms));
82
+ /** A running process's command line, or null when it cannot be read (no /proc and no ps, Windows). */
83
+ function commandOf(pid) {
84
+ try {
85
+ return readFileSync(`/proc/${pid}/cmdline`, 'utf8').split('\0').join(' ');
86
+ }
87
+ catch { /* not Linux */ }
88
+ if (process.platform === 'win32')
89
+ return null;
90
+ try {
91
+ return execFileSync('ps', ['-ww', '-o', 'command=', '-p', String(pid)], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 5000 });
92
+ }
93
+ catch {
94
+ return null;
95
+ }
96
+ }
97
+ /** What Qdrant gets of the environment: what a process needs to run and the key. None of the host's secrets, and none
98
+ * of its QDRANT__* variables — they override the config (QDRANT__SERVICE__HOST=0.0.0.0 would open it to the network). */
99
+ function childEnv(apiKey) {
100
+ const env = {};
101
+ for (const k of ['PATH', 'HOME', 'TMPDIR', 'TEMP', 'TMP', 'SystemRoot', 'USERPROFILE'])
102
+ if (process.env[k] !== undefined)
103
+ env[k] = process.env[k];
104
+ return { ...env, QDRANT__SERVICE__API_KEY: apiKey, QDRANT__TELEMETRY_DISABLED: 'true' };
105
+ }
106
+ /** Every Qdrant this process started and has not seen exit: stopped when the host exits without `close()` (an 'exit'
107
+ * handler can only send the signal; a host killed outright leaves its Qdrant to the next start's reapLeftover). */
108
+ const running = new Set();
109
+ let exitHook = false;
110
+ function track(child) {
111
+ running.add(child);
112
+ child.once('exit', () => running.delete(child));
113
+ if (exitHook)
114
+ return;
115
+ exitHook = true;
116
+ process.on('exit', () => { for (const c of running) {
117
+ try {
118
+ c.kill('SIGTERM');
119
+ }
120
+ catch { /* gone */ }
121
+ } });
52
122
  }
53
123
  export class QdrantProcess {
54
124
  child = null;
@@ -56,6 +126,12 @@ export class QdrantProcess {
56
126
  stopping = false;
57
127
  deaths = [];
58
128
  restartTimer = null;
129
+ /** A restart's start while it runs: stopAndWait waits for it, and for what it began to be gone. */
130
+ restarting = null;
131
+ /** Counts stop()s: a start that sees it move while it starts gives up — before it spawns too, where `child` is null. */
132
+ stops = 0;
133
+ /** This instance's children that have not exited: stopAndWait waits for each, a restart's own included. */
134
+ children = new Set();
59
135
  opts;
60
136
  /** `dir` may be relative: Qdrant runs inside it, so every path it is given is made absolute first. */
61
137
  constructor(opts) {
@@ -65,13 +141,19 @@ export class QdrantProcess {
65
141
  current() {
66
142
  return this.endpoint;
67
143
  }
144
+ /** Up and proven ours (owned), or it throws — and then nothing of this start keeps running or is started again: the
145
+ * caller was told, and may not be there to stop it. */
68
146
  async start() {
69
147
  this.stopping = false;
148
+ const stops = this.stops;
70
149
  if (this.endpoint && this.child && this.child.exitCode === null)
71
150
  return this.endpoint;
151
+ mkdirSync(this.opts.dir, { recursive: true, mode: 0o700 });
152
+ await this.reapLeftover();
72
153
  const port = await freePort();
154
+ if (this.stops !== stops)
155
+ throw new Error('Qdrant was stopped while it started');
73
156
  const apiKey = randomBytes(24).toString('hex');
74
- mkdirSync(this.opts.dir, { recursive: true });
75
157
  const config = join(this.opts.dir, 'config.yaml');
76
158
  writeFileSync(config, qdrantConfig(this.opts.dir, port), { mode: 0o600 });
77
159
  const fd = openLog(this.opts.dir);
@@ -80,46 +162,139 @@ export class QdrantProcess {
80
162
  child = spawn(this.opts.binary, ['--config-path', config, '--disable-telemetry'], {
81
163
  cwd: this.opts.dir,
82
164
  stdio: ['ignore', fd, fd],
83
- // Only the key: the rest is in the config file.
84
- env: { ...process.env, QDRANT__SERVICE__API_KEY: apiKey, QDRANT__TELEMETRY_DISABLED: 'true' },
165
+ env: childEnv(apiKey),
85
166
  });
86
167
  }
87
168
  finally {
88
169
  closeSync(fd);
89
170
  }
90
171
  this.child = child;
172
+ if (child.pid) {
173
+ this.children.add(child);
174
+ child.once('exit', () => this.children.delete(child));
175
+ }
176
+ // A binary that is missing or cannot run is an 'error', never an 'exit' — unheard, it is thrown in the host.
177
+ let failed = null;
178
+ child.on('error', (err) => { failed ??= err; });
91
179
  child.once('exit', (code, signal) => this.exited(child, code, signal));
180
+ if (child.pid)
181
+ this.recordPid(child, config);
92
182
  const endpoint = { url: `http://127.0.0.1:${port}`, apiKey };
93
- await this.ready(endpoint, child);
183
+ try {
184
+ await this.ready(endpoint, child, () => failed);
185
+ await this.owned(endpoint, child);
186
+ if (this.child !== child)
187
+ throw new Error('Qdrant was stopped while it started');
188
+ }
189
+ catch (err) {
190
+ if (this.child === child)
191
+ this.child = null; // exited() then schedules nothing
192
+ if (child.pid && child.exitCode === null && child.signalCode === null)
193
+ child.kill('SIGTERM');
194
+ throw err;
195
+ }
94
196
  this.endpoint = endpoint;
95
197
  this.opts.log(`[memory] Qdrant up on 127.0.0.1:${port}`);
96
198
  return endpoint;
97
199
  }
98
- /** Answering `/readyz` within 30 s, or the start failed. */
99
- async ready(e, child) {
200
+ /** The pid file, and the child tracked for the host's exit; the file goes when that child does. */
201
+ recordPid(child, config) {
202
+ const file = join(this.opts.dir, PID_FILE);
203
+ writeFileSync(file, JSON.stringify({ pid: child.pid, config, boot: bootTime() }), { mode: 0o600 });
204
+ track(child);
205
+ child.once('exit', () => {
206
+ try {
207
+ if (JSON.parse(readFileSync(file, 'utf8')).pid === child.pid)
208
+ rmSync(file, { force: true });
209
+ }
210
+ catch { /* none */ }
211
+ });
212
+ }
213
+ /** A Qdrant left running on this storage by a host that died without `close()` (a crash, a kill): stopped before
214
+ * another starts on it. Only when the pid is still the process written down — this boot, its command naming this
215
+ * folder's config — never an unrelated process that has the pid since. */
216
+ async reapLeftover() {
217
+ const file = join(this.opts.dir, PID_FILE);
218
+ let rec;
219
+ try {
220
+ rec = JSON.parse(readFileSync(file, 'utf8'));
221
+ }
222
+ catch {
223
+ return;
224
+ }
225
+ const pid = Number(rec.pid);
226
+ const config = join(this.opts.dir, 'config.yaml');
227
+ if (rec.config === config && sameBoot(rec.boot) && pid !== this.child?.pid && alive(pid) && commandOf(pid)?.includes(config)) {
228
+ this.opts.log(`[memory] stopping the Qdrant left running on ${this.opts.dir} (pid ${pid})`);
229
+ for (const [signal, ms] of [['SIGTERM', 10_000], ['SIGKILL', 2000]]) {
230
+ try {
231
+ process.kill(pid, signal);
232
+ }
233
+ catch { /* gone */ }
234
+ for (const until = Date.now() + ms; alive(pid) && Date.now() < until;)
235
+ await pause(50);
236
+ if (!alive(pid))
237
+ break;
238
+ }
239
+ }
240
+ rmSync(file, { force: true });
241
+ }
242
+ /** Answering `/readyz` within 30 s, or the start failed. /readyz asks no key, so none is sent: what answers may not be
243
+ * ours (owned). */
244
+ async ready(e, child, failed) {
100
245
  const until = Date.now() + 30_000;
101
246
  while (Date.now() < until) {
102
- if (child.exitCode !== null)
103
- throw new Error(`Qdrant exited (${child.exitCode}) while starting — see ${join(this.opts.dir, 'qdrant.log')}`);
247
+ const err = failed();
248
+ if (err)
249
+ throw new Error(`Qdrant could not be started (${this.opts.binary}): ${err.message}`);
250
+ if (child.exitCode !== null || child.signalCode !== null)
251
+ throw new Error(`Qdrant exited (${child.signalCode ?? child.exitCode}) while starting — see ${join(this.opts.dir, 'qdrant.log')}`);
104
252
  try {
105
- const r = await fetch(`${e.url}/readyz`, { headers: { 'api-key': e.apiKey }, signal: AbortSignal.timeout(1000) });
253
+ const r = await fetch(`${e.url}/readyz`, { signal: AbortSignal.timeout(1000) });
254
+ await r.arrayBuffer().catch(() => undefined);
106
255
  if (r.ok)
107
256
  return;
108
257
  }
109
258
  catch { /* not listening yet */ }
110
- await new Promise((r) => setTimeout(r, 100));
259
+ await pause(100);
111
260
  }
112
- child.kill('SIGTERM');
113
261
  throw new Error('Qdrant did not answer within 30 s');
114
262
  }
263
+ /** The port was free when picked, not when Qdrant bound it: another local process may have taken it, answered
264
+ * /readyz, and would be handed the key and every document. Ours is still running, refuses a wrong key and takes
265
+ * this run's — the wrong key asked first, so a process that takes any key never sees the real one. */
266
+ async owned(e, child) {
267
+ const status = async (key) => {
268
+ const r = await fetch(`${e.url}/collections`, { headers: { 'api-key': key }, signal: AbortSignal.timeout(5000) });
269
+ await r.arrayBuffer().catch(() => undefined);
270
+ return r.status;
271
+ };
272
+ const gone = () => child.exitCode !== null || child.signalCode !== null;
273
+ const notOurs = `what answers on ${e.url} is not this memory's Qdrant`;
274
+ if (gone())
275
+ throw new Error(`${notOurs}: ours exited — see ${join(this.opts.dir, 'qdrant.log')}`);
276
+ const wrong = await status(randomBytes(24).toString('hex'));
277
+ if (wrong !== 401 && wrong !== 403)
278
+ throw new Error(`${notOurs}: it took a wrong key (${wrong})`);
279
+ const right = await status(e.apiKey);
280
+ if (right !== 200)
281
+ throw new Error(`${notOurs}: it refused this run's key (${right})`);
282
+ if (gone())
283
+ throw new Error(`${notOurs}: ours exited — see ${join(this.opts.dir, 'qdrant.log')}`);
284
+ }
115
285
  exited(child, code, signal) {
116
286
  if (this.child !== child)
117
287
  return;
288
+ // One that never came up is its start()'s to report: that start rejects, and schedules nothing.
289
+ const wasUp = this.endpoint !== null;
118
290
  this.child = null;
119
291
  this.endpoint = null;
120
- if (this.stopping)
292
+ if (this.stopping || !wasUp)
121
293
  return;
122
- const why = `Qdrant stopped (${signal ?? code})`;
294
+ this.restartLater(`Qdrant stopped (${signal ?? code})`);
295
+ }
296
+ /** Started again after a growing pause; a restart that fails counts as another death. */
297
+ restartLater(why) {
123
298
  this.opts.log(`[memory] ${why}`);
124
299
  const now = Date.now();
125
300
  this.deaths = [...this.deaths.filter((t) => now - t < 10 * 60_000), now];
@@ -127,15 +302,26 @@ export class QdrantProcess {
127
302
  this.opts.onDown?.(`${why}; five times in ten minutes, so it is left stopped`);
128
303
  return;
129
304
  }
130
- const pause = Math.min(60_000, 1000 * 2 ** (this.deaths.length - 1));
305
+ const wait = Math.min(60_000, 1000 * 2 ** (this.deaths.length - 1));
131
306
  this.restartTimer = setTimeout(() => {
132
307
  this.restartTimer = null;
133
- this.start().catch((err) => this.opts.onDown?.(err.message));
134
- }, pause);
308
+ if (this.stopping)
309
+ return;
310
+ // Held until its start ends: a stop() meanwhile makes that start give up and stop what it began.
311
+ const run = this.start().then(() => undefined, (err) => {
312
+ if (this.stopping)
313
+ return; // given up for a stop: nothing is down
314
+ this.opts.onDown?.(err.message);
315
+ this.restartLater(err.message);
316
+ }).finally(() => { if (this.restarting === run)
317
+ this.restarting = null; });
318
+ this.restarting = run;
319
+ }, wait);
135
320
  this.restartTimer.unref?.();
136
321
  }
137
322
  stop() {
138
323
  this.stopping = true;
324
+ this.stops++;
139
325
  if (this.restartTimer) {
140
326
  clearTimeout(this.restartTimer);
141
327
  this.restartTimer = null;
@@ -146,15 +332,19 @@ export class QdrantProcess {
146
332
  if (c)
147
333
  c.kill('SIGTERM');
148
334
  }
149
- /** Stop and wait for the process to be gone — before its storage is deleted. */
335
+ /** Stop and wait for the process to be gone — before its storage is deleted. A restart that was starting one ends
336
+ * first, and the Qdrant it began is waited for too: none is left running after a close. */
150
337
  async stopAndWait(ms = 10_000) {
151
- const c = this.child;
338
+ const restart = this.restarting;
152
339
  this.stop();
153
- if (!c || c.exitCode !== null)
154
- return;
155
- await new Promise((resolve) => {
340
+ const within = (p) => new Promise((resolve) => {
156
341
  const t = setTimeout(resolve, ms);
157
- c.once('exit', () => { clearTimeout(t); resolve(); });
342
+ const done = () => { clearTimeout(t); resolve(); };
343
+ p.then(done, done);
158
344
  });
345
+ if (restart)
346
+ await within(restart);
347
+ await Promise.all([...this.children].filter((c) => c.exitCode === null && c.signalCode === null)
348
+ .map((c) => within(new Promise((exited) => c.once('exit', exited)))));
159
349
  }
160
350
  }
@@ -14,13 +14,17 @@ interface RebuildDeps {
14
14
  export declare class MemoryRebuild {
15
15
  private readonly d;
16
16
  private running;
17
+ /** The generation `running` was started for: a run() of the same one joins it, a later one waits for it. */
18
+ private runningFor;
17
19
  private generation;
18
20
  constructor(d: RebuildDeps);
19
21
  cancel(): void;
20
22
  settled(): Promise<void>;
21
23
  pending(): boolean;
22
- /** Written BEFORE clearing an old index. A crash at any later point leaves a resumable rebuild. */
24
+ /** Written BEFORE clearing an old index. A crash at any later point leaves a resumable rebuild. A run in flight is
25
+ * cancelled: it wrote into the index about to be cleared, and must neither move nor delete the new checkpoint. */
23
26
  begin(): void;
27
+ /** Joins the run in flight for the same begin(); after a newer begin(), waits for the cancelled one to stop first. */
24
28
  run(): Promise<void>;
25
29
  private save;
26
30
  /** One phase from its checkpoint: batches of `size`, up to `concurrency` written at once, the checkpoint moved over
@@ -4,6 +4,8 @@ import { dirname } from 'node:path';
4
4
  export class MemoryRebuild {
5
5
  d;
6
6
  running = null;
7
+ /** The generation `running` was started for: a run() of the same one joins it, a later one waits for it. */
8
+ runningFor = -1;
7
9
  generation = 0;
8
10
  constructor(d) {
9
11
  this.d = d;
@@ -11,11 +13,24 @@ export class MemoryRebuild {
11
13
  cancel() { this.generation++; }
12
14
  async settled() { await this.running?.catch(() => { }); }
13
15
  pending() { return existsSync(this.d.path); }
14
- /** Written BEFORE clearing an old index. A crash at any later point leaves a resumable rebuild. */
15
- begin() { this.save({ phase: 'ltm', after: null }); }
16
+ /** Written BEFORE clearing an old index. A crash at any later point leaves a resumable rebuild. A run in flight is
17
+ * cancelled: it wrote into the index about to be cleared, and must neither move nor delete the new checkpoint. */
18
+ begin() {
19
+ this.generation++;
20
+ this.save({ phase: 'ltm', after: null });
21
+ }
22
+ /** Joins the run in flight for the same begin(); after a newer begin(), waits for the cancelled one to stop first. */
16
23
  run() {
17
- this.running ??= this.resume(this.generation).finally(() => { this.running = null; });
18
- return this.running;
24
+ const generation = this.generation;
25
+ if (this.running && this.runningFor === generation)
26
+ return this.running;
27
+ const before = this.running?.catch(() => { });
28
+ const work = before ? before.then(() => this.resume(generation)) : this.resume(generation);
29
+ const p = work.finally(() => { if (this.running === p)
30
+ this.running = null; });
31
+ this.running = p;
32
+ this.runningFor = generation;
33
+ return p;
19
34
  }
20
35
  save(checkpoint) {
21
36
  mkdirSync(dirname(this.d.path), { recursive: true });
@@ -54,8 +69,11 @@ export class MemoryRebuild {
54
69
  let skip = after !== null;
55
70
  let batch = [];
56
71
  for (const it of all) {
57
- if (cancelled())
72
+ if (cancelled()) {
73
+ // Stopped (a newer begin, a close): what is being written ends before this returns, not into the next index.
74
+ await Promise.allSettled(queue.map((q) => q.p));
58
75
  return false;
76
+ }
59
77
  if (skip) {
60
78
  if (it.uid === after)
61
79
  skip = false;
@@ -11,11 +11,14 @@ export type RecallEvent = {
11
11
  ev: 'open';
12
12
  item: string;
13
13
  };
14
+ /** Events between trims of a book left open for long (a server): the cap holds without a restart. */
15
+ export declare const RECALL_TRIM_EVERY = 1000;
14
16
  export declare class RecallBook {
15
17
  private readonly path;
16
18
  private readonly now;
17
19
  private readonly keep;
18
20
  private chain;
21
+ private appended;
19
22
  constructor(path: string, now?: () => number, keep?: {
20
23
  ms: number;
21
24
  bytes: number;
@@ -14,7 +14,7 @@
14
14
  * Appended in order, a line at a time; a write that fails is dropped (recall never waits on, or fails for, its record).
15
15
  * The ids are the logs' own (`stm:…`, long-term uids): a rebuild from the logs keeps them (only `qdrant/` is made
16
16
  * again), so the record outlives a rebuild and stays true to it. Kept: the last RECALL_KEEP_MS, at most
17
- * RECALL_KEEP_BYTES of the newest (`trim()`).
17
+ * RECALL_KEEP_BYTES of the newest (`trim()`: when memory opens, and every RECALL_TRIM_EVERY events).
18
18
  */
19
19
  import { appendFile, mkdir, readFile, rename, writeFile } from 'node:fs/promises';
20
20
  import { dirname } from 'node:path';
@@ -22,11 +22,14 @@ import { dirname } from 'node:path';
22
22
  export const RECALL_QUERY_CHARS = 2000;
23
23
  export const RECALL_KEEP_MS = 180 * 24 * 60 * 60_000;
24
24
  export const RECALL_KEEP_BYTES = 32 * 1024 * 1024;
25
+ /** Events between trims of a book left open for long (a server): the cap holds without a restart. */
26
+ export const RECALL_TRIM_EVERY = 1000;
25
27
  export class RecallBook {
26
28
  path;
27
29
  now;
28
30
  keep;
29
31
  chain = Promise.resolve();
32
+ appended = 0;
30
33
  constructor(path, now = Date.now, keep = { ms: RECALL_KEEP_MS, bytes: RECALL_KEEP_BYTES }) {
31
34
  this.path = path;
32
35
  this.now = now;
@@ -40,6 +43,8 @@ export class RecallBook {
40
43
  await appendFile(this.path, line, { mode: 0o600 });
41
44
  })
42
45
  .catch(() => { });
46
+ if (++this.appended % RECALL_TRIM_EVERY === 0)
47
+ void this.trim();
43
48
  }
44
49
  /** The events older than `keep.ms` dropped, then the oldest until the rest fit `keep.bytes` — rewritten between
45
50
  * appends (a new file renamed over the old). How many were dropped; 0 on any failure, the record as it was. */
@@ -11,15 +11,19 @@ import { pointId } from './qdrant-client.js';
11
11
  import { queryText } from './embedder.js';
12
12
  import { explore } from './graph.js';
13
13
  import { RERANK_TOP, rerankedOrder } from './reranker.js';
14
- import { children, cut, querySparse } from './text.js';
14
+ import { children, cut, flat, querySparse } from './text.js';
15
15
  import { dateSpan, LONG_SUMMARY_CHUNKS } from './writer.js';
16
16
  import { collectionKey } from '../records.js';
17
- /** What search may show: active items, never a throwaway run, never superseded or archived knowledge. */
17
+ /** What search may show: active items, never a throwaway run, never superseded or archived knowledge. `kinds`: a
18
+ * record's kind is its `engine` (every point of a record has `kind: episode`), a learned item's its `kind`. */
18
19
  function baseFilter(kinds) {
19
- return {
20
- must: [{ key: 'synthetic', match: { value: false } }, { key: 'status', match: { value: 'active' } },
21
- ...(kinds?.length ? [{ key: 'engine', match: { any: kinds } }] : [])],
22
- };
20
+ const ofKind = kinds?.length ? [{
21
+ should: [
22
+ { must: [{ key: 'tier', match: { value: 'stm' } }, { key: 'engine', match: { any: kinds } }] },
23
+ { must: [{ key: 'tier', match: { value: 'ltm' } }, { key: 'kind', match: { any: kinds } }] },
24
+ ],
25
+ }] : [];
26
+ return { must: [{ key: 'synthetic', match: { value: false } }, { key: 'status', match: { value: 'active' } }, ...ofKind] };
23
27
  }
24
28
  async function items(d, uids) {
25
29
  const got = await d.client.retrieve(uids.map((u) => pointId(u, 0)));
@@ -76,6 +80,11 @@ async function reranked(d, query, list, dated) {
76
80
  * everything else are two searches of that many each, interleaved, so neither crowds the other out of the room. */
77
81
  const SINGLE_LIST_UP_TO = 20;
78
82
  export async function memorySearch(d, q) {
83
+ // A date that is not one would leave its end open: the search would quietly ignore the range it was asked for.
84
+ for (const [end, at] of [['from', q.from], ['to', q.to]]) {
85
+ if (at && !dateSpan(at))
86
+ throw new Error(`memory search: ${end} "${at}" is not a date (YYYY-MM-DD, YYYY-MM or YYYY)`);
87
+ }
79
88
  const projects = q.collections?.length ? q.collections.map(collectionKey) : undefined;
80
89
  // Up to 200: a question that gathers ("everything she did with her family") needs the facts of many threads at once,
81
90
  // a dated observation a line.
@@ -128,7 +137,7 @@ export async function memorySearch(d, q) {
128
137
  return out;
129
138
  }
130
139
  /** For each hit whose best point was a chunk of its text, that chunk — found again from the text (writer.ts chunks a
131
- * request's whole body; an item's body, and a summary too long for one key vector). */
140
+ * request's whole body; an item's body, and a summary too long for one key vector) — unless the key starts with it. */
132
141
  async function passages(d, hits) {
133
142
  const chunked = hits.filter((h) => h.point && h.point !== pointId(h.item, 0));
134
143
  if (!chunked.length)
@@ -144,7 +153,9 @@ async function passages(d, hits) {
144
153
  const summary = String(p.summary ?? '');
145
154
  const kids = p.tier === 'stm' ? children(body) : [...children(body), ...(children(summary).length >= LONG_SUMMARY_CHUNKS ? children(summary) : [])];
146
155
  const k = kids.findIndex((_, i) => pointId(h.item, i + 1) === h.point);
147
- if (k >= 0)
156
+ // A chunk that is the key's own start is not a passage found elsewhere: a short record's only chunk is its whole
157
+ // key, the two points tie, and either may come back first.
158
+ if (k >= 0 && !flat(summary).startsWith(kids[k]))
148
159
  out.set(h.item, kids[k]);
149
160
  }
150
161
  return out;
@@ -1,2 +1,10 @@
1
+ /**
2
+ * Secrets are scrubbed from what memory sends to a language model and from what consolidation keeps: provider keys,
3
+ * tokens, credentials in URLs, private keys, e-mail local parts, JWTs, `key=value` secrets; home directories become `~`;
4
+ * a long high-entropy run last. The records you add are kept as you wrote them.
5
+ *
6
+ * The input can be anything a record held, so every pattern is bounded: no rule may take more than linear time on a
7
+ * hostile string (a long run of what almost matches).
8
+ */
1
9
  /** Scrub one string. Never throws; returns `''` for a non-string. */
2
10
  export declare function redactString(input: unknown): string;