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
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The Qdrant binary memory runs when you give it no Qdrant server of your own: one pinned release, fetched on first use
|
|
3
3
|
* from Qdrant's GitHub release, verified against the size and SHA-256 pinned below, unpacked next to it (cache dir,
|
|
4
|
-
* paths.ts), and trusted after that through a `.verified` marker.
|
|
4
|
+
* paths.ts), and trusted after that through a `.verified` marker while the folder is this user's alone.
|
|
5
5
|
*
|
|
6
6
|
* Qdrant publishes no checksums, so the hashes are pinned here from its release. Upgrading means a new version and new
|
|
7
7
|
* hashes; Qdrant reads storage only from the minor version before it, so after a version change the index is written
|
|
8
8
|
* again from the logs (rebuild.ts) rather than migrated: it is derived data.
|
|
9
9
|
*/
|
|
10
|
-
import { createHash } from 'node:crypto';
|
|
11
|
-
import { chmodSync, createWriteStream, existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
12
|
-
import {
|
|
10
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
11
|
+
import { chmodSync, closeSync, createReadStream, createWriteStream, existsSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, renameSync, rmSync, statSync, utimesSync, writeFileSync, writeSync, } from 'node:fs';
|
|
12
|
+
import { hostname } from 'node:os';
|
|
13
|
+
import { dirname, join } from 'node:path';
|
|
13
14
|
import { Readable } from 'node:stream';
|
|
14
15
|
import { pipeline } from 'node:stream/promises';
|
|
15
16
|
import { spawn } from 'node:child_process';
|
|
@@ -34,75 +35,223 @@ export function qdrantBinaryPath(a = qdrantAsset()) {
|
|
|
34
35
|
}
|
|
35
36
|
export const QDRANT_RELEASE_BASE = `https://github.com/qdrant/qdrant/releases/download/v${QDRANT_VERSION}`;
|
|
36
37
|
const assetUrl = (a) => `${QDRANT_RELEASE_BASE}/${a.asset}`;
|
|
37
|
-
/**
|
|
38
|
+
/**
|
|
39
|
+
* Run only what this user alone could have put there: the cache, the folder, the binary and its marker owned by this
|
|
40
|
+
* user, no symlink, writable by no one else. The archive's hash is pinned but not the unpacked binary's (that needs every
|
|
41
|
+
* platform's archive to compute), so on a shared machine this is what stops a binary swapped in after unpacking.
|
|
42
|
+
* Windows has no owner bits; there the user's profile ACLs keep others out.
|
|
43
|
+
*/
|
|
44
|
+
export function ownedSafely(path) {
|
|
45
|
+
if (typeof process.getuid !== 'function')
|
|
46
|
+
return !lstatSync(path).isSymbolicLink();
|
|
47
|
+
const st = lstatSync(path);
|
|
48
|
+
return !st.isSymbolicLink() && st.uid === process.getuid() && (st.mode & 0o022) === 0;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* The folder made (0700) or made private, and this user's alone — or refused. What is verified in it is run or loaded
|
|
52
|
+
* later: a folder others could write to lets them swap a binary between its check and its run, or a download between
|
|
53
|
+
* its hash and its unpacking. (`mkdirSync`'s mode applies only to a folder it makes.)
|
|
54
|
+
*/
|
|
55
|
+
export function privateDir(dir) {
|
|
56
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
57
|
+
chmodSync(dir, 0o700);
|
|
58
|
+
if (!ownedSafely(dir)) {
|
|
59
|
+
throw new Error(`${dir} is not this user's alone (its owner, its permissions, or it is a link): set MEMTRAILS_CACHE to a folder of your own`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
/** The binary is in place, its archive was verified when it was unpacked, and no one else could have changed it since. */
|
|
38
63
|
export function qdrantReady(a = qdrantAsset()) {
|
|
39
64
|
const bin = qdrantBinaryPath(a);
|
|
40
|
-
if (!a || !bin
|
|
65
|
+
if (!a || !bin)
|
|
41
66
|
return false;
|
|
67
|
+
const marker = join(qdrantDir(), '.verified');
|
|
42
68
|
try {
|
|
43
|
-
return readFileSync(
|
|
69
|
+
return readFileSync(marker, 'utf8').trim() === `${a.size}:${a.sha256}` && statSync(bin).size > 0 &&
|
|
70
|
+
[dirname(qdrantDir()), qdrantDir(), bin, marker].every(ownedSafely);
|
|
44
71
|
}
|
|
45
72
|
catch {
|
|
46
73
|
return false;
|
|
47
74
|
}
|
|
48
75
|
}
|
|
76
|
+
/** The system's tar; on Windows by full path, so a `tar.exe` in the working folder or on a planted PATH is not run. */
|
|
77
|
+
function tarProgram() {
|
|
78
|
+
return process.platform === 'win32' ? join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'tar.exe') : 'tar';
|
|
79
|
+
}
|
|
49
80
|
/** Unpack one archive with the system's tar (bsdtar on macOS and Windows reads zip too). */
|
|
50
81
|
function untar(archive, into) {
|
|
51
82
|
return new Promise((resolve, reject) => {
|
|
52
|
-
const child = spawn(
|
|
83
|
+
const child = spawn(tarProgram(), ['-xf', archive, '-C', into], { stdio: 'ignore' });
|
|
53
84
|
child.once('error', reject);
|
|
54
85
|
child.once('exit', (code) => (code === 0 ? resolve() : reject(new Error(`tar exited ${code}`))));
|
|
55
86
|
});
|
|
56
87
|
}
|
|
88
|
+
/** A download given up after this long without a byte (the answer's headers included). */
|
|
89
|
+
export const DOWNLOAD_STALL_MS = 60_000;
|
|
57
90
|
/**
|
|
58
|
-
*
|
|
59
|
-
*
|
|
91
|
+
* `url` fetched into a fresh temp folder inside `dir`, never to a fixed name: two first runs at once (two processes, or
|
|
92
|
+
* two memories opened together in one) wrote the same `.part` and could rename each other's half-written file. The
|
|
93
|
+
* file is then checked as it is on disk, not as it streamed past, against its pinned size and SHA-256. Returns the
|
|
94
|
+
* file and its folder; the caller renames the file into place and removes the folder. Throws (leaving nothing) on a
|
|
95
|
+
* failure, a mismatch, or `stallMs` without a byte.
|
|
60
96
|
*/
|
|
61
|
-
export async function
|
|
62
|
-
const a = qdrantAsset();
|
|
63
|
-
if (!a)
|
|
64
|
-
throw new Error(`no Qdrant build for ${process.platform}-${process.arch}`);
|
|
65
|
-
const bin = qdrantBinaryPath(a);
|
|
66
|
-
if (qdrantReady(a))
|
|
67
|
-
return bin;
|
|
68
|
-
const dir = qdrantDir();
|
|
97
|
+
export async function downloadVerified(url, dir, pin, o = {}) {
|
|
69
98
|
mkdirSync(dir, { recursive: true });
|
|
70
|
-
const
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
99
|
+
const tmp = mkdtempSync(join(dir, '.download-'));
|
|
100
|
+
const file = join(tmp, 'file');
|
|
101
|
+
const name = url.slice(url.lastIndexOf('/') + 1);
|
|
102
|
+
const stallMs = o.stallMs ?? DOWNLOAD_STALL_MS;
|
|
103
|
+
const stalled = new AbortController();
|
|
104
|
+
let timer;
|
|
105
|
+
const arm = () => {
|
|
106
|
+
clearTimeout(timer);
|
|
107
|
+
timer = setTimeout(() => stalled.abort(new Error(`${name}: no data for ${Math.round(stallMs / 1000)} s`)), stallMs);
|
|
108
|
+
};
|
|
109
|
+
const signal = o.signal ? AbortSignal.any([o.signal, stalled.signal]) : stalled.signal;
|
|
79
110
|
try {
|
|
111
|
+
arm();
|
|
112
|
+
const res = await (o.fetchImpl ?? fetch)(url, { signal, redirect: 'follow' });
|
|
113
|
+
if (!res.ok || !res.body)
|
|
114
|
+
throw new Error(`HTTP ${res.status} for ${name}`);
|
|
115
|
+
let size = 0;
|
|
80
116
|
await pipeline(Readable.fromWeb(res.body), async function* (src) {
|
|
81
117
|
for await (const chunk of src) {
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
size
|
|
85
|
-
|
|
118
|
+
arm();
|
|
119
|
+
size += chunk.length;
|
|
120
|
+
if (size > pin.size)
|
|
121
|
+
throw new Error(`${name}: more than the expected ${pin.size} bytes`);
|
|
122
|
+
yield chunk;
|
|
86
123
|
}
|
|
87
|
-
}, createWriteStream(
|
|
124
|
+
}, createWriteStream(file, { flags: 'wx', mode: 0o600 }), { signal });
|
|
125
|
+
clearTimeout(timer);
|
|
126
|
+
const hash = createHash('sha256');
|
|
127
|
+
for await (const chunk of createReadStream(file))
|
|
128
|
+
hash.update(chunk);
|
|
129
|
+
const got = { size: statSync(file).size, sha256: hash.digest('hex') };
|
|
130
|
+
if (got.size !== pin.size || got.sha256 !== pin.sha256) {
|
|
131
|
+
throw new Error(`${name}: got ${got.size} bytes sha256 ${got.sha256}, expected ${pin.size} ${pin.sha256}`);
|
|
132
|
+
}
|
|
133
|
+
return { file, tmp };
|
|
88
134
|
}
|
|
89
135
|
catch (err) {
|
|
90
|
-
rmSync(
|
|
91
|
-
throw err;
|
|
136
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
137
|
+
throw stalled.signal.aborted ? stalled.signal.reason : err;
|
|
138
|
+
}
|
|
139
|
+
finally {
|
|
140
|
+
clearTimeout(timer);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
/** A lock not touched for this long is a dead holder's (the holder touches it every quarter of this). */
|
|
144
|
+
export const LOCK_STALE_MS = 120_000;
|
|
145
|
+
function lockStale(lock, staleMs) {
|
|
146
|
+
try {
|
|
147
|
+
const [pid, host] = readFileSync(lock, 'utf8').split(' ');
|
|
148
|
+
if (host === hostname() && Number(pid) !== process.pid) {
|
|
149
|
+
try {
|
|
150
|
+
process.kill(Number(pid), 0);
|
|
151
|
+
}
|
|
152
|
+
catch (err) {
|
|
153
|
+
if (err.code === 'ESRCH')
|
|
154
|
+
return true; // its process is gone
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return Date.now() - statSync(lock).mtimeMs > staleMs;
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
return false; // gone meanwhile: just try again
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* `f` run holding `<path>.lock`, created with O_EXCL: a second first run, in another process or this one, waits and
|
|
165
|
+
* then finds the first one's result. A lock whose process is gone, or untouched for `staleMs`, is taken over. Nothing
|
|
166
|
+
* rests on the lock for correctness (every file is verified in a private temp and renamed into place); it keeps two
|
|
167
|
+
* runs from fetching the same hundreds of MB, and from swapping folders under each other.
|
|
168
|
+
*/
|
|
169
|
+
export async function withLock(path, f, o = {}) {
|
|
170
|
+
const lock = `${path}.lock`;
|
|
171
|
+
const staleMs = o.staleMs ?? LOCK_STALE_MS;
|
|
172
|
+
const mine = `${process.pid} ${hostname()} ${randomBytes(8).toString('hex')}`;
|
|
173
|
+
mkdirSync(dirname(lock), { recursive: true });
|
|
174
|
+
for (;;) {
|
|
175
|
+
try {
|
|
176
|
+
const fd = openSync(lock, 'wx', 0o600);
|
|
177
|
+
writeSync(fd, mine);
|
|
178
|
+
closeSync(fd);
|
|
179
|
+
break;
|
|
180
|
+
}
|
|
181
|
+
catch (err) {
|
|
182
|
+
if (err.code !== 'EEXIST')
|
|
183
|
+
throw err;
|
|
184
|
+
}
|
|
185
|
+
if (lockStale(lock, staleMs)) {
|
|
186
|
+
rmSync(lock, { force: true });
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
o.signal?.throwIfAborted();
|
|
190
|
+
await new Promise((ok) => setTimeout(ok, o.pollMs ?? 250));
|
|
92
191
|
}
|
|
93
|
-
const
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
192
|
+
const beat = setInterval(() => {
|
|
193
|
+
try {
|
|
194
|
+
utimesSync(lock, new Date(), new Date());
|
|
195
|
+
}
|
|
196
|
+
catch { /* taken over: nothing to keep alive */ }
|
|
197
|
+
}, Math.max(10, staleMs / 4));
|
|
198
|
+
beat.unref();
|
|
199
|
+
try {
|
|
200
|
+
return await f();
|
|
97
201
|
}
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
202
|
+
finally {
|
|
203
|
+
clearInterval(beat);
|
|
204
|
+
try {
|
|
205
|
+
if (readFileSync(lock, 'utf8') === mine)
|
|
206
|
+
rmSync(lock, { force: true }); // not one taken over meanwhile
|
|
207
|
+
}
|
|
208
|
+
catch { /* already gone */ }
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* Fetch, verify and unpack the binary unless it is already there. Throws on a network failure or stall, a size or
|
|
213
|
+
* hash mismatch (the download is deleted), a platform Qdrant does not ship for, or a folder others could change; the
|
|
214
|
+
* caller retries with backoff. `asset` is for tests: another archive with its own pins.
|
|
215
|
+
*/
|
|
216
|
+
export async function ensureQdrant(opts = {}) {
|
|
217
|
+
const a = opts.asset ?? qdrantAsset();
|
|
218
|
+
if (!a)
|
|
219
|
+
throw new Error(`no Qdrant build for ${process.platform}-${process.arch}`);
|
|
220
|
+
const bin = qdrantBinaryPath(a);
|
|
221
|
+
if (qdrantReady(a))
|
|
222
|
+
return bin;
|
|
223
|
+
const dir = qdrantDir();
|
|
224
|
+
const cache = dirname(dir);
|
|
225
|
+
privateDir(cache);
|
|
226
|
+
return withLock(dir, async () => {
|
|
227
|
+
if (qdrantReady(a))
|
|
228
|
+
return bin; // another run finished it while this one waited
|
|
229
|
+
opts.log?.(`[memtrails] fetching Qdrant ${QDRANT_VERSION} (${Math.round(a.size / 1e6)} MB)`);
|
|
230
|
+
const { file, tmp } = await downloadVerified(assetUrl(a), cache, a, opts);
|
|
231
|
+
try {
|
|
232
|
+
// Unpacked into a fresh folder and moved into place whole: never a half-unpacked binary under the real name.
|
|
233
|
+
const archive = join(tmp, a.asset);
|
|
234
|
+
renameSync(file, archive);
|
|
235
|
+
const unpacked = join(tmp, 'unpacked');
|
|
236
|
+
mkdirSync(unpacked);
|
|
237
|
+
await untar(archive, unpacked);
|
|
238
|
+
const exe = join(unpacked, a.exe);
|
|
239
|
+
if (!existsSync(exe) || !lstatSync(exe).isFile() || statSync(exe).size === 0)
|
|
240
|
+
throw new Error(`${a.asset} did not contain ${a.exe}`);
|
|
241
|
+
chmodSync(exe, 0o755);
|
|
242
|
+
writeFileSync(join(unpacked, '.verified'), `${a.size}:${a.sha256}\n`, { mode: 0o644 });
|
|
243
|
+
chmodSync(join(unpacked, '.verified'), 0o644);
|
|
244
|
+
chmodSync(unpacked, 0o755);
|
|
245
|
+
if (existsSync(dir))
|
|
246
|
+
renameSync(dir, join(tmp, 'old')); // unverified or not this user's alone; removed with tmp
|
|
247
|
+
renameSync(unpacked, dir);
|
|
248
|
+
}
|
|
249
|
+
finally {
|
|
250
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
251
|
+
}
|
|
252
|
+
if (!qdrantReady(a))
|
|
253
|
+
throw new Error(`${dir} is not this user's alone (its owner or permissions): not running a binary others could change`);
|
|
254
|
+
opts.log?.(`[memtrails] Qdrant ${QDRANT_VERSION} ready`);
|
|
255
|
+
return bin;
|
|
256
|
+
}, { signal: opts.signal });
|
|
108
257
|
}
|
|
@@ -1,15 +1,14 @@
|
|
|
1
1
|
import type { QdrantEndpoint } from './qdrant-process.js';
|
|
2
2
|
export declare const COLLECTION = "memory";
|
|
3
|
-
/** The payload field a tenant client keys every point by, and the original point id it keeps beside the scoped one. */
|
|
4
|
-
export declare const TENANT_FIELD = "user";
|
|
5
3
|
export declare const DIMS: 768;
|
|
6
4
|
/** What the index is made of; a change empties it and it is written in again from the logs (rebuild.ts).
|
|
7
5
|
* 2: binary quantisation with rescoring (½ the RAM of int8, recall 0.98–1.00 measured at 1–3M points), keyword terms
|
|
8
6
|
* folded and segmented for every language (text.ts); 3: on-disk binary copy, HNSW and payload indexes;
|
|
9
7
|
* 4: remote EmbeddingGemma GPU vectors (768 dimensions, explicit runtime/pooling tag in embedder.ts); 5: nothing cut —
|
|
10
8
|
* every chunk of a body (no 12-chunk limit), short-term entries chunked too, cues numbered below the key, full payloads;
|
|
11
|
-
* 6: every captured request indexed for good — however old, consolidated or not (the raw record is the truth)
|
|
12
|
-
|
|
9
|
+
* 6: every captured request indexed for good — however old, consolidated or not (the raw record is the truth);
|
|
10
|
+
* 7: a record's kind (`engine`) on every point of it and indexed, so a kinds filter finds a record by its chunks. */
|
|
11
|
+
export declare const INDEX_LAYOUT = 7;
|
|
13
12
|
export interface SparseVector {
|
|
14
13
|
indices: number[];
|
|
15
14
|
values: number[];
|
|
@@ -33,25 +32,14 @@ export declare class QdrantError extends Error {
|
|
|
33
32
|
export interface QdrantClientOptions {
|
|
34
33
|
/** The collection (COLLECTION when not given). */
|
|
35
34
|
collection?: string;
|
|
36
|
-
/** Many users in one collection: the user every call is for — read on each call from the caller's context; null or ''
|
|
37
|
-
* refuses the call. */
|
|
38
|
-
tenant?: () => string | null | undefined;
|
|
39
35
|
}
|
|
40
36
|
export declare class QdrantClient {
|
|
41
37
|
private readonly endpoint;
|
|
42
38
|
private readonly timeoutMs;
|
|
43
|
-
|
|
44
|
-
private readonly
|
|
39
|
+
/** `/collections/<name>`, the name encoded: a name you give is a path segment, never more of the path. */
|
|
40
|
+
private readonly at;
|
|
45
41
|
/** `endpoint` may start Qdrant (the service runs it only while memory has work): every call waits for it. */
|
|
46
42
|
constructor(endpoint: () => QdrantEndpoint | null | Promise<QdrantEndpoint | null>, timeoutMs?: number, opts?: QdrantClientOptions);
|
|
47
|
-
/** The user this call is for (a tenant client), or null (one person's own index). Throws when a tenant client has none. */
|
|
48
|
-
private user;
|
|
49
|
-
/** A filter with the user added — unchanged for one person's own index. */
|
|
50
|
-
private scoped;
|
|
51
|
-
/** A point's id in a shared collection: the user's and the item's (pointId of both), so users never share a point. */
|
|
52
|
-
private scopedId;
|
|
53
|
-
/** What a read hands back: the point's own id (as the writer named it) and only this user's points. */
|
|
54
|
-
private own;
|
|
55
43
|
private call;
|
|
56
44
|
/** Drop the whole collection: the index is derived, written again from the logs (rebuild.ts). */
|
|
57
45
|
dropCollection(): Promise<void>;
|
|
@@ -74,11 +62,8 @@ export declare class QdrantClient {
|
|
|
74
62
|
score: number;
|
|
75
63
|
payload: Record<string, unknown>;
|
|
76
64
|
}>>;
|
|
77
|
-
/** Each of these items' best cosine to one vector among its points of one part (key, child, cue) — the ranker's
|
|
78
|
-
* per-family similarities. An item with no such point is not in the map. */
|
|
79
|
-
bestPerItem(vector: number[], items: string[], part: string, timeoutMs?: number): Promise<Map<string, number>>;
|
|
80
65
|
/** `nearest` for several vectors in one call. */
|
|
81
|
-
/** `fields`: only these payload fields (and the item
|
|
66
|
+
/** `fields`: only these payload fields (and the item), for a caller that needs no more — a walk over
|
|
82
67
|
* many neighbours reads ids, not the bodies of long requests. */
|
|
83
68
|
nearestMany(vectors: number[][], filter: Filter, limit?: number, timeoutMs?: number, fields?: string[]): Promise<Array<Array<{
|
|
84
69
|
item: string;
|
|
@@ -10,27 +10,19 @@
|
|
|
10
10
|
* A query ranks items once per query vector and once by keywords (each hit grouped by item, so an item found through
|
|
11
11
|
* several children counts once), then fuses those item rankings by rank (RRF). Only `active` items are ever returned
|
|
12
12
|
* to recall; superseded and archived ones stay in the log, out of the index.
|
|
13
|
-
*
|
|
14
|
-
* MANY USERS (a server holding a memory per user): one collection for all of them, its `user` field a
|
|
15
|
-
* tenant index (`is_tenant`). A client made with `tenant` adds that user to every query, count, scroll, update and
|
|
16
|
-
* delete, stamps it on every point it writes, and keys each point by user and item, so two users' identical items are
|
|
17
|
-
* two points. The user comes from the caller's context (whose work this is), never from a query's words; a call made
|
|
18
|
-
* with no user is refused before it reaches Qdrant.
|
|
19
13
|
*/
|
|
20
14
|
import { createHash } from 'node:crypto';
|
|
21
15
|
import { MEMORY_EMBEDDER } from './embedder.js';
|
|
22
16
|
export const COLLECTION = 'memory';
|
|
23
|
-
/** The payload field a tenant client keys every point by, and the original point id it keeps beside the scoped one. */
|
|
24
|
-
export const TENANT_FIELD = 'user';
|
|
25
|
-
const TENANT_PID = 'pid';
|
|
26
17
|
export const DIMS = MEMORY_EMBEDDER.dims;
|
|
27
18
|
/** What the index is made of; a change empties it and it is written in again from the logs (rebuild.ts).
|
|
28
19
|
* 2: binary quantisation with rescoring (½ the RAM of int8, recall 0.98–1.00 measured at 1–3M points), keyword terms
|
|
29
20
|
* folded and segmented for every language (text.ts); 3: on-disk binary copy, HNSW and payload indexes;
|
|
30
21
|
* 4: remote EmbeddingGemma GPU vectors (768 dimensions, explicit runtime/pooling tag in embedder.ts); 5: nothing cut —
|
|
31
22
|
* every chunk of a body (no 12-chunk limit), short-term entries chunked too, cues numbered below the key, full payloads;
|
|
32
|
-
* 6: every captured request indexed for good — however old, consolidated or not (the raw record is the truth)
|
|
33
|
-
|
|
23
|
+
* 6: every captured request indexed for good — however old, consolidated or not (the raw record is the truth);
|
|
24
|
+
* 7: a record's kind (`engine`) on every point of it and indexed, so a kinds filter finds a record by its chunks. */
|
|
25
|
+
export const INDEX_LAYOUT = 7;
|
|
34
26
|
/** The memory-mapped 1-bit copy finds candidates; full vectors on disk re-score 3× as many as asked. */
|
|
35
27
|
const RESCORE = { quantization: { rescore: true, oversampling: 3.0 } };
|
|
36
28
|
/** Two segments: Qdrant's default is one a core, which costs a laptop RAM and per-query work for nothing at memory's
|
|
@@ -65,42 +57,13 @@ export class QdrantError extends Error {
|
|
|
65
57
|
export class QdrantClient {
|
|
66
58
|
endpoint;
|
|
67
59
|
timeoutMs;
|
|
68
|
-
|
|
69
|
-
|
|
60
|
+
/** `/collections/<name>`, the name encoded: a name you give is a path segment, never more of the path. */
|
|
61
|
+
at;
|
|
70
62
|
/** `endpoint` may start Qdrant (the service runs it only while memory has work): every call waits for it. */
|
|
71
63
|
constructor(endpoint, timeoutMs = 10_000, opts = {}) {
|
|
72
64
|
this.endpoint = endpoint;
|
|
73
65
|
this.timeoutMs = timeoutMs;
|
|
74
|
-
this.
|
|
75
|
-
this.collection = opts.collection ?? COLLECTION;
|
|
76
|
-
}
|
|
77
|
-
/** The user this call is for (a tenant client), or null (one person's own index). Throws when a tenant client has none. */
|
|
78
|
-
user() {
|
|
79
|
-
if (!this.opts.tenant)
|
|
80
|
-
return null;
|
|
81
|
-
const u = this.opts.tenant();
|
|
82
|
-
if (!u)
|
|
83
|
-
throw new QdrantError('memory index: this call names no user, so it is refused', 403);
|
|
84
|
-
return u;
|
|
85
|
-
}
|
|
86
|
-
/** A filter with the user added — unchanged for one person's own index. */
|
|
87
|
-
scoped(filter, user) {
|
|
88
|
-
if (user === null)
|
|
89
|
-
return filter;
|
|
90
|
-
return { ...filter, must: [{ key: TENANT_FIELD, match: { value: user } }, ...(filter?.must ?? [])] };
|
|
91
|
-
}
|
|
92
|
-
/** A point's id in a shared collection: the user's and the item's (pointId of both), so users never share a point. */
|
|
93
|
-
scopedId(id, user) {
|
|
94
|
-
return user === null ? id : pointId(`${user}\u0000${id}`, 0);
|
|
95
|
-
}
|
|
96
|
-
/** What a read hands back: the point's own id (as the writer named it) and only this user's points. */
|
|
97
|
-
own(points, user) {
|
|
98
|
-
if (user === null)
|
|
99
|
-
return points;
|
|
100
|
-
return points.filter((p) => p.payload[TENANT_FIELD] === user).map((p) => {
|
|
101
|
-
const { [TENANT_FIELD]: _u, [TENANT_PID]: pid, ...payload } = p.payload;
|
|
102
|
-
return { ...p, id: String(pid ?? p.id), payload };
|
|
103
|
-
});
|
|
66
|
+
this.at = `/collections/${encodeURIComponent(opts.collection ?? COLLECTION)}`;
|
|
104
67
|
}
|
|
105
68
|
async call(method, path, body, timeoutMs = this.timeoutMs) {
|
|
106
69
|
const e = await this.endpoint();
|
|
@@ -138,7 +101,7 @@ export class QdrantClient {
|
|
|
138
101
|
}
|
|
139
102
|
/** Drop the whole collection: the index is derived, written again from the logs (rebuild.ts). */
|
|
140
103
|
async dropCollection() {
|
|
141
|
-
await this.call('DELETE',
|
|
104
|
+
await this.call('DELETE', this.at).catch(() => undefined);
|
|
142
105
|
}
|
|
143
106
|
/** The collection and its payload indexes, made if missing — an index added since a collection was made is added to
|
|
144
107
|
* it in place. All of it on disk, memory-mapped — the vectors, their 1-bit copy, the graph, the payloads and their
|
|
@@ -148,14 +111,14 @@ export class QdrantClient {
|
|
|
148
111
|
async ensureCollection() {
|
|
149
112
|
let info = null;
|
|
150
113
|
try {
|
|
151
|
-
info = (await this.call('GET',
|
|
114
|
+
info = (await this.call('GET', this.at)) ?? {};
|
|
152
115
|
}
|
|
153
116
|
catch (err) {
|
|
154
117
|
if (!(err instanceof QdrantError) || err.status !== 404)
|
|
155
118
|
throw err;
|
|
156
119
|
}
|
|
157
120
|
if (!info) {
|
|
158
|
-
await this.call('PUT',
|
|
121
|
+
await this.call('PUT', this.at, {
|
|
159
122
|
vectors: { dense: { size: DIMS, distance: 'Cosine', on_disk: true } },
|
|
160
123
|
sparse_vectors: { bm25: { modifier: 'idf', index: { on_disk: true } } },
|
|
161
124
|
quantization_config: { binary: { always_ram: false } },
|
|
@@ -167,41 +130,34 @@ export class QdrantClient {
|
|
|
167
130
|
}
|
|
168
131
|
else if (info.config?.optimizer_config?.default_segment_number !== SMALL.optimizers_config.default_segment_number) {
|
|
169
132
|
// Made before: two segments from now on (the optimiser merges them in the background).
|
|
170
|
-
await this.call('PATCH',
|
|
133
|
+
await this.call('PATCH', this.at, { optimizers_config: SMALL.optimizers_config });
|
|
171
134
|
}
|
|
172
135
|
const have = info.payload_schema ?? {};
|
|
173
|
-
// Many users: the user field first, as Qdrant's tenant index (its points kept together on disk, searched per user).
|
|
174
|
-
if (this.opts.tenant && !(TENANT_FIELD in have)) {
|
|
175
|
-
await this.call('PUT', `/collections/${this.collection}/index?wait=true`, { field_name: TENANT_FIELD, field_schema: { type: 'keyword', is_tenant: true, on_disk: true } });
|
|
176
|
-
}
|
|
177
136
|
for (const [field_name, field_schema] of [['item', 'keyword'], ['part', 'keyword'], ['tier', 'keyword'], ['status', 'keyword'],
|
|
178
|
-
['kind', 'keyword'], ['scope', 'keyword'], ['project', 'keyword'], ['session', 'keyword'], ['ts', 'integer'],
|
|
137
|
+
['kind', 'keyword'], ['engine', 'keyword'], ['scope', 'keyword'], ['project', 'keyword'], ['session', 'keyword'], ['ts', 'integer'],
|
|
179
138
|
['synthetic', 'bool'], ['concepts', 'keyword'], ['eventFrom', 'integer'], ['eventTo', 'integer']]) {
|
|
180
139
|
const schema = field_schema === 'keyword' || field_schema === 'integer' ? { type: field_schema, on_disk: true } : field_schema;
|
|
181
140
|
if (!(field_name in have))
|
|
182
|
-
await this.call('PUT',
|
|
141
|
+
await this.call('PUT', `${this.at}/index?wait=true`, { field_name, field_schema: schema });
|
|
183
142
|
}
|
|
184
143
|
}
|
|
185
144
|
async upsert(points, wait = false) {
|
|
186
145
|
if (!points.length)
|
|
187
146
|
return;
|
|
188
|
-
|
|
189
|
-
const out = user === null ? points
|
|
190
|
-
: points.map((p) => ({ ...p, id: this.scopedId(p.id, user), payload: { ...p.payload, [TENANT_FIELD]: user, [TENANT_PID]: p.id } }));
|
|
191
|
-
await this.call('PUT', `/collections/${this.collection}/points?wait=${wait}`, { points: out }, 60_000);
|
|
147
|
+
await this.call('PUT', `${this.at}/points?wait=${wait}`, { points }, 60_000);
|
|
192
148
|
}
|
|
193
149
|
/** Every point of these items — before an item is written again (its children may be fewer now). */
|
|
194
150
|
async deleteItems(items) {
|
|
195
151
|
if (!items.length)
|
|
196
152
|
return;
|
|
197
|
-
await this.call('POST',
|
|
153
|
+
await this.call('POST', `${this.at}/points/delete?wait=true`, { filter: { must: [{ key: 'item', match: { any: items } }] } }, 60_000);
|
|
198
154
|
}
|
|
199
155
|
/** One kind of point of these items (`cue`), before that part is written again. */
|
|
200
156
|
async deleteParts(items, part) {
|
|
201
157
|
if (!items.length)
|
|
202
158
|
return;
|
|
203
|
-
await this.call('POST',
|
|
204
|
-
filter:
|
|
159
|
+
await this.call('POST', `${this.at}/points/delete?wait=true`, {
|
|
160
|
+
filter: { must: [{ key: 'item', match: { any: items } }, { key: 'part', match: { value: part } }] },
|
|
205
161
|
}, 60_000);
|
|
206
162
|
}
|
|
207
163
|
/** Change some payload fields of these items' points (status, a merged key's display fields). */
|
|
@@ -211,65 +167,47 @@ export class QdrantClient {
|
|
|
211
167
|
const must = [{ key: 'item', match: { any: items } }];
|
|
212
168
|
if (keyOnly)
|
|
213
169
|
must.push({ key: 'part', match: { value: 'key' } });
|
|
214
|
-
await this.call('POST',
|
|
170
|
+
await this.call('POST', `${this.at}/points/payload?wait=true`, { payload, filter: { must } }, 60_000);
|
|
215
171
|
}
|
|
216
172
|
/** The nearest key points to one vector (consolidation's "do I know this already?"), with their cosine. */
|
|
217
173
|
async neighbors(vector, filter, limit = 5) {
|
|
218
174
|
const must = [...(filter.must ?? []), { key: 'part', match: { value: 'key' } }];
|
|
219
|
-
const r = await this.call('POST',
|
|
220
|
-
query: vector, using: 'dense', limit, params: RESCORE, filter:
|
|
175
|
+
const r = await this.call('POST', `${this.at}/points/query`, {
|
|
176
|
+
query: vector, using: 'dense', limit, params: RESCORE, filter: { ...filter, must }, with_payload: ['item', 'title', 'summary', 'kind', 'scope', 'project', 'status'],
|
|
221
177
|
});
|
|
222
178
|
return (r?.points ?? []).map((p) => ({ item: String(p.payload?.item ?? ''), score: p.score, payload: p.payload ?? {} }));
|
|
223
179
|
}
|
|
224
|
-
/** Each of these items' best cosine to one vector among its points of one part (key, child, cue) — the ranker's
|
|
225
|
-
* per-family similarities. An item with no such point is not in the map. */
|
|
226
|
-
async bestPerItem(vector, items, part, timeoutMs = 2000) {
|
|
227
|
-
if (!items.length)
|
|
228
|
-
return new Map();
|
|
229
|
-
const r = await this.call('POST', `/collections/${this.collection}/points/query/groups`, {
|
|
230
|
-
query: vector, using: 'dense', params: RESCORE,
|
|
231
|
-
filter: this.scoped({ must: [{ key: 'item', match: { any: items } }, { key: 'part', match: { value: part } }] }, this.user()),
|
|
232
|
-
group_by: 'item', group_size: 1, limit: items.length, with_payload: false,
|
|
233
|
-
}, timeoutMs);
|
|
234
|
-
return new Map((r?.groups ?? []).map((g) => [String(g.id), g.hits[0]?.score ?? 0]));
|
|
235
|
-
}
|
|
236
180
|
/** `nearest` for several vectors in one call. */
|
|
237
|
-
/** `fields`: only these payload fields (and the item
|
|
181
|
+
/** `fields`: only these payload fields (and the item), for a caller that needs no more — a walk over
|
|
238
182
|
* many neighbours reads ids, not the bodies of long requests. */
|
|
239
183
|
async nearestMany(vectors, filter, limit = 10, timeoutMs = 5000, fields) {
|
|
240
184
|
if (!vectors.length)
|
|
241
185
|
return [];
|
|
242
|
-
const
|
|
243
|
-
const
|
|
244
|
-
|
|
245
|
-
searches: vectors.map((v) => ({ query: v, using: 'dense', limit, params: RESCORE, filter: this.scoped(filter, user), with_payload: withPayload })),
|
|
186
|
+
const withPayload = fields ? [...new Set([...fields, 'item'])] : true;
|
|
187
|
+
const r = await this.call('POST', `${this.at}/points/query/batch`, {
|
|
188
|
+
searches: vectors.map((v) => ({ query: v, using: 'dense', limit, params: RESCORE, filter, with_payload: withPayload })),
|
|
246
189
|
}, timeoutMs);
|
|
247
|
-
return (r ?? []).map((x) =>
|
|
248
|
-
.map((p) => ({ item: String(p.payload.item ?? ''), score: p.score, payload: p.payload })));
|
|
190
|
+
return (r ?? []).map((x) => (x?.points ?? []).map((p) => ({ item: String(p.payload?.item ?? ''), score: p.score, payload: p.payload ?? {} })));
|
|
249
191
|
}
|
|
250
192
|
/** Points within a filter, with their payload (and dense vector when asked) — at most `limit`. */
|
|
251
193
|
async scroll(filter, limit = 256, withVector = false, timeoutMs = 5000) {
|
|
252
|
-
const
|
|
253
|
-
|
|
254
|
-
filter: this.scoped(filter, user), limit, with_payload: true, with_vector: withVector ? ['dense'] : false,
|
|
194
|
+
const r = await this.call('POST', `${this.at}/points/scroll`, {
|
|
195
|
+
filter, limit, with_payload: true, with_vector: withVector ? ['dense'] : false,
|
|
255
196
|
}, timeoutMs);
|
|
256
|
-
return
|
|
197
|
+
return (r?.points ?? []).map((p) => ({ id: String(p.id), payload: p.payload ?? {}, ...(p.vector?.dense ? { vector: p.vector.dense } : {}) }));
|
|
257
198
|
}
|
|
258
199
|
async count(filter) {
|
|
259
|
-
const
|
|
260
|
-
const r = await this.call('POST', `/collections/${this.collection}/points/count`, { exact: false, ...(scoped ? { filter: scoped } : {}) });
|
|
200
|
+
const r = await this.call('POST', `${this.at}/points/count`, { exact: false, ...(filter ? { filter } : {}) });
|
|
261
201
|
return r?.count ?? 0;
|
|
262
202
|
}
|
|
263
203
|
/** The items behind `ids` (key points), with their payload — and their dense vector when asked. */
|
|
264
204
|
async retrieve(ids, withVector = false) {
|
|
265
205
|
if (!ids.length)
|
|
266
206
|
return [];
|
|
267
|
-
const
|
|
268
|
-
|
|
269
|
-
ids: ids.map((id) => this.scopedId(id, user)), with_payload: true, with_vector: withVector ? ['dense'] : false,
|
|
207
|
+
const r = await this.call('POST', `${this.at}/points`, {
|
|
208
|
+
ids, with_payload: true, with_vector: withVector ? ['dense'] : false,
|
|
270
209
|
});
|
|
271
|
-
|
|
272
|
-
return this.own((r ?? []).map((p) => ({ id: String(p.id), payload: p.payload ?? {}, ...(p.vector?.dense ? { vector: p.vector.dense } : {}) })), user);
|
|
210
|
+
return (r ?? []).map((p) => ({ id: String(p.id), payload: p.payload ?? {}, ...(p.vector?.dense ? { vector: p.vector.dense } : {}) }));
|
|
273
211
|
}
|
|
274
212
|
/** The best items for the query, fused by rank — each with the point that matched best (`point`: its key, a chunk of
|
|
275
213
|
* its text, or a cue), so a caller can show the passage that matched. Each channel (a query vector, the keywords)
|
|
@@ -277,8 +215,7 @@ export class QdrantClient {
|
|
|
277
215
|
async fusedItems(q, timeoutMs = 3000) {
|
|
278
216
|
const limit = q.limit ?? 40;
|
|
279
217
|
const per = q.per ?? Math.max(limit, 50);
|
|
280
|
-
const
|
|
281
|
-
const filter = this.scoped(q.filter, user);
|
|
218
|
+
const filter = q.filter;
|
|
282
219
|
const channels = [];
|
|
283
220
|
const sparse = q.sparse?.indices.length ? q.sparse : null;
|
|
284
221
|
const denseWeight = (sparse ? DENSE_SHARE : 1) / Math.max(1, q.dense.length);
|
|
@@ -292,24 +229,23 @@ export class QdrantClient {
|
|
|
292
229
|
channels.push({ query: sparse, using: 'bm25', weight: 1 - DENSE_SHARE });
|
|
293
230
|
if (!channels.length)
|
|
294
231
|
return [];
|
|
295
|
-
const lists = await Promise.all(channels.map((c) => this.call('POST',
|
|
232
|
+
const lists = await Promise.all(channels.map((c) => this.call('POST', `${this.at}/points/query/groups`, {
|
|
296
233
|
query: c.query, using: c.using, ...(c.params ? { params: c.params } : {}),
|
|
297
234
|
...(c.filter ?? filter ? { filter: c.filter ?? filter } : {}),
|
|
298
235
|
group_by: 'item',
|
|
299
236
|
group_size: 1,
|
|
300
237
|
limit: per,
|
|
301
|
-
|
|
302
|
-
with_payload: user === null ? false : [TENANT_FIELD, TENANT_PID],
|
|
238
|
+
with_payload: false,
|
|
303
239
|
}, timeoutMs)));
|
|
304
240
|
const fused = new Map();
|
|
305
241
|
lists.forEach((r, c) => {
|
|
306
|
-
const groups =
|
|
242
|
+
const groups = r?.groups ?? [];
|
|
307
243
|
const scores = groups.map((g) => g.hits[0]?.score ?? 0);
|
|
308
244
|
const lo = Math.min(...scores);
|
|
309
245
|
const hi = Math.max(...scores);
|
|
310
246
|
groups.forEach((g, rank) => {
|
|
311
247
|
const item = String(g.id);
|
|
312
|
-
const raw =
|
|
248
|
+
const raw = g.hits[0]?.id;
|
|
313
249
|
const at = fused.get(item) ?? { score: 0, rank: Infinity };
|
|
314
250
|
// A channel whose items all score alike (or that found one) counts each of them fully.
|
|
315
251
|
at.score += channels[c].weight * (hi > lo ? (scores[rank] - lo) / (hi - lo) : 1);
|