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.
- package/CHANGELOG.md +56 -0
- package/README.md +25 -9
- package/dist/core/consolidate.d.ts +24 -9
- package/dist/core/consolidate.js +101 -49
- package/dist/core/episodes.d.ts +1 -1
- package/dist/core/episodes.js +2 -8
- package/dist/core/extract.d.ts +3 -0
- package/dist/core/extract.js +12 -5
- package/dist/core/gather.d.ts +3 -0
- package/dist/core/gather.js +16 -10
- package/dist/core/model.d.ts +9 -4
- package/dist/core/model.js +39 -40
- package/dist/core/profiles.d.ts +1 -1
- package/dist/core/profiles.js +12 -9
- package/dist/core/qdrant-binary.d.ts +52 -5
- package/dist/core/qdrant-binary.js +197 -48
- package/dist/core/qdrant-client.d.ts +6 -21
- package/dist/core/qdrant-client.js +36 -100
- package/dist/core/qdrant-process.d.ts +30 -2
- package/dist/core/qdrant-process.js +217 -27
- package/dist/core/rebuild.d.ts +5 -1
- package/dist/core/rebuild.js +23 -5
- package/dist/core/recall-log.d.ts +3 -0
- package/dist/core/recall-log.js +6 -1
- package/dist/core/recall.js +19 -8
- 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 +27 -16
- package/dist/core/store.d.ts +7 -1
- package/dist/core/store.js +101 -33
- package/dist/core/text.d.ts +6 -2
- package/dist/core/text.js +19 -6
- 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 +96 -15
- package/dist/importers/git.js +3 -1
- package/dist/importers/jsonl.d.ts +6 -0
- package/dist/importers/jsonl.js +10 -5
- package/dist/importers/lines.d.ts +13 -0
- package/dist/importers/lines.js +70 -0
- package/dist/importers/mbox.js +32 -12
- package/dist/importers/slack.js +5 -2
- package/dist/importers/text.js +85 -14
- package/dist/llm.d.ts +10 -8
- package/dist/llm.js +53 -7
- package/dist/memory.d.ts +7 -2
- package/dist/memory.js +284 -57
- 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
|
@@ -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
|
-
/**
|
|
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,
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
99
|
-
|
|
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
|
-
|
|
103
|
-
|
|
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`, {
|
|
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
|
|
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
|
-
|
|
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
|
|
305
|
+
const wait = Math.min(60_000, 1000 * 2 ** (this.deaths.length - 1));
|
|
131
306
|
this.restartTimer = setTimeout(() => {
|
|
132
307
|
this.restartTimer = null;
|
|
133
|
-
|
|
134
|
-
|
|
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
|
|
338
|
+
const restart = this.restarting;
|
|
152
339
|
this.stop();
|
|
153
|
-
|
|
154
|
-
return;
|
|
155
|
-
await new Promise((resolve) => {
|
|
340
|
+
const within = (p) => new Promise((resolve) => {
|
|
156
341
|
const t = setTimeout(resolve, ms);
|
|
157
|
-
|
|
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
|
}
|
package/dist/core/rebuild.d.ts
CHANGED
|
@@ -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
|
package/dist/core/rebuild.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
18
|
-
|
|
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;
|
package/dist/core/recall-log.js
CHANGED
|
@@ -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. */
|
package/dist/core/recall.js
CHANGED
|
@@ -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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
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;
|
package/dist/core/redact.d.ts
CHANGED
|
@@ -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;
|