memtrails 0.0.0-stage → 0.2.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 (76) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/LICENSE +21 -0
  3. package/README.md +221 -2
  4. package/dist/core/consolidate.d.ts +144 -0
  5. package/dist/core/consolidate.js +660 -0
  6. package/dist/core/embedder.d.ts +30 -0
  7. package/dist/core/embedder.js +137 -0
  8. package/dist/core/episodes.d.ts +32 -0
  9. package/dist/core/episodes.js +53 -0
  10. package/dist/core/extract.d.ts +142 -0
  11. package/dist/core/extract.js +452 -0
  12. package/dist/core/gather.d.ts +17 -0
  13. package/dist/core/gather.js +98 -0
  14. package/dist/core/graph.d.ts +48 -0
  15. package/dist/core/graph.js +208 -0
  16. package/dist/core/local-embedder.d.ts +40 -0
  17. package/dist/core/local-embedder.js +98 -0
  18. package/dist/core/model.d.ts +16 -0
  19. package/dist/core/model.js +74 -0
  20. package/dist/core/paths.d.ts +1 -0
  21. package/dist/core/paths.js +7 -0
  22. package/dist/core/profiles.d.ts +30 -0
  23. package/dist/core/profiles.js +147 -0
  24. package/dist/core/qdrant-binary.d.ts +25 -0
  25. package/dist/core/qdrant-binary.js +108 -0
  26. package/dist/core/qdrant-client.d.ts +115 -0
  27. package/dist/core/qdrant-client.js +327 -0
  28. package/dist/core/qdrant-process.d.ts +30 -0
  29. package/dist/core/qdrant-process.js +160 -0
  30. package/dist/core/rebuild.d.ts +31 -0
  31. package/dist/core/rebuild.js +109 -0
  32. package/dist/core/recall-log.d.ts +29 -0
  33. package/dist/core/recall-log.js +82 -0
  34. package/dist/core/recall.d.ts +91 -0
  35. package/dist/core/recall.js +172 -0
  36. package/dist/core/redact.d.ts +2 -0
  37. package/dist/core/redact.js +48 -0
  38. package/dist/core/reranker.d.ts +26 -0
  39. package/dist/core/reranker.js +67 -0
  40. package/dist/core/room.d.ts +20 -0
  41. package/dist/core/room.js +63 -0
  42. package/dist/core/sleep.d.ts +44 -0
  43. package/dist/core/sleep.js +199 -0
  44. package/dist/core/store.d.ts +198 -0
  45. package/dist/core/store.js +190 -0
  46. package/dist/core/text.d.ts +31 -0
  47. package/dist/core/text.js +101 -0
  48. package/dist/core/words.d.ts +32 -0
  49. package/dist/core/words.js +127 -0
  50. package/dist/core/writer.d.ts +36 -0
  51. package/dist/core/writer.js +146 -0
  52. package/dist/importers/files.d.ts +31 -0
  53. package/dist/importers/files.js +157 -0
  54. package/dist/importers/git.d.ts +30 -0
  55. package/dist/importers/git.js +118 -0
  56. package/dist/importers/index.d.ts +14 -0
  57. package/dist/importers/index.js +26 -0
  58. package/dist/importers/jsonl.d.ts +20 -0
  59. package/dist/importers/jsonl.js +65 -0
  60. package/dist/importers/mbox.d.ts +20 -0
  61. package/dist/importers/mbox.js +347 -0
  62. package/dist/importers/slack.d.ts +21 -0
  63. package/dist/importers/slack.js +134 -0
  64. package/dist/importers/text.d.ts +31 -0
  65. package/dist/importers/text.js +191 -0
  66. package/dist/index.d.ts +28 -0
  67. package/dist/index.js +22 -0
  68. package/dist/llm.d.ts +60 -0
  69. package/dist/llm.js +60 -0
  70. package/dist/memory.d.ts +106 -0
  71. package/dist/memory.js +262 -0
  72. package/dist/records.d.ts +62 -0
  73. package/dist/records.js +107 -0
  74. package/dist/tools/agent-tools.d.ts +52 -0
  75. package/dist/tools/agent-tools.js +142 -0
  76. package/package.json +78 -4
@@ -0,0 +1,108 @@
1
+ /**
2
+ * The Qdrant binary memory runs when you give it no Qdrant server of your own: one pinned release, fetched on first use
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.
5
+ *
6
+ * Qdrant publishes no checksums, so the hashes are pinned here from its release. Upgrading means a new version and new
7
+ * hashes; Qdrant reads storage only from the minor version before it, so after a version change the index is written
8
+ * again from the logs (rebuild.ts) rather than migrated: it is derived data.
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';
13
+ import { Readable } from 'node:stream';
14
+ import { pipeline } from 'node:stream/promises';
15
+ import { spawn } from 'node:child_process';
16
+ import { cacheDir } from './paths.js';
17
+ export const QDRANT_VERSION = '1.20.0';
18
+ /** Per `${platform}-${arch}`. Linux takes the static musl build: it runs on any distribution. */
19
+ export const QDRANT_ASSETS = {
20
+ 'darwin-arm64': { asset: 'qdrant-aarch64-apple-darwin.tar.gz', size: 29_070_255, sha256: '531904e822050790bb8c1da6705f801ee48b91eb4c229d0d605ff06f6331b20a', exe: 'qdrant' },
21
+ 'darwin-x64': { asset: 'qdrant-x86_64-apple-darwin.tar.gz', size: 31_917_921, sha256: '94a2e6f6b8ef3a3b5756a11172fd66554ac753e0a71c1d8d8b5349356c084f4d', exe: 'qdrant' },
22
+ 'linux-arm64': { asset: 'qdrant-aarch64-unknown-linux-musl.tar.gz', size: 31_475_234, sha256: 'bd8a458b8ef8006ce2c603c5bd6d1d973e39d787f794008dd959112e747c3bb7', exe: 'qdrant' },
23
+ 'linux-x64': { asset: 'qdrant-x86_64-unknown-linux-musl.tar.gz', size: 33_773_335, sha256: 'b03a64616a1f41ddab78b480a318466541765f19835318113b1821cbea8df52c', exe: 'qdrant' },
24
+ 'win32-x64': { asset: 'qdrant-x86_64-pc-windows-msvc.zip', size: 30_801_664, sha256: '6d13553584fd0411f00637519e293331ff0aa1fb79bd9e30db45839bb75de2be', exe: 'qdrant.exe' },
25
+ };
26
+ export function qdrantAsset(platform = process.platform, arch = process.arch) {
27
+ return QDRANT_ASSETS[`${platform}-${arch}`] ?? null;
28
+ }
29
+ export function qdrantDir() {
30
+ return join(cacheDir(), `qdrant-${QDRANT_VERSION}`);
31
+ }
32
+ export function qdrantBinaryPath(a = qdrantAsset()) {
33
+ return a ? join(qdrantDir(), a.exe) : null;
34
+ }
35
+ export const QDRANT_RELEASE_BASE = `https://github.com/qdrant/qdrant/releases/download/v${QDRANT_VERSION}`;
36
+ const assetUrl = (a) => `${QDRANT_RELEASE_BASE}/${a.asset}`;
37
+ /** The binary is in place and its archive was verified when it was unpacked. */
38
+ export function qdrantReady(a = qdrantAsset()) {
39
+ const bin = qdrantBinaryPath(a);
40
+ if (!a || !bin || !existsSync(bin))
41
+ return false;
42
+ try {
43
+ return readFileSync(join(qdrantDir(), '.verified'), 'utf8').trim() === `${a.size}:${a.sha256}`;
44
+ }
45
+ catch {
46
+ return false;
47
+ }
48
+ }
49
+ /** Unpack one archive with the system's tar (bsdtar on macOS and Windows reads zip too). */
50
+ function untar(archive, into) {
51
+ return new Promise((resolve, reject) => {
52
+ const child = spawn('tar', ['-xf', archive, '-C', into], { stdio: 'ignore' });
53
+ child.once('error', reject);
54
+ child.once('exit', (code) => (code === 0 ? resolve() : reject(new Error(`tar exited ${code}`))));
55
+ });
56
+ }
57
+ /**
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.
60
+ */
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();
69
+ 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;
79
+ try {
80
+ await pipeline(Readable.fromWeb(res.body), async function* (src) {
81
+ for await (const chunk of src) {
82
+ const b = chunk;
83
+ hash.update(b);
84
+ size += b.length;
85
+ yield b;
86
+ }
87
+ }, createWriteStream(part));
88
+ }
89
+ catch (err) {
90
+ rmSync(part, { force: true });
91
+ throw err;
92
+ }
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}`);
97
+ }
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;
108
+ }
@@ -0,0 +1,115 @@
1
+ import type { QdrantEndpoint } from './qdrant-process.js';
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
+ export declare const DIMS: 768;
6
+ /** What the index is made of; a change empties it and it is written in again from the logs (rebuild.ts).
7
+ * 2: binary quantisation with rescoring (½ the RAM of int8, recall 0.98–1.00 measured at 1–3M points), keyword terms
8
+ * folded and segmented for every language (text.ts); 3: on-disk binary copy, HNSW and payload indexes;
9
+ * 4: remote EmbeddingGemma GPU vectors (768 dimensions, explicit runtime/pooling tag in embedder.ts); 5: nothing cut —
10
+ * 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;
13
+ export interface SparseVector {
14
+ indices: number[];
15
+ values: number[];
16
+ }
17
+ export interface Filter {
18
+ must?: unknown[];
19
+ must_not?: unknown[];
20
+ should?: unknown[];
21
+ }
22
+ export interface PointStruct {
23
+ id: string;
24
+ vector: Record<string, number[] | SparseVector>;
25
+ payload: Record<string, unknown>;
26
+ }
27
+ /** A point's id from its item and its place (0 = the key, n = the n-th child): the same item writes the same ids. */
28
+ export declare function pointId(item: string, n: number): string;
29
+ export declare class QdrantError extends Error {
30
+ readonly status: number;
31
+ constructor(message: string, status: number);
32
+ }
33
+ export interface QdrantClientOptions {
34
+ /** The collection (COLLECTION when not given). */
35
+ 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
+ }
40
+ export declare class QdrantClient {
41
+ private readonly endpoint;
42
+ private readonly timeoutMs;
43
+ private readonly opts;
44
+ private readonly collection;
45
+ /** `endpoint` may start Qdrant (the service runs it only while memory has work): every call waits for it. */
46
+ 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
+ private call;
56
+ /** Drop the whole collection: the index is derived, written again from the logs (rebuild.ts). */
57
+ dropCollection(): Promise<void>;
58
+ /** The collection and its payload indexes, made if missing — an index added since a collection was made is added to
59
+ * it in place. All of it on disk, memory-mapped — the vectors, their 1-bit copy, the graph, the payloads and their
60
+ * indexes: RAM holds what queries touch, and the system may take it back. Measured at 30k / 100k items (0.33M /
61
+ * 1.1M points): 205 → 68 MB and 591 → 144 MB that cannot be given back,
62
+ * for 12–16 ms more of Qdrant's CPU a turn (26 → 38, 81 → 97). */
63
+ ensureCollection(): Promise<void>;
64
+ upsert(points: PointStruct[], wait?: boolean): Promise<void>;
65
+ /** Every point of these items — before an item is written again (its children may be fewer now). */
66
+ deleteItems(items: string[]): Promise<void>;
67
+ /** One kind of point of these items (`cue`), before that part is written again. */
68
+ deleteParts(items: string[], part: string): Promise<void>;
69
+ /** Change some payload fields of these items' points (status, a merged key's display fields). */
70
+ setPayload(items: string[], payload: Record<string, unknown>, keyOnly?: boolean): Promise<void>;
71
+ /** The nearest key points to one vector (consolidation's "do I know this already?"), with their cosine. */
72
+ neighbors(vector: number[], filter: Filter, limit?: number): Promise<Array<{
73
+ item: string;
74
+ score: number;
75
+ payload: Record<string, unknown>;
76
+ }>>;
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
+ /** `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
82
+ * many neighbours reads ids, not the bodies of long requests. */
83
+ nearestMany(vectors: number[][], filter: Filter, limit?: number, timeoutMs?: number, fields?: string[]): Promise<Array<Array<{
84
+ item: string;
85
+ score: number;
86
+ payload: Record<string, unknown>;
87
+ }>>>;
88
+ /** Points within a filter, with their payload (and dense vector when asked) — at most `limit`. */
89
+ scroll(filter: Filter, limit?: number, withVector?: boolean, timeoutMs?: number): Promise<Array<{
90
+ id: string;
91
+ payload: Record<string, unknown>;
92
+ vector?: number[];
93
+ }>>;
94
+ count(filter?: Filter): Promise<number>;
95
+ /** The items behind `ids` (key points), with their payload — and their dense vector when asked. */
96
+ retrieve(ids: string[], withVector?: boolean): Promise<Array<{
97
+ id: string;
98
+ payload: Record<string, unknown>;
99
+ vector?: number[];
100
+ }>>;
101
+ /** The best items for the query, fused by rank — each with the point that matched best (`point`: its key, a chunk of
102
+ * its text, or a cue), so a caller can show the passage that matched. Each channel (a query vector, the keywords)
103
+ * ranks up to `per` items (default: `limit`, at least 50); their scores are then fused (CombSUM, above). */
104
+ fusedItems(q: {
105
+ dense: number[][];
106
+ sparse?: SparseVector;
107
+ filter?: Filter;
108
+ per?: number;
109
+ limit?: number;
110
+ }, timeoutMs?: number): Promise<Array<{
111
+ item: string;
112
+ score: number;
113
+ point?: string;
114
+ }>>;
115
+ }
@@ -0,0 +1,327 @@
1
+ /**
2
+ * The few Qdrant REST calls the memory index makes — plain fetch, no client library. One collection, `memory`, two
3
+ * tiers (payload `tier`):
4
+ *
5
+ * - `stm` — short-term memory: one point per captured request (its raw text as the key), there until consolidation;
6
+ * - `ltm` — long-term memory: per item a KEY point (`part: key` — its content title + ~150-word summary as the dense
7
+ * vector, a BM25 sparse vector over its title, summary, body and files, the item itself as payload) and CHILD
8
+ * points (`part: child`, ~96-word windows of its body, dense only) pointing at it.
9
+ *
10
+ * A query ranks items once per query vector and once by keywords (each hit grouped by item, so an item found through
11
+ * several children counts once), then fuses those item rankings by rank (RRF). Only `active` items are ever returned
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
+ */
20
+ import { createHash } from 'node:crypto';
21
+ import { MEMORY_EMBEDDER } from './embedder.js';
22
+ 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
+ export const DIMS = MEMORY_EMBEDDER.dims;
27
+ /** What the index is made of; a change empties it and it is written in again from the logs (rebuild.ts).
28
+ * 2: binary quantisation with rescoring (½ the RAM of int8, recall 0.98–1.00 measured at 1–3M points), keyword terms
29
+ * folded and segmented for every language (text.ts); 3: on-disk binary copy, HNSW and payload indexes;
30
+ * 4: remote EmbeddingGemma GPU vectors (768 dimensions, explicit runtime/pooling tag in embedder.ts); 5: nothing cut —
31
+ * 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;
34
+ /** The memory-mapped 1-bit copy finds candidates; full vectors on disk re-score 3× as many as asked. */
35
+ const RESCORE = { quantization: { rescore: true, oversampling: 3.0 } };
36
+ /** Two segments: Qdrant's default is one a core, which costs a laptop RAM and per-query work for nothing at memory's
37
+ * size. The write-ahead log keeps its 32 MB: a 4 MB one returned wrong
38
+ * results in qdrant.integration.test.ts on 1.20, for page cache the system can take back anyway. */
39
+ const SMALL = { optimizers_config: { default_segment_number: 2 } };
40
+ /** Item-level fusion: each channel (a query vector, the keywords) is its own grouped query, its items' scores are
41
+ * min-max normalised over its own list, and an item's score is the weighted sum (CombSUM) — the dense channels share
42
+ * DENSE_SHARE of the weight, keywords the rest. Each channel is its own query, never a prefetch under a fusion: Qdrant
43
+ * 1.20 orders a prefetch by the 1-bit scores whatever its `rescore` (42% of the exact top 10 on LoCoMo, any@1 halved).
44
+ * Fusing items rather than points keeps one item's many chunk hits from crowding the list; fusing scores rather than
45
+ * ranks keeps the gap of a hit that is much closer than the rest. Measured against gold evidence (docs/RESEARCH.md): dense 0.7
46
+ * was the best share on both benchmarks, and CombSUM beat rank fusion at the top of both — LoCoMo requests any@3
47
+ * 71.6 → 74.5, nDCG@10 64.6 → 65.7; LongMemEval-S any@1 63.4 → 64.5, sessions any@1 92.6 → 93.2. */
48
+ const DENSE_SHARE = 0.7;
49
+ /** Cues — the questions an item answers, in the asker's words — search as their own channel at this share of the dense
50
+ * weight rather than among the item's points. Measured on LoCoMo's 1,307 held-out questions, together with cues on
51
+ * every observation and the seven-days-before calendar: multi-hop 50.6 → 55.2, overall 77.2 → 77.4 (docs/RESEARCH.md). */
52
+ const CUE_SHARE = 0.1;
53
+ /** A point's id from its item and its place (0 = the key, n = the n-th child): the same item writes the same ids. */
54
+ export function pointId(item, n) {
55
+ const h = createHash('sha1').update(`${item}#${n}`).digest('hex');
56
+ return `${h.slice(0, 8)}-${h.slice(8, 12)}-5${h.slice(13, 16)}-${((parseInt(h[16], 16) & 0x3) | 0x8).toString(16)}${h.slice(17, 20)}-${h.slice(20, 32)}`;
57
+ }
58
+ export class QdrantError extends Error {
59
+ status;
60
+ constructor(message, status) {
61
+ super(message);
62
+ this.status = status;
63
+ }
64
+ }
65
+ export class QdrantClient {
66
+ endpoint;
67
+ timeoutMs;
68
+ opts;
69
+ collection;
70
+ /** `endpoint` may start Qdrant (the service runs it only while memory has work): every call waits for it. */
71
+ constructor(endpoint, timeoutMs = 10_000, opts = {}) {
72
+ this.endpoint = endpoint;
73
+ 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
+ });
104
+ }
105
+ async call(method, path, body, timeoutMs = this.timeoutMs) {
106
+ const e = await this.endpoint();
107
+ if (!e)
108
+ throw new QdrantError('memory index is not running', 503);
109
+ let res = null;
110
+ const payload = body !== undefined ? JSON.stringify(body) : undefined;
111
+ // Every call here can be sent again (upserts by id, deletes by filter, reads): a connection the server closed while
112
+ // it sat idle (keep-alive) fails before the request reaches it, so it is tried again — twice at most.
113
+ for (let attempt = 0; !res; attempt++) {
114
+ try {
115
+ res = await fetch(`${e.url}${path}`, {
116
+ method,
117
+ headers: { 'api-key': e.apiKey, ...(payload !== undefined ? { 'content-type': 'application/json' } : {}) },
118
+ ...(payload !== undefined ? { body: payload } : {}),
119
+ signal: AbortSignal.timeout(timeoutMs),
120
+ });
121
+ }
122
+ catch (err) {
123
+ // fetch says only "fetch failed": the reason is in its cause (refused, reset, closed, timed out).
124
+ const cause = err.cause;
125
+ const timedOut = err.name === 'TimeoutError';
126
+ if (attempt < 2 && !timedOut) {
127
+ await new Promise((r) => setTimeout(r, 150 * (attempt + 1)));
128
+ continue;
129
+ }
130
+ throw new QdrantError(`${method} ${path}: ${err.message}${cause ? ` (${cause.code ?? cause.message})` : ''}`, 0);
131
+ }
132
+ }
133
+ const text = await res.text();
134
+ if (!res.ok)
135
+ throw new QdrantError(`${method} ${path}: ${res.status} ${text.slice(0, 300)}`, res.status);
136
+ const json = text ? JSON.parse(text) : {};
137
+ return json.result;
138
+ }
139
+ /** Drop the whole collection: the index is derived, written again from the logs (rebuild.ts). */
140
+ async dropCollection() {
141
+ await this.call('DELETE', `/collections/${this.collection}`).catch(() => undefined);
142
+ }
143
+ /** The collection and its payload indexes, made if missing — an index added since a collection was made is added to
144
+ * it in place. All of it on disk, memory-mapped — the vectors, their 1-bit copy, the graph, the payloads and their
145
+ * indexes: RAM holds what queries touch, and the system may take it back. Measured at 30k / 100k items (0.33M /
146
+ * 1.1M points): 205 → 68 MB and 591 → 144 MB that cannot be given back,
147
+ * for 12–16 ms more of Qdrant's CPU a turn (26 → 38, 81 → 97). */
148
+ async ensureCollection() {
149
+ let info = null;
150
+ try {
151
+ info = (await this.call('GET', `/collections/${this.collection}`)) ?? {};
152
+ }
153
+ catch (err) {
154
+ if (!(err instanceof QdrantError) || err.status !== 404)
155
+ throw err;
156
+ }
157
+ if (!info) {
158
+ await this.call('PUT', `/collections/${this.collection}`, {
159
+ vectors: { dense: { size: DIMS, distance: 'Cosine', on_disk: true } },
160
+ sparse_vectors: { bm25: { modifier: 'idf', index: { on_disk: true } } },
161
+ quantization_config: { binary: { always_ram: false } },
162
+ hnsw_config: { m: 16, ef_construct: 100, on_disk: true },
163
+ on_disk_payload: true,
164
+ ...SMALL,
165
+ });
166
+ info = { payload_schema: {}, config: { optimizer_config: { default_segment_number: SMALL.optimizers_config.default_segment_number } } };
167
+ }
168
+ else if (info.config?.optimizer_config?.default_segment_number !== SMALL.optimizers_config.default_segment_number) {
169
+ // 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 });
171
+ }
172
+ 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
+ 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'],
179
+ ['synthetic', 'bool'], ['concepts', 'keyword'], ['eventFrom', 'integer'], ['eventTo', 'integer']]) {
180
+ const schema = field_schema === 'keyword' || field_schema === 'integer' ? { type: field_schema, on_disk: true } : field_schema;
181
+ if (!(field_name in have))
182
+ await this.call('PUT', `/collections/${this.collection}/index?wait=true`, { field_name, field_schema: schema });
183
+ }
184
+ }
185
+ async upsert(points, wait = false) {
186
+ if (!points.length)
187
+ 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);
192
+ }
193
+ /** Every point of these items — before an item is written again (its children may be fewer now). */
194
+ async deleteItems(items) {
195
+ if (!items.length)
196
+ 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);
198
+ }
199
+ /** One kind of point of these items (`cue`), before that part is written again. */
200
+ async deleteParts(items, part) {
201
+ if (!items.length)
202
+ 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()),
205
+ }, 60_000);
206
+ }
207
+ /** Change some payload fields of these items' points (status, a merged key's display fields). */
208
+ async setPayload(items, payload, keyOnly = false) {
209
+ if (!items.length)
210
+ return;
211
+ const must = [{ key: 'item', match: { any: items } }];
212
+ if (keyOnly)
213
+ 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);
215
+ }
216
+ /** The nearest key points to one vector (consolidation's "do I know this already?"), with their cosine. */
217
+ async neighbors(vector, filter, limit = 5) {
218
+ 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'],
221
+ });
222
+ return (r?.points ?? []).map((p) => ({ item: String(p.payload?.item ?? ''), score: p.score, payload: p.payload ?? {} }));
223
+ }
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
+ /** `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
238
+ * many neighbours reads ids, not the bodies of long requests. */
239
+ async nearestMany(vectors, filter, limit = 10, timeoutMs = 5000, fields) {
240
+ if (!vectors.length)
241
+ 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 })),
246
+ }, 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 })));
249
+ }
250
+ /** Points within a filter, with their payload (and dense vector when asked) — at most `limit`. */
251
+ 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,
255
+ }, timeoutMs);
256
+ return this.own((r?.points ?? []).map((p) => ({ id: String(p.id), payload: p.payload ?? {}, ...(p.vector?.dense ? { vector: p.vector.dense } : {}) })), user);
257
+ }
258
+ 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 } : {}) });
261
+ return r?.count ?? 0;
262
+ }
263
+ /** The items behind `ids` (key points), with their payload — and their dense vector when asked. */
264
+ async retrieve(ids, withVector = false) {
265
+ if (!ids.length)
266
+ 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,
270
+ });
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);
273
+ }
274
+ /** The best items for the query, fused by rank — each with the point that matched best (`point`: its key, a chunk of
275
+ * its text, or a cue), so a caller can show the passage that matched. Each channel (a query vector, the keywords)
276
+ * ranks up to `per` items (default: `limit`, at least 50); their scores are then fused (CombSUM, above). */
277
+ async fusedItems(q, timeoutMs = 3000) {
278
+ const limit = q.limit ?? 40;
279
+ const per = q.per ?? Math.max(limit, 50);
280
+ const user = this.user();
281
+ const filter = this.scoped(q.filter, user);
282
+ const channels = [];
283
+ const sparse = q.sparse?.indices.length ? q.sparse : null;
284
+ const denseWeight = (sparse ? DENSE_SHARE : 1) / Math.max(1, q.dense.length);
285
+ const cueOff = { ...(filter ?? {}), must_not: [...(filter?.must_not ?? []), { key: 'part', match: { value: 'cue' } }] };
286
+ const cueOnly = { ...(filter ?? {}), must: [...(filter?.must ?? []), { key: 'part', match: { value: 'cue' } }] };
287
+ for (const v of q.dense) {
288
+ channels.push({ query: v, using: 'dense', params: RESCORE, weight: denseWeight * (1 - CUE_SHARE), filter: cueOff });
289
+ channels.push({ query: v, using: 'dense', params: RESCORE, weight: denseWeight * CUE_SHARE, filter: cueOnly });
290
+ }
291
+ if (sparse)
292
+ channels.push({ query: sparse, using: 'bm25', weight: 1 - DENSE_SHARE });
293
+ if (!channels.length)
294
+ return [];
295
+ const lists = await Promise.all(channels.map((c) => this.call('POST', `/collections/${this.collection}/points/query/groups`, {
296
+ query: c.query, using: c.using, ...(c.params ? { params: c.params } : {}),
297
+ ...(c.filter ?? filter ? { filter: c.filter ?? filter } : {}),
298
+ group_by: 'item',
299
+ group_size: 1,
300
+ 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],
303
+ }, timeoutMs)));
304
+ const fused = new Map();
305
+ lists.forEach((r, c) => {
306
+ const groups = (r?.groups ?? []).filter((g) => user === null || g.hits[0]?.payload?.[TENANT_FIELD] === user);
307
+ const scores = groups.map((g) => g.hits[0]?.score ?? 0);
308
+ const lo = Math.min(...scores);
309
+ const hi = Math.max(...scores);
310
+ groups.forEach((g, rank) => {
311
+ const item = String(g.id);
312
+ const raw = user === null ? g.hits[0]?.id : g.hits[0]?.payload?.[TENANT_PID];
313
+ const at = fused.get(item) ?? { score: 0, rank: Infinity };
314
+ // A channel whose items all score alike (or that found one) counts each of them fully.
315
+ at.score += channels[c].weight * (hi > lo ? (scores[rank] - lo) / (hi - lo) : 1);
316
+ // The matched passage: the point from the channel that ranked the item highest.
317
+ if (rank < at.rank && raw) {
318
+ at.rank = rank;
319
+ at.point = String(raw);
320
+ }
321
+ fused.set(item, at);
322
+ });
323
+ });
324
+ return [...fused.entries()].sort((a, b) => b[1].score - a[1].score).slice(0, limit)
325
+ .map(([item, f]) => ({ item, score: f.score, ...(f.point ? { point: f.point } : {}) }));
326
+ }
327
+ }
@@ -0,0 +1,30 @@
1
+ export interface QdrantEndpoint {
2
+ url: string;
3
+ apiKey: string;
4
+ }
5
+ /** The config file: what environment variables cannot say (gRPC off is `grpc_port: null`). */
6
+ export declare function qdrantConfig(storage: string, port: number): string;
7
+ export declare class QdrantProcess {
8
+ private child;
9
+ private endpoint;
10
+ private stopping;
11
+ private deaths;
12
+ private restartTimer;
13
+ private readonly opts;
14
+ /** `dir` may be relative: Qdrant runs inside it, so every path it is given is made absolute first. */
15
+ constructor(opts: {
16
+ binary: string;
17
+ dir: string;
18
+ log: (line: string) => void;
19
+ onDown?: (why: string) => void;
20
+ });
21
+ /** Where it answers, once it does; null while it starts or after it stopped. */
22
+ current(): QdrantEndpoint | null;
23
+ start(): Promise<QdrantEndpoint>;
24
+ /** Answering `/readyz` within 30 s, or the start failed. */
25
+ private ready;
26
+ private exited;
27
+ stop(): void;
28
+ /** Stop and wait for the process to be gone — before its storage is deleted. */
29
+ stopAndWait(ms?: number): Promise<void>;
30
+ }