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
@@ -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 { join } from 'node:path';
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
- /** The binary is in place and its archive was verified when it was unpacked. */
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 || !existsSync(bin))
65
+ if (!a || !bin)
41
66
  return false;
67
+ const marker = join(qdrantDir(), '.verified');
42
68
  try {
43
- return readFileSync(join(qdrantDir(), '.verified'), 'utf8').trim() === `${a.size}:${a.sha256}`;
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('tar', ['-xf', archive, '-C', into], { stdio: 'ignore' });
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
- * Fetch, verify and unpack the binary unless it is already there. Throws on a network failure, a size or hash
59
- * mismatch (the download is deleted), or a platform Qdrant does not ship for; the caller retries with backoff.
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 ensureQdrant(opts = {}) {
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 archive = join(dir, a.asset);
71
- const part = `${archive}.part`;
72
- rmSync(part, { force: true });
73
- opts.log?.(`[memtrails] fetching Qdrant ${QDRANT_VERSION} (${Math.round(a.size / 1e6)} MB)`);
74
- const res = await (opts.fetchImpl ?? fetch)(assetUrl(a), { signal: opts.signal, redirect: 'follow' });
75
- if (!res.ok || !res.body)
76
- throw new Error(`HTTP ${res.status} for ${a.asset}`);
77
- const hash = createHash('sha256');
78
- let size = 0;
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
- const b = chunk;
83
- hash.update(b);
84
- size += b.length;
85
- yield b;
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(part));
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(part, { force: true });
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 digest = hash.digest('hex');
94
- if (size !== a.size || digest !== a.sha256) {
95
- rmSync(part, { force: true });
96
- throw new Error(`${a.asset}: got ${size} bytes sha256 ${digest}, expected ${a.size} ${a.sha256}`);
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
- renameSync(part, archive);
99
- await untar(archive, dir);
100
- rmSync(archive, { force: true });
101
- if (!existsSync(bin) || statSync(bin).size === 0)
102
- throw new Error(`${a.asset} did not contain ${a.exe}`);
103
- if (process.platform !== 'win32')
104
- chmodSync(bin, 0o755);
105
- writeFileSync(join(dir, '.verified'), `${a.size}:${a.sha256}\n`);
106
- opts.log?.(`[memtrails] Qdrant ${QDRANT_VERSION} ready`);
107
- return bin;
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
- export declare const INDEX_LAYOUT = 6;
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
- private readonly opts;
44
- private readonly collection;
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 and its user), for a caller that needs no more — a walk over
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
- export const INDEX_LAYOUT = 6;
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
- opts;
69
- collection;
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.opts = opts;
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', `/collections/${this.collection}`).catch(() => undefined);
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', `/collections/${this.collection}`)) ?? {};
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', `/collections/${this.collection}`, {
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', `/collections/${this.collection}`, { optimizers_config: SMALL.optimizers_config });
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', `/collections/${this.collection}/index?wait=true`, { field_name, field_schema: schema });
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
- const user = this.user();
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', `/collections/${this.collection}/points/delete?wait=true`, { filter: this.scoped({ must: [{ key: 'item', match: { any: items } }] }, this.user()) }, 60_000);
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', `/collections/${this.collection}/points/delete?wait=true`, {
204
- filter: this.scoped({ must: [{ key: 'item', match: { any: items } }, { key: 'part', match: { value: part } }] }, this.user()),
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', `/collections/${this.collection}/points/payload?wait=true`, { payload, filter: this.scoped({ must }, this.user()) }, 60_000);
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', `/collections/${this.collection}/points/query`, {
220
- query: vector, using: 'dense', limit, params: RESCORE, filter: this.scoped({ ...filter, must }, this.user()), with_payload: ['item', 'title', 'summary', 'kind', 'scope', 'project', 'status'],
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 and its user), for a caller that needs no more — a walk over
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 user = this.user();
243
- const withPayload = fields ? [...new Set([...fields, 'item', TENANT_FIELD])] : true;
244
- const r = await this.call('POST', `/collections/${this.collection}/points/query/batch`, {
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) => this.own((x?.points ?? []).map((p) => ({ id: '', payload: p.payload ?? {}, score: p.score })), user)
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 user = this.user();
253
- const r = await this.call('POST', `/collections/${this.collection}/points/scroll`, {
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 this.own((r?.points ?? []).map((p) => ({ id: String(p.id), payload: p.payload ?? {}, ...(p.vector?.dense ? { vector: p.vector.dense } : {}) })), user);
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 scoped = this.scoped(filter, this.user());
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 user = this.user();
268
- const r = await this.call('POST', `/collections/${this.collection}/points`, {
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
- // A point is fetched by its id alone; another user's (a hash of their id and the item) is dropped all the same.
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 user = this.user();
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', `/collections/${this.collection}/points/query/groups`, {
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
- // A shared collection's point ids are scoped: the writer's own id is in the payload.
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 = (r?.groups ?? []).filter((g) => user === null || g.hits[0]?.payload?.[TENANT_FIELD] === user);
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 = user === null ? g.hits[0]?.id : g.hits[0]?.payload?.[TENANT_PID];
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);