memtrails 0.3.0 → 0.4.0

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 (48) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +14 -9
  3. package/dist/core/consolidate.d.ts +23 -8
  4. package/dist/core/consolidate.js +52 -25
  5. package/dist/core/episodes.d.ts +1 -1
  6. package/dist/core/episodes.js +2 -8
  7. package/dist/core/extract.js +5 -5
  8. package/dist/core/gather.d.ts +3 -0
  9. package/dist/core/gather.js +12 -8
  10. package/dist/core/model.d.ts +8 -4
  11. package/dist/core/model.js +29 -40
  12. package/dist/core/profiles.d.ts +1 -1
  13. package/dist/core/profiles.js +7 -6
  14. package/dist/core/qdrant-binary.d.ts +39 -5
  15. package/dist/core/qdrant-binary.js +185 -48
  16. package/dist/core/qdrant-client.d.ts +6 -18
  17. package/dist/core/qdrant-client.js +38 -90
  18. package/dist/core/qdrant-process.d.ts +22 -1
  19. package/dist/core/qdrant-process.js +190 -22
  20. package/dist/core/rebuild.d.ts +5 -1
  21. package/dist/core/rebuild.js +19 -4
  22. package/dist/core/recall-log.d.ts +3 -0
  23. package/dist/core/recall-log.js +6 -1
  24. package/dist/core/recall.js +14 -5
  25. package/dist/core/redact.d.ts +8 -0
  26. package/dist/core/redact.js +48 -9
  27. package/dist/core/sleep.d.ts +4 -3
  28. package/dist/core/sleep.js +19 -11
  29. package/dist/core/store.d.ts +7 -1
  30. package/dist/core/store.js +101 -33
  31. package/dist/core/text.d.ts +4 -2
  32. package/dist/core/text.js +17 -5
  33. package/dist/core/words.js +6 -2
  34. package/dist/core/writer.d.ts +2 -1
  35. package/dist/core/writer.js +9 -3
  36. package/dist/importers/files.js +53 -10
  37. package/dist/importers/git.js +3 -1
  38. package/dist/importers/mbox.js +22 -7
  39. package/dist/importers/slack.js +5 -2
  40. package/dist/importers/text.js +85 -14
  41. package/dist/llm.d.ts +1 -8
  42. package/dist/llm.js +11 -5
  43. package/dist/memory.d.ts +6 -2
  44. package/dist/memory.js +215 -47
  45. package/dist/records.js +6 -3
  46. package/dist/tools/agent-tools.d.ts +2 -5
  47. package/dist/tools/agent-tools.js +37 -21
  48. package/package.json +1 -1
@@ -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,13 +167,13 @@ 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
  }
@@ -226,50 +182,44 @@ export class QdrantClient {
226
182
  async bestPerItem(vector, items, part, timeoutMs = 2000) {
227
183
  if (!items.length)
228
184
  return new Map();
229
- const r = await this.call('POST', `/collections/${this.collection}/points/query/groups`, {
185
+ const r = await this.call('POST', `${this.at}/points/query/groups`, {
230
186
  query: vector, using: 'dense', params: RESCORE,
231
- filter: this.scoped({ must: [{ key: 'item', match: { any: items } }, { key: 'part', match: { value: part } }] }, this.user()),
187
+ filter: { must: [{ key: 'item', match: { any: items } }, { key: 'part', match: { value: part } }] },
232
188
  group_by: 'item', group_size: 1, limit: items.length, with_payload: false,
233
189
  }, timeoutMs);
234
190
  return new Map((r?.groups ?? []).map((g) => [String(g.id), g.hits[0]?.score ?? 0]));
235
191
  }
236
192
  /** `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
193
+ /** `fields`: only these payload fields (and the item), for a caller that needs no more — a walk over
238
194
  * many neighbours reads ids, not the bodies of long requests. */
239
195
  async nearestMany(vectors, filter, limit = 10, timeoutMs = 5000, fields) {
240
196
  if (!vectors.length)
241
197
  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 })),
198
+ const withPayload = fields ? [...new Set([...fields, 'item'])] : true;
199
+ const r = await this.call('POST', `${this.at}/points/query/batch`, {
200
+ searches: vectors.map((v) => ({ query: v, using: 'dense', limit, params: RESCORE, filter, with_payload: withPayload })),
246
201
  }, 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 })));
202
+ return (r ?? []).map((x) => (x?.points ?? []).map((p) => ({ item: String(p.payload?.item ?? ''), score: p.score, payload: p.payload ?? {} })));
249
203
  }
250
204
  /** Points within a filter, with their payload (and dense vector when asked) — at most `limit`. */
251
205
  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,
206
+ const r = await this.call('POST', `${this.at}/points/scroll`, {
207
+ filter, limit, with_payload: true, with_vector: withVector ? ['dense'] : false,
255
208
  }, timeoutMs);
256
- return this.own((r?.points ?? []).map((p) => ({ id: String(p.id), payload: p.payload ?? {}, ...(p.vector?.dense ? { vector: p.vector.dense } : {}) })), user);
209
+ return (r?.points ?? []).map((p) => ({ id: String(p.id), payload: p.payload ?? {}, ...(p.vector?.dense ? { vector: p.vector.dense } : {}) }));
257
210
  }
258
211
  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 } : {}) });
212
+ const r = await this.call('POST', `${this.at}/points/count`, { exact: false, ...(filter ? { filter } : {}) });
261
213
  return r?.count ?? 0;
262
214
  }
263
215
  /** The items behind `ids` (key points), with their payload — and their dense vector when asked. */
264
216
  async retrieve(ids, withVector = false) {
265
217
  if (!ids.length)
266
218
  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,
219
+ const r = await this.call('POST', `${this.at}/points`, {
220
+ ids, with_payload: true, with_vector: withVector ? ['dense'] : false,
270
221
  });
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);
222
+ return (r ?? []).map((p) => ({ id: String(p.id), payload: p.payload ?? {}, ...(p.vector?.dense ? { vector: p.vector.dense } : {}) }));
273
223
  }
274
224
  /** The best items for the query, fused by rank — each with the point that matched best (`point`: its key, a chunk of
275
225
  * its text, or a cue), so a caller can show the passage that matched. Each channel (a query vector, the keywords)
@@ -277,8 +227,7 @@ export class QdrantClient {
277
227
  async fusedItems(q, timeoutMs = 3000) {
278
228
  const limit = q.limit ?? 40;
279
229
  const per = q.per ?? Math.max(limit, 50);
280
- const user = this.user();
281
- const filter = this.scoped(q.filter, user);
230
+ const filter = q.filter;
282
231
  const channels = [];
283
232
  const sparse = q.sparse?.indices.length ? q.sparse : null;
284
233
  const denseWeight = (sparse ? DENSE_SHARE : 1) / Math.max(1, q.dense.length);
@@ -292,24 +241,23 @@ export class QdrantClient {
292
241
  channels.push({ query: sparse, using: 'bm25', weight: 1 - DENSE_SHARE });
293
242
  if (!channels.length)
294
243
  return [];
295
- const lists = await Promise.all(channels.map((c) => this.call('POST', `/collections/${this.collection}/points/query/groups`, {
244
+ const lists = await Promise.all(channels.map((c) => this.call('POST', `${this.at}/points/query/groups`, {
296
245
  query: c.query, using: c.using, ...(c.params ? { params: c.params } : {}),
297
246
  ...(c.filter ?? filter ? { filter: c.filter ?? filter } : {}),
298
247
  group_by: 'item',
299
248
  group_size: 1,
300
249
  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],
250
+ with_payload: false,
303
251
  }, timeoutMs)));
304
252
  const fused = new Map();
305
253
  lists.forEach((r, c) => {
306
- const groups = (r?.groups ?? []).filter((g) => user === null || g.hits[0]?.payload?.[TENANT_FIELD] === user);
254
+ const groups = r?.groups ?? [];
307
255
  const scores = groups.map((g) => g.hits[0]?.score ?? 0);
308
256
  const lo = Math.min(...scores);
309
257
  const hi = Math.max(...scores);
310
258
  groups.forEach((g, rank) => {
311
259
  const item = String(g.id);
312
- const raw = user === null ? g.hits[0]?.id : g.hits[0]?.payload?.[TENANT_PID];
260
+ const raw = g.hits[0]?.id;
313
261
  const at = fused.get(item) ?? { score: 0, rank: Infinity };
314
262
  // A channel whose items all score alike (or that found one) counts each of them fully.
315
263
  at.score += channels[c].weight * (hi > lo ? (scores[rank] - lo) / (hi - lo) : 1);
@@ -4,6 +4,12 @@ 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;
@@ -20,10 +26,25 @@ export declare class QdrantProcess {
20
26
  });
21
27
  /** Where it answers, once it does; null while it starts or after it stopped. */
22
28
  current(): QdrantEndpoint | null;
29
+ /** Up and proven ours (owned), or it throws — and then nothing of this start keeps running or is started again: the
30
+ * caller was told, and may not be there to stop it. */
23
31
  start(): Promise<QdrantEndpoint>;
24
- /** Answering `/readyz` within 30 s, or the start failed. */
32
+ /** The pid file, and the child tracked for the host's exit; the file goes when that child does. */
33
+ private recordPid;
34
+ /** A Qdrant left running on this storage by a host that died without `close()` (a crash, a kill): stopped before
35
+ * another starts on it. Only when the pid is still the process written down — this boot, its command naming this
36
+ * folder's config — never an unrelated process that has the pid since. */
37
+ private reapLeftover;
38
+ /** Answering `/readyz` within 30 s, or the start failed. /readyz asks no key, so none is sent: what answers may not be
39
+ * ours (owned). */
25
40
  private ready;
41
+ /** The port was free when picked, not when Qdrant bound it: another local process may have taken it, answered
42
+ * /readyz, and would be handed the key and every document. Ours is still running, refuses a wrong key and takes
43
+ * this run's — the wrong key asked first, so a process that takes any key never sees the real one. */
44
+ private owned;
26
45
  private exited;
46
+ /** Started again after a growing pause; a restart that fails counts as another death. */
47
+ private restartLater;
27
48
  stop(): void;
28
49
  /** Stop and wait for the process to be gone — before its storage is deleted. */
29
50
  stopAndWait(ms?: number): Promise<void>;
@@ -1,8 +1,9 @@
1
+ import { execFileSync, spawn } from 'node:child_process';
1
2
  import { randomBytes } from 'node:crypto';
2
- import { closeSync, mkdirSync, openSync, statSync, renameSync, writeFileSync } from 'node:fs';
3
+ import { chmodSync, closeSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
3
4
  import { createServer } from 'node:net';
5
+ import { uptime } from 'node:os';
4
6
  import { join, resolve } from 'node:path';
5
- import { spawn } from 'node:child_process';
6
7
  /** A port the OS says is free on loopback. */
7
8
  async function freePort() {
8
9
  return new Promise((resolve, reject) => {
@@ -39,7 +40,9 @@ export function qdrantConfig(storage, port) {
39
40
  ].join('\n');
40
41
  }
41
42
  const LOG_MAX = 4 * 1024 * 1024;
42
- /** The process's log beside its data, rotated once at start when it has grown past 4 MB. */
43
+ /** Beside the storage: the pid of the Qdrant running on it, with its config and the machine's boot (reapLeftover). */
44
+ const PID_FILE = 'qdrant.pid';
45
+ /** The process's log beside its data, rotated once at start when it has grown past 4 MB. Private: it names paths. */
43
46
  function openLog(dir) {
44
47
  const path = join(dir, 'qdrant.log');
45
48
  try {
@@ -47,8 +50,75 @@ function openLog(dir) {
47
50
  renameSync(path, `${path}.1`);
48
51
  }
49
52
  catch { /* none yet */ }
50
- mkdirSync(dir, { recursive: true });
51
- return openSync(path, 'a');
53
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
54
+ const fd = openSync(path, 'a', 0o600);
55
+ try {
56
+ chmodSync(path, 0o600);
57
+ }
58
+ catch { /* not ours to change: written all the same */ }
59
+ return fd;
60
+ }
61
+ /** When this machine started, in ms: a pid written down before a reboot names some other process now. */
62
+ export function bootTime() {
63
+ return Date.now() - uptime() * 1000;
64
+ }
65
+ /** The same boot as `boot` (bootTime() of then): a minute apart at most, uptime being in whole seconds on some systems. */
66
+ export function sameBoot(boot) {
67
+ return typeof boot === 'number' && Math.abs(bootTime() - boot) < 60_000;
68
+ }
69
+ /** A process with this pid exists — one of another user's too (EPERM). */
70
+ export function alive(pid) {
71
+ if (!Number.isInteger(pid) || pid <= 0)
72
+ return false;
73
+ try {
74
+ process.kill(pid, 0);
75
+ return true;
76
+ }
77
+ catch (err) {
78
+ return err.code === 'EPERM';
79
+ }
80
+ }
81
+ const pause = (ms) => new Promise((r) => setTimeout(r, ms));
82
+ /** A running process's command line, or null when it cannot be read (no /proc and no ps, Windows). */
83
+ function commandOf(pid) {
84
+ try {
85
+ return readFileSync(`/proc/${pid}/cmdline`, 'utf8').split('\0').join(' ');
86
+ }
87
+ catch { /* not Linux */ }
88
+ if (process.platform === 'win32')
89
+ return null;
90
+ try {
91
+ return execFileSync('ps', ['-ww', '-o', 'command=', '-p', String(pid)], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 5000 });
92
+ }
93
+ catch {
94
+ return null;
95
+ }
96
+ }
97
+ /** What Qdrant gets of the environment: what a process needs to run and the key. None of the host's secrets, and none
98
+ * of its QDRANT__* variables — they override the config (QDRANT__SERVICE__HOST=0.0.0.0 would open it to the network). */
99
+ function childEnv(apiKey) {
100
+ const env = {};
101
+ for (const k of ['PATH', 'HOME', 'TMPDIR', 'TEMP', 'TMP', 'SystemRoot', 'USERPROFILE'])
102
+ if (process.env[k] !== undefined)
103
+ env[k] = process.env[k];
104
+ return { ...env, QDRANT__SERVICE__API_KEY: apiKey, QDRANT__TELEMETRY_DISABLED: 'true' };
105
+ }
106
+ /** Every Qdrant this process started and has not seen exit: stopped when the host exits without `close()` (an 'exit'
107
+ * handler can only send the signal; a host killed outright leaves its Qdrant to the next start's reapLeftover). */
108
+ const running = new Set();
109
+ let exitHook = false;
110
+ function track(child) {
111
+ running.add(child);
112
+ child.once('exit', () => running.delete(child));
113
+ if (exitHook)
114
+ return;
115
+ exitHook = true;
116
+ process.on('exit', () => { for (const c of running) {
117
+ try {
118
+ c.kill('SIGTERM');
119
+ }
120
+ catch { /* gone */ }
121
+ } });
52
122
  }
53
123
  export class QdrantProcess {
54
124
  child = null;
@@ -65,13 +135,16 @@ export class QdrantProcess {
65
135
  current() {
66
136
  return this.endpoint;
67
137
  }
138
+ /** Up and proven ours (owned), or it throws — and then nothing of this start keeps running or is started again: the
139
+ * caller was told, and may not be there to stop it. */
68
140
  async start() {
69
141
  this.stopping = false;
70
142
  if (this.endpoint && this.child && this.child.exitCode === null)
71
143
  return this.endpoint;
144
+ mkdirSync(this.opts.dir, { recursive: true, mode: 0o700 });
145
+ await this.reapLeftover();
72
146
  const port = await freePort();
73
147
  const apiKey = randomBytes(24).toString('hex');
74
- mkdirSync(this.opts.dir, { recursive: true });
75
148
  const config = join(this.opts.dir, 'config.yaml');
76
149
  writeFileSync(config, qdrantConfig(this.opts.dir, port), { mode: 0o600 });
77
150
  const fd = openLog(this.opts.dir);
@@ -80,46 +153,135 @@ export class QdrantProcess {
80
153
  child = spawn(this.opts.binary, ['--config-path', config, '--disable-telemetry'], {
81
154
  cwd: this.opts.dir,
82
155
  stdio: ['ignore', fd, fd],
83
- // Only the key: the rest is in the config file.
84
- env: { ...process.env, QDRANT__SERVICE__API_KEY: apiKey, QDRANT__TELEMETRY_DISABLED: 'true' },
156
+ env: childEnv(apiKey),
85
157
  });
86
158
  }
87
159
  finally {
88
160
  closeSync(fd);
89
161
  }
90
162
  this.child = child;
163
+ // A binary that is missing or cannot run is an 'error', never an 'exit' — unheard, it is thrown in the host.
164
+ let failed = null;
165
+ child.on('error', (err) => { failed ??= err; });
91
166
  child.once('exit', (code, signal) => this.exited(child, code, signal));
167
+ if (child.pid)
168
+ this.recordPid(child, config);
92
169
  const endpoint = { url: `http://127.0.0.1:${port}`, apiKey };
93
- await this.ready(endpoint, child);
170
+ try {
171
+ await this.ready(endpoint, child, () => failed);
172
+ await this.owned(endpoint, child);
173
+ if (this.child !== child)
174
+ throw new Error('Qdrant was stopped while it started');
175
+ }
176
+ catch (err) {
177
+ if (this.child === child)
178
+ this.child = null; // exited() then schedules nothing
179
+ if (child.pid && child.exitCode === null && child.signalCode === null)
180
+ child.kill('SIGTERM');
181
+ throw err;
182
+ }
94
183
  this.endpoint = endpoint;
95
184
  this.opts.log(`[memory] Qdrant up on 127.0.0.1:${port}`);
96
185
  return endpoint;
97
186
  }
98
- /** Answering `/readyz` within 30 s, or the start failed. */
99
- async ready(e, child) {
187
+ /** The pid file, and the child tracked for the host's exit; the file goes when that child does. */
188
+ recordPid(child, config) {
189
+ const file = join(this.opts.dir, PID_FILE);
190
+ writeFileSync(file, JSON.stringify({ pid: child.pid, config, boot: bootTime() }), { mode: 0o600 });
191
+ track(child);
192
+ child.once('exit', () => {
193
+ try {
194
+ if (JSON.parse(readFileSync(file, 'utf8')).pid === child.pid)
195
+ rmSync(file, { force: true });
196
+ }
197
+ catch { /* none */ }
198
+ });
199
+ }
200
+ /** A Qdrant left running on this storage by a host that died without `close()` (a crash, a kill): stopped before
201
+ * another starts on it. Only when the pid is still the process written down — this boot, its command naming this
202
+ * folder's config — never an unrelated process that has the pid since. */
203
+ async reapLeftover() {
204
+ const file = join(this.opts.dir, PID_FILE);
205
+ let rec;
206
+ try {
207
+ rec = JSON.parse(readFileSync(file, 'utf8'));
208
+ }
209
+ catch {
210
+ return;
211
+ }
212
+ const pid = Number(rec.pid);
213
+ const config = join(this.opts.dir, 'config.yaml');
214
+ if (rec.config === config && sameBoot(rec.boot) && pid !== this.child?.pid && alive(pid) && commandOf(pid)?.includes(config)) {
215
+ this.opts.log(`[memory] stopping the Qdrant left running on ${this.opts.dir} (pid ${pid})`);
216
+ for (const [signal, ms] of [['SIGTERM', 10_000], ['SIGKILL', 2000]]) {
217
+ try {
218
+ process.kill(pid, signal);
219
+ }
220
+ catch { /* gone */ }
221
+ for (const until = Date.now() + ms; alive(pid) && Date.now() < until;)
222
+ await pause(50);
223
+ if (!alive(pid))
224
+ break;
225
+ }
226
+ }
227
+ rmSync(file, { force: true });
228
+ }
229
+ /** Answering `/readyz` within 30 s, or the start failed. /readyz asks no key, so none is sent: what answers may not be
230
+ * ours (owned). */
231
+ async ready(e, child, failed) {
100
232
  const until = Date.now() + 30_000;
101
233
  while (Date.now() < until) {
102
- if (child.exitCode !== null)
103
- throw new Error(`Qdrant exited (${child.exitCode}) while starting — see ${join(this.opts.dir, 'qdrant.log')}`);
234
+ const err = failed();
235
+ if (err)
236
+ throw new Error(`Qdrant could not be started (${this.opts.binary}): ${err.message}`);
237
+ if (child.exitCode !== null || child.signalCode !== null)
238
+ throw new Error(`Qdrant exited (${child.signalCode ?? child.exitCode}) while starting — see ${join(this.opts.dir, 'qdrant.log')}`);
104
239
  try {
105
- const r = await fetch(`${e.url}/readyz`, { headers: { 'api-key': e.apiKey }, signal: AbortSignal.timeout(1000) });
240
+ const r = await fetch(`${e.url}/readyz`, { signal: AbortSignal.timeout(1000) });
241
+ await r.arrayBuffer().catch(() => undefined);
106
242
  if (r.ok)
107
243
  return;
108
244
  }
109
245
  catch { /* not listening yet */ }
110
- await new Promise((r) => setTimeout(r, 100));
246
+ await pause(100);
111
247
  }
112
- child.kill('SIGTERM');
113
248
  throw new Error('Qdrant did not answer within 30 s');
114
249
  }
250
+ /** The port was free when picked, not when Qdrant bound it: another local process may have taken it, answered
251
+ * /readyz, and would be handed the key and every document. Ours is still running, refuses a wrong key and takes
252
+ * this run's — the wrong key asked first, so a process that takes any key never sees the real one. */
253
+ async owned(e, child) {
254
+ const status = async (key) => {
255
+ const r = await fetch(`${e.url}/collections`, { headers: { 'api-key': key }, signal: AbortSignal.timeout(5000) });
256
+ await r.arrayBuffer().catch(() => undefined);
257
+ return r.status;
258
+ };
259
+ const gone = () => child.exitCode !== null || child.signalCode !== null;
260
+ const notOurs = `what answers on ${e.url} is not this memory's Qdrant`;
261
+ if (gone())
262
+ throw new Error(`${notOurs}: ours exited — see ${join(this.opts.dir, 'qdrant.log')}`);
263
+ const wrong = await status(randomBytes(24).toString('hex'));
264
+ if (wrong !== 401 && wrong !== 403)
265
+ throw new Error(`${notOurs}: it took a wrong key (${wrong})`);
266
+ const right = await status(e.apiKey);
267
+ if (right !== 200)
268
+ throw new Error(`${notOurs}: it refused this run's key (${right})`);
269
+ if (gone())
270
+ throw new Error(`${notOurs}: ours exited — see ${join(this.opts.dir, 'qdrant.log')}`);
271
+ }
115
272
  exited(child, code, signal) {
116
273
  if (this.child !== child)
117
274
  return;
275
+ // One that never came up is its start()'s to report: that start rejects, and schedules nothing.
276
+ const wasUp = this.endpoint !== null;
118
277
  this.child = null;
119
278
  this.endpoint = null;
120
- if (this.stopping)
279
+ if (this.stopping || !wasUp)
121
280
  return;
122
- const why = `Qdrant stopped (${signal ?? code})`;
281
+ this.restartLater(`Qdrant stopped (${signal ?? code})`);
282
+ }
283
+ /** Started again after a growing pause; a restart that fails counts as another death. */
284
+ restartLater(why) {
123
285
  this.opts.log(`[memory] ${why}`);
124
286
  const now = Date.now();
125
287
  this.deaths = [...this.deaths.filter((t) => now - t < 10 * 60_000), now];
@@ -127,11 +289,17 @@ export class QdrantProcess {
127
289
  this.opts.onDown?.(`${why}; five times in ten minutes, so it is left stopped`);
128
290
  return;
129
291
  }
130
- const pause = Math.min(60_000, 1000 * 2 ** (this.deaths.length - 1));
292
+ const wait = Math.min(60_000, 1000 * 2 ** (this.deaths.length - 1));
131
293
  this.restartTimer = setTimeout(() => {
132
294
  this.restartTimer = null;
133
- this.start().catch((err) => this.opts.onDown?.(err.message));
134
- }, pause);
295
+ if (this.stopping)
296
+ return;
297
+ this.start().catch((err) => {
298
+ this.opts.onDown?.(err.message);
299
+ if (!this.stopping)
300
+ this.restartLater(err.message);
301
+ });
302
+ }, wait);
135
303
  this.restartTimer.unref?.();
136
304
  }
137
305
  stop() {
@@ -150,7 +318,7 @@ export class QdrantProcess {
150
318
  async stopAndWait(ms = 10_000) {
151
319
  const c = this.child;
152
320
  this.stop();
153
- if (!c || c.exitCode !== null)
321
+ if (!c || c.exitCode !== null || c.signalCode !== null)
154
322
  return;
155
323
  await new Promise((resolve) => {
156
324
  const t = setTimeout(resolve, ms);
@@ -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