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.
- package/CHANGELOG.md +32 -0
- package/README.md +14 -9
- package/dist/core/consolidate.d.ts +23 -8
- package/dist/core/consolidate.js +52 -25
- package/dist/core/episodes.d.ts +1 -1
- package/dist/core/episodes.js +2 -8
- package/dist/core/extract.js +5 -5
- package/dist/core/gather.d.ts +3 -0
- package/dist/core/gather.js +12 -8
- package/dist/core/model.d.ts +8 -4
- package/dist/core/model.js +29 -40
- package/dist/core/profiles.d.ts +1 -1
- package/dist/core/profiles.js +7 -6
- package/dist/core/qdrant-binary.d.ts +39 -5
- package/dist/core/qdrant-binary.js +185 -48
- package/dist/core/qdrant-client.d.ts +6 -18
- package/dist/core/qdrant-client.js +38 -90
- package/dist/core/qdrant-process.d.ts +22 -1
- package/dist/core/qdrant-process.js +190 -22
- package/dist/core/rebuild.d.ts +5 -1
- package/dist/core/rebuild.js +19 -4
- package/dist/core/recall-log.d.ts +3 -0
- package/dist/core/recall-log.js +6 -1
- package/dist/core/recall.js +14 -5
- 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 +19 -11
- package/dist/core/store.d.ts +7 -1
- package/dist/core/store.js +101 -33
- package/dist/core/text.d.ts +4 -2
- package/dist/core/text.js +17 -5
- 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 +53 -10
- package/dist/importers/git.js +3 -1
- package/dist/importers/mbox.js +22 -7
- package/dist/importers/slack.js +5 -2
- package/dist/importers/text.js +85 -14
- package/dist/llm.d.ts +1 -8
- package/dist/llm.js +11 -5
- package/dist/memory.d.ts +6 -2
- package/dist/memory.js +215 -47
- 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
|
@@ -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,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',
|
|
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
|
}
|
|
@@ -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',
|
|
185
|
+
const r = await this.call('POST', `${this.at}/points/query/groups`, {
|
|
230
186
|
query: vector, using: 'dense', params: RESCORE,
|
|
231
|
-
filter:
|
|
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
|
|
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
|
|
243
|
-
const
|
|
244
|
-
|
|
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) =>
|
|
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
|
|
253
|
-
|
|
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
|
|
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
|
|
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
|
|
268
|
-
|
|
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
|
-
|
|
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
|
|
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',
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
/**
|
|
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,
|
|
3
|
+
import { chmodSync, closeSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
3
4
|
import { createServer } from 'node:net';
|
|
5
|
+
import { uptime } from 'node:os';
|
|
4
6
|
import { join, resolve } from 'node:path';
|
|
5
|
-
import { spawn } from 'node:child_process';
|
|
6
7
|
/** A port the OS says is free on loopback. */
|
|
7
8
|
async function freePort() {
|
|
8
9
|
return new Promise((resolve, reject) => {
|
|
@@ -39,7 +40,9 @@ export function qdrantConfig(storage, port) {
|
|
|
39
40
|
].join('\n');
|
|
40
41
|
}
|
|
41
42
|
const LOG_MAX = 4 * 1024 * 1024;
|
|
42
|
-
/**
|
|
43
|
+
/** Beside the storage: the pid of the Qdrant running on it, with its config and the machine's boot (reapLeftover). */
|
|
44
|
+
const PID_FILE = 'qdrant.pid';
|
|
45
|
+
/** The process's log beside its data, rotated once at start when it has grown past 4 MB. Private: it names paths. */
|
|
43
46
|
function openLog(dir) {
|
|
44
47
|
const path = join(dir, 'qdrant.log');
|
|
45
48
|
try {
|
|
@@ -47,8 +50,75 @@ function openLog(dir) {
|
|
|
47
50
|
renameSync(path, `${path}.1`);
|
|
48
51
|
}
|
|
49
52
|
catch { /* none yet */ }
|
|
50
|
-
mkdirSync(dir, { recursive: true });
|
|
51
|
-
|
|
53
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
54
|
+
const fd = openSync(path, 'a', 0o600);
|
|
55
|
+
try {
|
|
56
|
+
chmodSync(path, 0o600);
|
|
57
|
+
}
|
|
58
|
+
catch { /* not ours to change: written all the same */ }
|
|
59
|
+
return fd;
|
|
60
|
+
}
|
|
61
|
+
/** When this machine started, in ms: a pid written down before a reboot names some other process now. */
|
|
62
|
+
export function bootTime() {
|
|
63
|
+
return Date.now() - uptime() * 1000;
|
|
64
|
+
}
|
|
65
|
+
/** The same boot as `boot` (bootTime() of then): a minute apart at most, uptime being in whole seconds on some systems. */
|
|
66
|
+
export function sameBoot(boot) {
|
|
67
|
+
return typeof boot === 'number' && Math.abs(bootTime() - boot) < 60_000;
|
|
68
|
+
}
|
|
69
|
+
/** A process with this pid exists — one of another user's too (EPERM). */
|
|
70
|
+
export function alive(pid) {
|
|
71
|
+
if (!Number.isInteger(pid) || pid <= 0)
|
|
72
|
+
return false;
|
|
73
|
+
try {
|
|
74
|
+
process.kill(pid, 0);
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
catch (err) {
|
|
78
|
+
return err.code === 'EPERM';
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
const pause = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
82
|
+
/** A running process's command line, or null when it cannot be read (no /proc and no ps, Windows). */
|
|
83
|
+
function commandOf(pid) {
|
|
84
|
+
try {
|
|
85
|
+
return readFileSync(`/proc/${pid}/cmdline`, 'utf8').split('\0').join(' ');
|
|
86
|
+
}
|
|
87
|
+
catch { /* not Linux */ }
|
|
88
|
+
if (process.platform === 'win32')
|
|
89
|
+
return null;
|
|
90
|
+
try {
|
|
91
|
+
return execFileSync('ps', ['-ww', '-o', 'command=', '-p', String(pid)], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 5000 });
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
/** What Qdrant gets of the environment: what a process needs to run and the key. None of the host's secrets, and none
|
|
98
|
+
* of its QDRANT__* variables — they override the config (QDRANT__SERVICE__HOST=0.0.0.0 would open it to the network). */
|
|
99
|
+
function childEnv(apiKey) {
|
|
100
|
+
const env = {};
|
|
101
|
+
for (const k of ['PATH', 'HOME', 'TMPDIR', 'TEMP', 'TMP', 'SystemRoot', 'USERPROFILE'])
|
|
102
|
+
if (process.env[k] !== undefined)
|
|
103
|
+
env[k] = process.env[k];
|
|
104
|
+
return { ...env, QDRANT__SERVICE__API_KEY: apiKey, QDRANT__TELEMETRY_DISABLED: 'true' };
|
|
105
|
+
}
|
|
106
|
+
/** Every Qdrant this process started and has not seen exit: stopped when the host exits without `close()` (an 'exit'
|
|
107
|
+
* handler can only send the signal; a host killed outright leaves its Qdrant to the next start's reapLeftover). */
|
|
108
|
+
const running = new Set();
|
|
109
|
+
let exitHook = false;
|
|
110
|
+
function track(child) {
|
|
111
|
+
running.add(child);
|
|
112
|
+
child.once('exit', () => running.delete(child));
|
|
113
|
+
if (exitHook)
|
|
114
|
+
return;
|
|
115
|
+
exitHook = true;
|
|
116
|
+
process.on('exit', () => { for (const c of running) {
|
|
117
|
+
try {
|
|
118
|
+
c.kill('SIGTERM');
|
|
119
|
+
}
|
|
120
|
+
catch { /* gone */ }
|
|
121
|
+
} });
|
|
52
122
|
}
|
|
53
123
|
export class QdrantProcess {
|
|
54
124
|
child = null;
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
99
|
-
|
|
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
|
-
|
|
103
|
-
|
|
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`, {
|
|
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
|
|
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
|
-
|
|
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
|
|
292
|
+
const wait = Math.min(60_000, 1000 * 2 ** (this.deaths.length - 1));
|
|
131
293
|
this.restartTimer = setTimeout(() => {
|
|
132
294
|
this.restartTimer = null;
|
|
133
|
-
|
|
134
|
-
|
|
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);
|
package/dist/core/rebuild.d.ts
CHANGED
|
@@ -14,13 +14,17 @@ interface RebuildDeps {
|
|
|
14
14
|
export declare class MemoryRebuild {
|
|
15
15
|
private readonly d;
|
|
16
16
|
private running;
|
|
17
|
+
/** The generation `running` was started for: a run() of the same one joins it, a later one waits for it. */
|
|
18
|
+
private runningFor;
|
|
17
19
|
private generation;
|
|
18
20
|
constructor(d: RebuildDeps);
|
|
19
21
|
cancel(): void;
|
|
20
22
|
settled(): Promise<void>;
|
|
21
23
|
pending(): boolean;
|
|
22
|
-
/** Written BEFORE clearing an old index. A crash at any later point leaves a resumable rebuild.
|
|
24
|
+
/** Written BEFORE clearing an old index. A crash at any later point leaves a resumable rebuild. A run in flight is
|
|
25
|
+
* cancelled: it wrote into the index about to be cleared, and must neither move nor delete the new checkpoint. */
|
|
23
26
|
begin(): void;
|
|
27
|
+
/** Joins the run in flight for the same begin(); after a newer begin(), waits for the cancelled one to stop first. */
|
|
24
28
|
run(): Promise<void>;
|
|
25
29
|
private save;
|
|
26
30
|
/** One phase from its checkpoint: batches of `size`, up to `concurrency` written at once, the checkpoint moved over
|