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.
- package/CHANGELOG.md +30 -0
- package/LICENSE +21 -0
- package/README.md +221 -2
- package/dist/core/consolidate.d.ts +144 -0
- package/dist/core/consolidate.js +660 -0
- package/dist/core/embedder.d.ts +30 -0
- package/dist/core/embedder.js +137 -0
- package/dist/core/episodes.d.ts +32 -0
- package/dist/core/episodes.js +53 -0
- package/dist/core/extract.d.ts +142 -0
- package/dist/core/extract.js +452 -0
- package/dist/core/gather.d.ts +17 -0
- package/dist/core/gather.js +98 -0
- package/dist/core/graph.d.ts +48 -0
- package/dist/core/graph.js +208 -0
- package/dist/core/local-embedder.d.ts +40 -0
- package/dist/core/local-embedder.js +98 -0
- package/dist/core/model.d.ts +16 -0
- package/dist/core/model.js +74 -0
- package/dist/core/paths.d.ts +1 -0
- package/dist/core/paths.js +7 -0
- package/dist/core/profiles.d.ts +30 -0
- package/dist/core/profiles.js +147 -0
- package/dist/core/qdrant-binary.d.ts +25 -0
- package/dist/core/qdrant-binary.js +108 -0
- package/dist/core/qdrant-client.d.ts +115 -0
- package/dist/core/qdrant-client.js +327 -0
- package/dist/core/qdrant-process.d.ts +30 -0
- package/dist/core/qdrant-process.js +160 -0
- package/dist/core/rebuild.d.ts +31 -0
- package/dist/core/rebuild.js +109 -0
- package/dist/core/recall-log.d.ts +29 -0
- package/dist/core/recall-log.js +82 -0
- package/dist/core/recall.d.ts +91 -0
- package/dist/core/recall.js +172 -0
- package/dist/core/redact.d.ts +2 -0
- package/dist/core/redact.js +48 -0
- package/dist/core/reranker.d.ts +26 -0
- package/dist/core/reranker.js +67 -0
- package/dist/core/room.d.ts +20 -0
- package/dist/core/room.js +63 -0
- package/dist/core/sleep.d.ts +44 -0
- package/dist/core/sleep.js +199 -0
- package/dist/core/store.d.ts +198 -0
- package/dist/core/store.js +190 -0
- package/dist/core/text.d.ts +31 -0
- package/dist/core/text.js +101 -0
- package/dist/core/words.d.ts +32 -0
- package/dist/core/words.js +127 -0
- package/dist/core/writer.d.ts +36 -0
- package/dist/core/writer.js +146 -0
- package/dist/importers/files.d.ts +31 -0
- package/dist/importers/files.js +157 -0
- package/dist/importers/git.d.ts +30 -0
- package/dist/importers/git.js +118 -0
- package/dist/importers/index.d.ts +14 -0
- package/dist/importers/index.js +26 -0
- package/dist/importers/jsonl.d.ts +20 -0
- package/dist/importers/jsonl.js +65 -0
- package/dist/importers/mbox.d.ts +20 -0
- package/dist/importers/mbox.js +347 -0
- package/dist/importers/slack.d.ts +21 -0
- package/dist/importers/slack.js +134 -0
- package/dist/importers/text.d.ts +31 -0
- package/dist/importers/text.js +191 -0
- package/dist/index.d.ts +28 -0
- package/dist/index.js +22 -0
- package/dist/llm.d.ts +60 -0
- package/dist/llm.js +60 -0
- package/dist/memory.d.ts +106 -0
- package/dist/memory.js +262 -0
- package/dist/records.d.ts +62 -0
- package/dist/records.js +107 -0
- package/dist/tools/agent-tools.d.ts +52 -0
- package/dist/tools/agent-tools.js +142 -0
- package/package.json +78 -4
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The concept graph — memory's associative index (HippoRAG's hippocampal index, 2405.14831), for an agent EXPLORING a
|
|
3
|
+
* topic or what it found, rather than for a search's top few (as a ranker feature it added ~1 point; for reaching
|
|
4
|
+
* what search does not, it is where the gain is).
|
|
5
|
+
*
|
|
6
|
+
* Nodes are long-term items and the concepts they are about (consolidation writes 3–12 per item: files, services,
|
|
7
|
+
* commands, named things), a concept being its spelling key (text.ts conceptKey). Edges:
|
|
8
|
+
* - item ↔ concept, weighted by how specific the concept is (1 / log(2 + the items that share it));
|
|
9
|
+
* - item ↔ item: `related` (the same subject, different facts — consolidation and sleep link them) and each seed's
|
|
10
|
+
* nearest items by key vector, two hops out.
|
|
11
|
+
* Concepts are NOT embedded: matched and linked by embedding instead of spelling, the walk found the same needed items
|
|
12
|
+
* (195 either way) and ~0.4 more items a topic, for a point and six embeddings an item more — not worth a laptop's RAM
|
|
13
|
+
* and CPU (measured 2026-10-25). It lives in Qdrant — the concept keys on items' key points, indexed — so it grows with
|
|
14
|
+
* memory without a copy in RAM: `explore` reads the neighbourhood of its seeds and walks it with Personalized PageRank
|
|
15
|
+
* (restart 0.5, the eval's), within recall's walls (active, real, the scope asked).
|
|
16
|
+
*/
|
|
17
|
+
import { pointId } from './qdrant-client.js';
|
|
18
|
+
import { queryText } from './embedder.js';
|
|
19
|
+
import { conceptKey, fold, querySparse } from './text.js';
|
|
20
|
+
/** A seed's nearest items by key vector (from cos 0.5: a small memory's "nearest" can be anything), the walk's search
|
|
21
|
+
* hits used as seeds, the items read through concepts. */
|
|
22
|
+
const KNN = 10;
|
|
23
|
+
const KNN_NEAR = 0.5;
|
|
24
|
+
const SEEDS = 5;
|
|
25
|
+
const FOUND = 20;
|
|
26
|
+
const POSTINGS = 400;
|
|
27
|
+
/** A `related` link's weight, beside item ↔ concept's 1/log(2 + df). */
|
|
28
|
+
const RELATED = 1.0;
|
|
29
|
+
const RESTART = 0.5;
|
|
30
|
+
const STEPS = 20;
|
|
31
|
+
const strings = (v) => (Array.isArray(v) ? v.filter((x) => typeof x === 'string') : []);
|
|
32
|
+
/** A small weighted undirected graph, walked by Personalized PageRank. */
|
|
33
|
+
class Walk {
|
|
34
|
+
ids = new Map();
|
|
35
|
+
adj = [];
|
|
36
|
+
node(name) {
|
|
37
|
+
let i = this.ids.get(name);
|
|
38
|
+
if (i === undefined) {
|
|
39
|
+
i = this.adj.length;
|
|
40
|
+
this.ids.set(name, i);
|
|
41
|
+
this.adj.push(new Map());
|
|
42
|
+
}
|
|
43
|
+
return i;
|
|
44
|
+
}
|
|
45
|
+
link(a, b, w) {
|
|
46
|
+
if (a === b || !(w > 0))
|
|
47
|
+
return;
|
|
48
|
+
const i = this.node(a);
|
|
49
|
+
const j = this.node(b);
|
|
50
|
+
this.adj[i].set(j, Math.max(this.adj[i].get(j) ?? 0, w));
|
|
51
|
+
this.adj[j].set(i, Math.max(this.adj[j].get(i) ?? 0, w));
|
|
52
|
+
}
|
|
53
|
+
/** Each node's share of a walk that starts again at `seeds` (by weight) half the time. */
|
|
54
|
+
ppr(seeds) {
|
|
55
|
+
const n = this.adj.length;
|
|
56
|
+
const e = new Float64Array(n);
|
|
57
|
+
let total = 0;
|
|
58
|
+
for (const [name, w] of seeds) {
|
|
59
|
+
const i = this.ids.get(name);
|
|
60
|
+
if (i !== undefined) {
|
|
61
|
+
e[i] += w;
|
|
62
|
+
total += w;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
if (!total)
|
|
66
|
+
return new Map();
|
|
67
|
+
for (let i = 0; i < n; i++)
|
|
68
|
+
e[i] /= total;
|
|
69
|
+
const deg = this.adj.map((m) => [...m.values()].reduce((s, w) => s + w, 0));
|
|
70
|
+
let x = Float64Array.from(e);
|
|
71
|
+
for (let step = 0; step < STEPS; step++) {
|
|
72
|
+
const next = new Float64Array(n);
|
|
73
|
+
for (let i = 0; i < n; i++) {
|
|
74
|
+
if (!x[i] || !deg[i])
|
|
75
|
+
continue;
|
|
76
|
+
for (const [j, w] of this.adj[i])
|
|
77
|
+
next[j] += (1 - RESTART) * x[i] * (w / deg[i]);
|
|
78
|
+
}
|
|
79
|
+
for (let i = 0; i < n; i++)
|
|
80
|
+
next[i] += RESTART * e[i];
|
|
81
|
+
x = next;
|
|
82
|
+
}
|
|
83
|
+
return new Map([...this.ids].map(([name, i]) => [name, x[i]]));
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
/** Walk the graph from a topic and/or items: what is related but not found directly, and the concepts around it. */
|
|
87
|
+
export async function explore(d, q) {
|
|
88
|
+
const keyOnly = (extra = []) => ({
|
|
89
|
+
...q.filter, must: [...(q.filter.must ?? []), { key: 'tier', match: { value: 'ltm' } }, { key: 'part', match: { value: 'key' } }, ...extra],
|
|
90
|
+
});
|
|
91
|
+
const shown = (p) => p.tier === 'ltm' && p.status === 'active' && p.synthetic === false && (!q.projects?.length || q.projects.includes(String(p.project)));
|
|
92
|
+
const seedItems = new Map();
|
|
93
|
+
const seedConcepts = new Map();
|
|
94
|
+
let found = [];
|
|
95
|
+
if (q.topic?.trim()) {
|
|
96
|
+
const [qv] = await d.embed([queryText(q.topic)]);
|
|
97
|
+
found = (await d.client.fusedItems({ dense: [Array.from(qv)], sparse: querySparse([q.topic]), filter: q.filter, limit: FOUND })).map((h) => h.item);
|
|
98
|
+
for (const u of found.slice(0, SEEDS))
|
|
99
|
+
seedItems.set(u, 1);
|
|
100
|
+
// The topic's own concepts: the topic named as one, and those of what search found that the topic names.
|
|
101
|
+
const topic = conceptKey(q.topic);
|
|
102
|
+
if (topic)
|
|
103
|
+
seedConcepts.set(topic, 1);
|
|
104
|
+
const said = fold(q.topic);
|
|
105
|
+
for (const p of await d.client.retrieve(found.slice(0, SEEDS).map((u) => pointId(u, 0)))) {
|
|
106
|
+
for (const k of strings(p.payload.concepts))
|
|
107
|
+
if (k.length > 2 && said.includes(k))
|
|
108
|
+
seedConcepts.set(k, 1);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
for (const u of q.from ?? [])
|
|
112
|
+
seedItems.set(u, 1);
|
|
113
|
+
if (!seedItems.size && !seedConcepts.size)
|
|
114
|
+
return { found, related: [], concepts: [] };
|
|
115
|
+
const g = new Walk();
|
|
116
|
+
const items = new Map();
|
|
117
|
+
// Hop 1: the seed items, their concepts and links, and their nearest items by key vector.
|
|
118
|
+
const seeds = (await d.client.retrieve([...seedItems.keys()].map((u) => pointId(u, 0)), true)).filter((p) => p.vector);
|
|
119
|
+
for (const p of seeds)
|
|
120
|
+
items.set(String(p.payload.item), p.payload);
|
|
121
|
+
const near = await d.client.nearestMany(seeds.map((p) => p.vector), keyOnly(), KNN + 1);
|
|
122
|
+
const hop1 = new Map();
|
|
123
|
+
seeds.forEach((p, i) => {
|
|
124
|
+
for (const n of near[i] ?? []) {
|
|
125
|
+
if (n.item === p.payload.item || n.score < KNN_NEAR)
|
|
126
|
+
continue;
|
|
127
|
+
if (!items.has(n.item))
|
|
128
|
+
items.set(n.item, n.payload);
|
|
129
|
+
if (!seedItems.has(n.item))
|
|
130
|
+
hop1.set(n.item, Math.max(hop1.get(n.item) ?? 0, n.score));
|
|
131
|
+
g.link(`i:${String(p.payload.item)}`, `i:${n.item}`, n.score);
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
// A second hop from the nearest of those (measured: the multi-hop item walk found more than one hop).
|
|
135
|
+
const next = (await d.client.retrieve([...hop1].sort((a, b) => b[1] - a[1]).slice(0, KNN).map(([u]) => pointId(u, 0)), true)).filter((p) => p.vector);
|
|
136
|
+
const near2 = await d.client.nearestMany(next.map((p) => p.vector), keyOnly(), KNN + 1);
|
|
137
|
+
next.forEach((p, i) => {
|
|
138
|
+
for (const n of near2[i] ?? []) {
|
|
139
|
+
if (n.item === p.payload.item || n.score < KNN_NEAR)
|
|
140
|
+
continue;
|
|
141
|
+
if (!items.has(n.item))
|
|
142
|
+
items.set(n.item, n.payload);
|
|
143
|
+
g.link(`i:${String(p.payload.item)}`, `i:${n.item}`, n.score);
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
// The concepts of what was reached and of the topic.
|
|
147
|
+
const concepts = new Set(seedConcepts.keys());
|
|
148
|
+
for (const p of items.values())
|
|
149
|
+
for (const k of strings(p.concepts))
|
|
150
|
+
concepts.add(k);
|
|
151
|
+
// Hop 2: the items holding those concepts — a concept many of them share links each one less — and the items
|
|
152
|
+
// linked to anything reached.
|
|
153
|
+
const asked = [...concepts].slice(0, 300);
|
|
154
|
+
if (asked.length) {
|
|
155
|
+
for (const p of await d.client.scroll(keyOnly([{ key: 'concepts', match: { any: asked } }]), POSTINGS)) {
|
|
156
|
+
if (!items.has(String(p.payload.item)))
|
|
157
|
+
items.set(String(p.payload.item), p.payload);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
const linkedOut = [...new Set([...items.values()].flatMap((p) => strings(p.related)))].filter((u) => !items.has(u));
|
|
161
|
+
for (const p of await d.client.retrieve(linkedOut.map((u) => pointId(u, 0))))
|
|
162
|
+
items.set(String(p.payload.item), p.payload);
|
|
163
|
+
const df = new Map();
|
|
164
|
+
for (const p of items.values())
|
|
165
|
+
for (const k of strings(p.concepts))
|
|
166
|
+
if (concepts.has(k))
|
|
167
|
+
df.set(k, (df.get(k) ?? 0) + 1);
|
|
168
|
+
for (const [u, p] of items) {
|
|
169
|
+
if (!shown(p))
|
|
170
|
+
continue;
|
|
171
|
+
for (const k of strings(p.concepts))
|
|
172
|
+
if (concepts.has(k))
|
|
173
|
+
g.link(`i:${u}`, `c:${k}`, 1 / Math.log(2 + (df.get(k) ?? 1)));
|
|
174
|
+
for (const r of strings(p.related))
|
|
175
|
+
if (items.has(r) && shown(items.get(r)))
|
|
176
|
+
g.link(`i:${u}`, `i:${r}`, RELATED);
|
|
177
|
+
}
|
|
178
|
+
// The walk starts again half at the seed items and half at the topic's concepts (either alone when only one is).
|
|
179
|
+
const start = new Map();
|
|
180
|
+
for (const u of seedItems.keys())
|
|
181
|
+
start.set(`i:${u}`, (seedConcepts.size ? 0.5 : 1) / seedItems.size);
|
|
182
|
+
const cTotal = [...seedConcepts.values()].reduce((s, w) => s + w, 0);
|
|
183
|
+
for (const [k, w] of seedConcepts)
|
|
184
|
+
start.set(`c:${k}`, (seedItems.size ? 0.5 : 1) * (w / cTotal));
|
|
185
|
+
const x = g.ppr(start);
|
|
186
|
+
const skip = new Set([...found, ...seedItems.keys()]);
|
|
187
|
+
const seedTitles = new Map([...seedItems.keys()].map((u) => [u, String(items.get(u)?.title ?? u)]));
|
|
188
|
+
const seedKeys = new Set([...seedConcepts.keys(), ...[...seedItems.keys()].flatMap((u) => strings(items.get(u)?.concepts))]);
|
|
189
|
+
const related = [...items].filter(([u, p]) => !skip.has(u) && shown(p) && (x.get(`i:${u}`) ?? 0) > 0)
|
|
190
|
+
.map(([u, p]) => ({ id: u, score: x.get(`i:${u}`), via: via(p) }))
|
|
191
|
+
.sort((a, b) => b.score - a.score).slice(0, q.limit ?? 10);
|
|
192
|
+
/** How an item was reached: the concepts it shares with where the walk started, a link, or nearness to a seed. */
|
|
193
|
+
function via(p) {
|
|
194
|
+
const shared = strings(p.concepts).filter((k) => seedKeys.has(k)).slice(0, 3).map((k) => `"${k}"`);
|
|
195
|
+
if (shared.length)
|
|
196
|
+
return `shares ${shared.join(', ')}`;
|
|
197
|
+
const linked = strings(p.related).find((r) => seedTitles.has(r));
|
|
198
|
+
if (linked)
|
|
199
|
+
return `linked to "${seedTitles.get(linked)}"`;
|
|
200
|
+
const through = strings(p.concepts).find((k) => (x.get(`c:${k}`) ?? 0) > 0);
|
|
201
|
+
if (through)
|
|
202
|
+
return `through "${through}"`;
|
|
203
|
+
return 'near what was found';
|
|
204
|
+
}
|
|
205
|
+
const conceptScores = [...x].filter(([n, s]) => n.startsWith('c:') && s > 0).sort((a, b) => b[1] - a[1]).slice(0, 12)
|
|
206
|
+
.map(([n, score]) => ({ name: n.slice(2), score }));
|
|
207
|
+
return { found, related, concepts: conceptScores };
|
|
208
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { type WordEncoder } from './words.js';
|
|
2
|
+
/** The slice of onnxruntime-node used here, typed locally so the build never needs the binding. */
|
|
3
|
+
interface OrtTensor {
|
|
4
|
+
dims: readonly number[];
|
|
5
|
+
data: Float32Array;
|
|
6
|
+
}
|
|
7
|
+
interface OrtSession {
|
|
8
|
+
outputNames: readonly string[];
|
|
9
|
+
run(feeds: Record<string, unknown>): Promise<Record<string, OrtTensor>>;
|
|
10
|
+
release?(): Promise<void>;
|
|
11
|
+
}
|
|
12
|
+
export interface OrtModule {
|
|
13
|
+
InferenceSession: {
|
|
14
|
+
create(path: string, opts?: Record<string, unknown>): Promise<OrtSession>;
|
|
15
|
+
};
|
|
16
|
+
Tensor: new (type: string, data: BigInt64Array, dims: number[]) => unknown;
|
|
17
|
+
}
|
|
18
|
+
export interface Tokens {
|
|
19
|
+
encode(text: string): {
|
|
20
|
+
ids?: number[];
|
|
21
|
+
input_ids?: number[];
|
|
22
|
+
} | number[];
|
|
23
|
+
}
|
|
24
|
+
export interface LocalEmbedderOptions {
|
|
25
|
+
threads?: number;
|
|
26
|
+
batch?: number;
|
|
27
|
+
/** Tokens read of a text (the model's own limit is longer; memory's keys are short). */
|
|
28
|
+
maxTokens?: number;
|
|
29
|
+
/** Stand-ins for the two optional packages (tests). */
|
|
30
|
+
ort?: OrtModule;
|
|
31
|
+
tokenizer?: Tokens;
|
|
32
|
+
}
|
|
33
|
+
export interface LocalEmbedder {
|
|
34
|
+
/** Already prompted texts → unit vectors, in order: RecallDeps.embed. */
|
|
35
|
+
embed(texts: string[], timeoutMs?: number): Promise<Float32Array[]>;
|
|
36
|
+
words: WordEncoder;
|
|
37
|
+
close(): Promise<void>;
|
|
38
|
+
}
|
|
39
|
+
export declare function loadLocalEmbedder(dir: string, opts?: LocalEmbedderOptions): Promise<LocalEmbedder>;
|
|
40
|
+
export {};
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EmbeddingGemma on this machine, for a memory that needs no server: the same quantised model and the same prompts as
|
|
3
|
+
* the remote workers, so vectors and an index move between the two. One run gives both what the search keeps (the
|
|
4
|
+
* memory's vector) and what word by word keeps (a vector per word, the step before it is averaged: `last_hidden_state`).
|
|
5
|
+
*
|
|
6
|
+
* The model and its tokenizer are a directory you give it (onnx/model_q4.onnx, tokenizer.json, tokenizer_config.json).
|
|
7
|
+
* `onnxruntime-node` and `@huggingface/tokenizers` are optional dependencies, imported only here. One inference at a
|
|
8
|
+
* time; it works on the calling process, so a host with its own work should run it in a worker.
|
|
9
|
+
*/
|
|
10
|
+
import { readFile } from 'node:fs/promises';
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
import { docText, queryText } from './embedder.js';
|
|
13
|
+
import { WORD_LIMIT } from './words.js';
|
|
14
|
+
const BOS_EOS = 2;
|
|
15
|
+
function unit(v) {
|
|
16
|
+
let s = 0;
|
|
17
|
+
for (let i = 0; i < v.length; i++)
|
|
18
|
+
s += v[i] * v[i];
|
|
19
|
+
const n = Math.sqrt(s) || 1;
|
|
20
|
+
return v.map((x) => x / n);
|
|
21
|
+
}
|
|
22
|
+
async function importOptional(name) {
|
|
23
|
+
try {
|
|
24
|
+
const mod = (await import(name));
|
|
25
|
+
return mod.default ?? mod;
|
|
26
|
+
}
|
|
27
|
+
catch (e) {
|
|
28
|
+
throw new Error(`The local embedder needs the optional package ${name}: ${e.message}`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
export async function loadLocalEmbedder(dir, opts = {}) {
|
|
32
|
+
const ort = opts.ort ?? await importOptional('onnxruntime-node');
|
|
33
|
+
const tokenizer = opts.tokenizer ?? await (async () => {
|
|
34
|
+
const { Tokenizer } = await importOptional('@huggingface/tokenizers');
|
|
35
|
+
const json = async (f) => JSON.parse(await readFile(join(dir, f), 'utf8'));
|
|
36
|
+
return new Tokenizer(await json('tokenizer.json'), await json('tokenizer_config.json'));
|
|
37
|
+
})();
|
|
38
|
+
const session = await ort.InferenceSession.create(join(dir, 'onnx', 'model_q4.onnx'), {
|
|
39
|
+
executionProviders: ['cpu'], graphOptimizationLevel: 'all', executionMode: 'sequential', intraOpNumThreads: Math.max(1, opts.threads ?? 1),
|
|
40
|
+
});
|
|
41
|
+
const batch = Math.max(1, opts.batch ?? 8);
|
|
42
|
+
const maxTokens = opts.maxTokens ?? 512;
|
|
43
|
+
const tokens = (text, limit) => {
|
|
44
|
+
const e = tokenizer.encode(text);
|
|
45
|
+
const ids = Array.isArray(e) ? e : (e.ids ?? e.input_ids ?? []);
|
|
46
|
+
return { ids: ids.slice(0, limit), cut: ids.length > limit };
|
|
47
|
+
};
|
|
48
|
+
const run = async (encoded) => {
|
|
49
|
+
const width = Math.max(1, ...encoded.map((e) => e.length));
|
|
50
|
+
const ids = new BigInt64Array(encoded.length * width);
|
|
51
|
+
const mask = new BigInt64Array(encoded.length * width);
|
|
52
|
+
encoded.forEach((e, b) => e.forEach((id, i) => { ids[b * width + i] = BigInt(id); mask[b * width + i] = 1n; }));
|
|
53
|
+
const dims = [encoded.length, width];
|
|
54
|
+
return session.run({ input_ids: new ort.Tensor('int64', ids, dims), attention_mask: new ort.Tensor('int64', mask, dims) });
|
|
55
|
+
};
|
|
56
|
+
// One inference at a time, like lengths in one run: each text is padded to the run's longest.
|
|
57
|
+
let queue = Promise.resolve();
|
|
58
|
+
const inOrder = (job) => {
|
|
59
|
+
const next = queue.then(job, job);
|
|
60
|
+
queue = next.catch(() => undefined);
|
|
61
|
+
return next;
|
|
62
|
+
};
|
|
63
|
+
const batches = async (items, read) => {
|
|
64
|
+
const order = items.map((_, i) => i).sort((a, b) => items[a].ids.length - items[b].ids.length);
|
|
65
|
+
const results = new Array(items.length);
|
|
66
|
+
for (let i = 0; i < order.length; i += batch) {
|
|
67
|
+
const part = order.slice(i, i + batch);
|
|
68
|
+
const out = await run(part.map((j) => items[j].ids));
|
|
69
|
+
part.forEach((j, b) => { results[j] = read(out, b, items[j]); });
|
|
70
|
+
}
|
|
71
|
+
return results;
|
|
72
|
+
};
|
|
73
|
+
return {
|
|
74
|
+
embed: (texts) => inOrder(() => batches(texts.map((t) => tokens(t, maxTokens)), (out, b) => {
|
|
75
|
+
const t = out['sentence_embedding'] ?? out[session.outputNames[session.outputNames.length - 1]];
|
|
76
|
+
const d = t.dims[t.dims.length - 1];
|
|
77
|
+
return unit(Float32Array.from(t.data.subarray(b * d, (b + 1) * d)));
|
|
78
|
+
})),
|
|
79
|
+
words: (texts, kind) => inOrder(() => {
|
|
80
|
+
const prompted = texts.map((t) => (kind === 'query' ? queryText(t) : docText('', t)));
|
|
81
|
+
return batches(prompted.map((t) => tokens(t, WORD_LIMIT + BOS_EOS)), (out, b, it) => {
|
|
82
|
+
const h = out['last_hidden_state'];
|
|
83
|
+
if (!h)
|
|
84
|
+
throw new Error('This model gives no vector per word (last_hidden_state).');
|
|
85
|
+
const [, width, dims] = h.dims;
|
|
86
|
+
// Without its first token, and its last unless the text was cut before it.
|
|
87
|
+
const words = Math.max(0, it.ids.length - (it.cut ? 1 : BOS_EOS));
|
|
88
|
+
const data = new Float32Array(words * dims);
|
|
89
|
+
for (let w = 0; w < words; w++) {
|
|
90
|
+
const from = (b * width + w + 1) * dims;
|
|
91
|
+
data.set(unit(Float32Array.from(h.data.subarray(from, from + dims))), w * dims);
|
|
92
|
+
}
|
|
93
|
+
return { words, dims, data };
|
|
94
|
+
});
|
|
95
|
+
}),
|
|
96
|
+
close: async () => { await queue; await session.release?.(); },
|
|
97
|
+
};
|
|
98
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export declare const MODEL_REPO = "onnx-community/embeddinggemma-300m-ONNX";
|
|
2
|
+
export declare const MODEL_REVISION = "5090578d9565bb06545b4552f76e6bc2c93e4a66";
|
|
3
|
+
export declare const MODEL_FILES: ReadonlyArray<{
|
|
4
|
+
rel: string;
|
|
5
|
+
size: number;
|
|
6
|
+
sha256: string;
|
|
7
|
+
}>;
|
|
8
|
+
export declare function defaultModelDir(): string;
|
|
9
|
+
export declare function modelReady(dir?: string): boolean;
|
|
10
|
+
/** The model in `dir` (fetched and checked unless it is already there); the directory, ready for `modelDir`. */
|
|
11
|
+
export declare function ensureModel(opts?: {
|
|
12
|
+
dir?: string;
|
|
13
|
+
log?: (line: string) => void;
|
|
14
|
+
fetchImpl?: typeof fetch;
|
|
15
|
+
signal?: AbortSignal;
|
|
16
|
+
}): Promise<string>;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The embedding model memory runs on this machine: EmbeddingGemma-300m, 4-bit (onnx-community/embeddinggemma-300m-ONNX
|
|
3
|
+
* on Hugging Face, one pinned revision), fetched on first use into the cache (paths.ts), each file checked against its
|
|
4
|
+
* pinned size and SHA-256. 218 MB. The model is Google's, under the Gemma Terms of Use (ai.google.dev/gemma/terms);
|
|
5
|
+
* memtrails does not ship it.
|
|
6
|
+
*/
|
|
7
|
+
import { createHash } from 'node:crypto';
|
|
8
|
+
import { createWriteStream, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
9
|
+
import { dirname, join } from 'node:path';
|
|
10
|
+
import { Readable } from 'node:stream';
|
|
11
|
+
import { pipeline } from 'node:stream/promises';
|
|
12
|
+
import { cacheDir } from './paths.js';
|
|
13
|
+
export const MODEL_REPO = 'onnx-community/embeddinggemma-300m-ONNX';
|
|
14
|
+
export const MODEL_REVISION = '5090578d9565bb06545b4552f76e6bc2c93e4a66';
|
|
15
|
+
export const MODEL_FILES = [
|
|
16
|
+
{ rel: 'onnx/model_q4.onnx', size: 519_322, sha256: 'ad1dfee81a70f7944b9b9d1cc6e48075b832881cf33fab2f2b248be78f3f0043' },
|
|
17
|
+
{ rel: 'onnx/model_q4.onnx_data', size: 196_725_760, sha256: '599962c3143b040de2dd05e5975be3e9091dd067cacc6a8f7186e3203bab9e02' },
|
|
18
|
+
{ rel: 'tokenizer.json', size: 20_323_312, sha256: '4dda02faaf32bc91031dc8c88457ac272b00c1016cc679757d1c441b248b9c47' },
|
|
19
|
+
{ rel: 'tokenizer_config.json', size: 1_156_830, sha256: '3ca953eea6c3c9fcda9cf3df22949ff18b216f7c74bd6459230f3f1013953f3a' },
|
|
20
|
+
];
|
|
21
|
+
export function defaultModelDir() {
|
|
22
|
+
return join(cacheDir(), `embeddinggemma-300m-${MODEL_REVISION.slice(0, 8)}`);
|
|
23
|
+
}
|
|
24
|
+
const marker = (dir) => join(dir, '.verified');
|
|
25
|
+
const stamp = MODEL_FILES.map((f) => `${f.rel}:${f.size}:${f.sha256}`).join('\n');
|
|
26
|
+
export function modelReady(dir = defaultModelDir()) {
|
|
27
|
+
try {
|
|
28
|
+
return readFileSync(marker(dir), 'utf8') === stamp && MODEL_FILES.every((f) => existsSync(join(dir, f.rel)));
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/** The model in `dir` (fetched and checked unless it is already there); the directory, ready for `modelDir`. */
|
|
35
|
+
export async function ensureModel(opts = {}) {
|
|
36
|
+
const dir = opts.dir ?? defaultModelDir();
|
|
37
|
+
if (modelReady(dir))
|
|
38
|
+
return dir;
|
|
39
|
+
opts.log?.(`[memtrails] fetching ${MODEL_REPO} (218 MB, Gemma Terms of Use)`);
|
|
40
|
+
for (const f of MODEL_FILES) {
|
|
41
|
+
const path = join(dir, f.rel);
|
|
42
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
43
|
+
const part = `${path}.part`;
|
|
44
|
+
rmSync(part, { force: true });
|
|
45
|
+
const res = await (opts.fetchImpl ?? fetch)(`https://huggingface.co/${MODEL_REPO}/resolve/${MODEL_REVISION}/${f.rel}`, { signal: opts.signal, redirect: 'follow' });
|
|
46
|
+
if (!res.ok || !res.body)
|
|
47
|
+
throw new Error(`HTTP ${res.status} for ${f.rel}`);
|
|
48
|
+
const hash = createHash('sha256');
|
|
49
|
+
let size = 0;
|
|
50
|
+
try {
|
|
51
|
+
await pipeline(Readable.fromWeb(res.body), async function* (src) {
|
|
52
|
+
for await (const chunk of src) {
|
|
53
|
+
const b = chunk;
|
|
54
|
+
hash.update(b);
|
|
55
|
+
size += b.length;
|
|
56
|
+
yield b;
|
|
57
|
+
}
|
|
58
|
+
}, createWriteStream(part));
|
|
59
|
+
}
|
|
60
|
+
catch (err) {
|
|
61
|
+
rmSync(part, { force: true });
|
|
62
|
+
throw err;
|
|
63
|
+
}
|
|
64
|
+
const digest = hash.digest('hex');
|
|
65
|
+
if (size !== f.size || digest !== f.sha256) {
|
|
66
|
+
rmSync(part, { force: true });
|
|
67
|
+
throw new Error(`${f.rel}: got ${size} bytes sha256 ${digest}, expected ${f.size} ${f.sha256}`);
|
|
68
|
+
}
|
|
69
|
+
renameSync(part, path);
|
|
70
|
+
}
|
|
71
|
+
writeFileSync(marker(dir), stamp);
|
|
72
|
+
opts.log?.('[memtrails] embedding model ready');
|
|
73
|
+
return dir;
|
|
74
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function cacheDir(): string;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** Where memtrails keeps what it downloads (the Qdrant binary, the embedding model): $MEMTRAILS_CACHE, else
|
|
2
|
+
* $XDG_CACHE_HOME/memtrails, else ~/.cache/memtrails. Shared by every memory on the machine; safe to delete. */
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
export function cacheDir() {
|
|
6
|
+
return process.env.MEMTRAILS_CACHE?.trim() || join(process.env.XDG_CACHE_HOME?.trim() || join(homedir(), '.cache'), 'memtrails');
|
|
7
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { ConsolidateDeps } from './consolidate.js';
|
|
2
|
+
import type { MemoryItem, MemoryScope } from './store.js';
|
|
3
|
+
export declare const PROFILE_MIN_ITEMS = 3;
|
|
4
|
+
export declare const PROFILE_BROAD = 0.2;
|
|
5
|
+
/** The items a profile reads: the newest this many, oldest first, each line cut to PROFILE_LINE. */
|
|
6
|
+
export declare const PROFILE_READ = 40;
|
|
7
|
+
/** Profiles a night writes (a large first sleep goes on the next nights), and asks in flight. */
|
|
8
|
+
export declare const PROFILES_PER_SLEEP = 400;
|
|
9
|
+
/** What memory_search and gathering show of a profile: all of it, up to this (measured p90 1,082 characters). */
|
|
10
|
+
export declare const PROFILE_SHOWN = 1500;
|
|
11
|
+
export interface ProfileSubject {
|
|
12
|
+
uid: string;
|
|
13
|
+
concept: string;
|
|
14
|
+
scope: MemoryScope;
|
|
15
|
+
project: string;
|
|
16
|
+
read: MemoryItem[];
|
|
17
|
+
fingerprint: string;
|
|
18
|
+
}
|
|
19
|
+
export interface ProfileReport {
|
|
20
|
+
profiled: number;
|
|
21
|
+
unprofiled: number;
|
|
22
|
+
paused?: string;
|
|
23
|
+
}
|
|
24
|
+
/** The subjects worth a profile, per project: each concept (as conceptKey folds it, named as most items spell it) on at
|
|
25
|
+
* least PROFILE_MIN_ITEMS items and on at most PROFILE_BROAD of the project's `sizes` (its active items). */
|
|
26
|
+
export declare function profileSubjects(items: MemoryItem[], sizes: Map<string, number>): ProfileSubject[];
|
|
27
|
+
/** Profiles written for the subjects whose items changed (at most `max`, several asks at once), and archived for the
|
|
28
|
+
* subjects that no longer qualify. An ask the model did not answer stops the night's profiles; the next night asks
|
|
29
|
+
* again, as their fingerprints still differ. */
|
|
30
|
+
export declare function writeProfiles(d: ConsolidateDeps, max?: number): Promise<ProfileReport>;
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Profiles — what memory knows about one subject, gathered in one place (sleep's last step).
|
|
3
|
+
*
|
|
4
|
+
* A question about a subject whose facts were told over many conversations ("when did John join the brigade, and what
|
|
5
|
+
* did he do there?") needs the dated facts of all of them, and a top 10 holds a few. For every concept enough long-term
|
|
6
|
+
* items share — at least PROFILE_MIN_ITEMS of a project's items, and on at most PROFILE_BROAD of them (not the person
|
|
7
|
+
* themselves, not the project's own name) — the fast model gathers what they say into one dated list of facts, word for
|
|
8
|
+
* word: a `profile` item, keyed and searched like any other, whose provenance is every source it read.
|
|
9
|
+
*
|
|
10
|
+
* Measured on all of LoCoMo (docs/BENCHMARKS.md): the answer fully shown in the search's top 10
|
|
11
|
+
* 59.1% → 61.7% (56 questions gained, 16 lost), dates 66.6% → 73.1%, 709 profiles for $0.11 of the fast model — shown
|
|
12
|
+
* whole (cut at 500 characters like any item, the gain was not sure). Questions over many conversations did not move.
|
|
13
|
+
*
|
|
14
|
+
* - written again only when what it read changed (a hash of those items is its fingerprint), never from a profile;
|
|
15
|
+
* - a subject that no longer qualifies has its profile archived;
|
|
16
|
+
* - never related, merged or superseded by sleep or consolidation: it is written again from its items.
|
|
17
|
+
*/
|
|
18
|
+
import { createHash } from 'node:crypto';
|
|
19
|
+
import { redactString } from './redact.js';
|
|
20
|
+
import { defaultAsk } from '../llm.js';
|
|
21
|
+
import { conceptKey, cut } from './text.js';
|
|
22
|
+
import { writeLtm } from './writer.js';
|
|
23
|
+
export const PROFILE_MIN_ITEMS = 3;
|
|
24
|
+
export const PROFILE_BROAD = 0.2;
|
|
25
|
+
/** The items a profile reads: the newest this many, oldest first, each line cut to PROFILE_LINE. */
|
|
26
|
+
export const PROFILE_READ = 40;
|
|
27
|
+
const PROFILE_LINE = 300;
|
|
28
|
+
/** Profiles a night writes (a large first sleep goes on the next nights), and asks in flight. */
|
|
29
|
+
export const PROFILES_PER_SLEEP = 400;
|
|
30
|
+
const PROFILE_IN_FLIGHT = 8;
|
|
31
|
+
/** What memory_search and gathering show of a profile: all of it, up to this (measured p90 1,082 characters). */
|
|
32
|
+
export const PROFILE_SHOWN = 1500;
|
|
33
|
+
const PROFILE_SYSTEM = `You write a profile: everything a person's memories say about one subject, gathered in one place, so that a
|
|
34
|
+
later question about the subject finds all of it at once. You get the subject and the memories that mention it, each
|
|
35
|
+
with its date. Write:
|
|
36
|
+
- title: the subject and what the profile covers, at most 12 words
|
|
37
|
+
- facts: one line per distinct fact about the subject, oldest first, each starting with the date it happened or was
|
|
38
|
+
said, as given. Keep every name, number, place, title and quote exactly as written; say who did what. Merge
|
|
39
|
+
repeats into one line, keep facts that differ apart. Nothing that is not in the memories. Never include secret
|
|
40
|
+
values. Never translate.`;
|
|
41
|
+
const PROFILE_SCHEMA = {
|
|
42
|
+
type: 'object', additionalProperties: false, required: ['title', 'facts'],
|
|
43
|
+
properties: { title: { type: 'string' }, facts: { type: 'array', items: { type: 'string' } } },
|
|
44
|
+
};
|
|
45
|
+
const day = (ts) => new Date(ts).toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric', timeZone: 'UTC' });
|
|
46
|
+
const when = (it) => it.eventAt || day(it.ts);
|
|
47
|
+
const hash = (s) => createHash('sha256').update(s).digest('hex');
|
|
48
|
+
/** The subjects worth a profile, per project: each concept (as conceptKey folds it, named as most items spell it) on at
|
|
49
|
+
* least PROFILE_MIN_ITEMS items and on at most PROFILE_BROAD of the project's `sizes` (its active items). */
|
|
50
|
+
export function profileSubjects(items, sizes) {
|
|
51
|
+
const on = new Map();
|
|
52
|
+
for (const it of items) {
|
|
53
|
+
if (it.status !== 'active' || it.kind === 'profile')
|
|
54
|
+
continue;
|
|
55
|
+
const named = new Map();
|
|
56
|
+
for (const c of it.concepts ?? []) {
|
|
57
|
+
const key = conceptKey(c);
|
|
58
|
+
if (key && !named.has(key))
|
|
59
|
+
named.set(key, c.trim());
|
|
60
|
+
}
|
|
61
|
+
for (const [key, name] of named) {
|
|
62
|
+
const id = `${it.project}\n${key}`;
|
|
63
|
+
const s = on.get(id) ?? { project: it.project, key, names: new Map(), items: [] };
|
|
64
|
+
s.names.set(name, (s.names.get(name) ?? 0) + 1);
|
|
65
|
+
s.items.push(it);
|
|
66
|
+
on.set(id, s);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
const out = [];
|
|
70
|
+
for (const [id, s] of on) {
|
|
71
|
+
if (s.items.length < PROFILE_MIN_ITEMS || s.items.length > PROFILE_BROAD * (sizes.get(s.project) ?? 0))
|
|
72
|
+
continue;
|
|
73
|
+
const read = [...s.items].sort((a, b) => b.ts - a.ts || a.uid.localeCompare(b.uid)).slice(0, PROFILE_READ).reverse();
|
|
74
|
+
const scopes = new Map();
|
|
75
|
+
for (const it of read)
|
|
76
|
+
scopes.set(it.scope, (scopes.get(it.scope) ?? 0) + 1);
|
|
77
|
+
out.push({
|
|
78
|
+
uid: `pf:${hash(id).slice(0, 24)}`,
|
|
79
|
+
concept: [...s.names].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))[0][0],
|
|
80
|
+
scope: [...scopes].sort((a, b) => b[1] - a[1])[0][0],
|
|
81
|
+
project: s.project,
|
|
82
|
+
read,
|
|
83
|
+
fingerprint: hash([s.key, ...read.map((it) => `${it.uid}\n${when(it)}\n${it.title}\n${it.summary}`)].join('\n\n')),
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
return out;
|
|
87
|
+
}
|
|
88
|
+
const promptOf = (s) => `SUBJECT: ${s.concept}\n\nMEMORIES:\n${s.read.map((it) => `- (${when(it)}) ${it.title}: ${cut(it.summary.replace(/\s+/g, ' '), PROFILE_LINE)}`).join('\n')}`;
|
|
89
|
+
function profileItem(s, title, facts) {
|
|
90
|
+
const newest = s.read[s.read.length - 1];
|
|
91
|
+
return {
|
|
92
|
+
uid: s.uid, kind: 'profile', scope: s.scope, project: s.project, engine: newest.engine, session: null, cwd: newest.cwd,
|
|
93
|
+
where: s.concept, ts: Math.max(...s.read.map((it) => it.ts)), firstTs: Math.min(...s.read.map((it) => it.firstTs || it.ts)),
|
|
94
|
+
title: cut(title.trim() || s.concept, 120), summary: facts.join('\n'), body: '', files: [], appliesWhen: '',
|
|
95
|
+
confidence: 'curated', importance: 3, status: 'active', provenance: [...new Set(s.read.flatMap((it) => it.provenance))],
|
|
96
|
+
seen: 1, fingerprint: s.fingerprint, concepts: [s.concept],
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
/** Profiles written for the subjects whose items changed (at most `max`, several asks at once), and archived for the
|
|
100
|
+
* subjects that no longer qualify. An ask the model did not answer stops the night's profiles; the next night asks
|
|
101
|
+
* again, as their fingerprints still differ. */
|
|
102
|
+
export async function writeProfiles(d, max = PROFILES_PER_SLEEP) {
|
|
103
|
+
const r = { profiled: 0, unprofiled: 0 };
|
|
104
|
+
const sizes = new Map();
|
|
105
|
+
const mapped = [];
|
|
106
|
+
for (const [uid, m] of d.ltm.entries()) {
|
|
107
|
+
if (m.status !== 'active' || m.kind === 'profile')
|
|
108
|
+
continue;
|
|
109
|
+
sizes.set(m.project, (sizes.get(m.project) ?? 0) + 1);
|
|
110
|
+
const it = m.mapped ? d.ltm.get(uid) : undefined;
|
|
111
|
+
if (it)
|
|
112
|
+
mapped.push(it);
|
|
113
|
+
}
|
|
114
|
+
const subjects = profileSubjects(mapped, sizes);
|
|
115
|
+
const wanted = new Set(subjects.map((s) => s.uid));
|
|
116
|
+
const gone = [...d.ltm.entries()].filter(([uid, m]) => m.kind === 'profile' && m.status === 'active' && !wanted.has(uid))
|
|
117
|
+
.map(([uid]) => d.ltm.get(uid)).filter((it) => !!it).map((it) => ({ ...it, status: 'archived' }));
|
|
118
|
+
if (gone.length) {
|
|
119
|
+
await writeLtm(d, gone);
|
|
120
|
+
d.ltm.put(gone);
|
|
121
|
+
r.unprofiled = gone.length;
|
|
122
|
+
}
|
|
123
|
+
const todo = subjects.filter((s) => { const m = d.ltm.meta(s.uid); return m?.status !== 'active' || m.fp !== s.fingerprint; }).slice(0, max);
|
|
124
|
+
let next = 0;
|
|
125
|
+
await Promise.all(Array.from({ length: Math.min(PROFILE_IN_FLIGHT, todo.length) }, async () => {
|
|
126
|
+
while (!r.paused && next < todo.length && !d.aborted()) {
|
|
127
|
+
const s = todo[next++];
|
|
128
|
+
const a = await (d.ask ?? defaultAsk)({
|
|
129
|
+
purpose: 'memory', system: PROFILE_SYSTEM, prompt: redactString(promptOf(s)), schema: PROFILE_SCHEMA,
|
|
130
|
+
name: 'memory_profile', maxTokens: 3000, timeoutMs: 180_000, consequence: "so this subject's profile waits for the next night",
|
|
131
|
+
});
|
|
132
|
+
if (!('answer' in a)) {
|
|
133
|
+
r.paused = 'unavailable' in a ? a.unavailable : 'busy';
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
const answer = a.answer;
|
|
137
|
+
const facts = (Array.isArray(answer.facts) ? answer.facts : []).filter((f) => typeof f === 'string' && !!f.trim()).map((f) => f.trim());
|
|
138
|
+
if (!facts.length)
|
|
139
|
+
continue;
|
|
140
|
+
const it = profileItem(s, typeof answer.title === 'string' ? answer.title : '', facts);
|
|
141
|
+
await writeLtm(d, [it]);
|
|
142
|
+
d.ltm.put([it]);
|
|
143
|
+
r.profiled++;
|
|
144
|
+
}
|
|
145
|
+
}));
|
|
146
|
+
return r;
|
|
147
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export declare const QDRANT_VERSION = "1.20.0";
|
|
2
|
+
export interface QdrantAsset {
|
|
3
|
+
asset: string;
|
|
4
|
+
size: number;
|
|
5
|
+
sha256: string;
|
|
6
|
+
/** The executable's name inside the archive. */
|
|
7
|
+
exe: string;
|
|
8
|
+
}
|
|
9
|
+
/** Per `${platform}-${arch}`. Linux takes the static musl build: it runs on any distribution. */
|
|
10
|
+
export declare const QDRANT_ASSETS: Readonly<Record<string, QdrantAsset>>;
|
|
11
|
+
export declare function qdrantAsset(platform?: string, arch?: string): QdrantAsset | null;
|
|
12
|
+
export declare function qdrantDir(): string;
|
|
13
|
+
export declare function qdrantBinaryPath(a?: QdrantAsset | null): string | null;
|
|
14
|
+
export declare const QDRANT_RELEASE_BASE = "https://github.com/qdrant/qdrant/releases/download/v1.20.0";
|
|
15
|
+
/** The binary is in place and its archive was verified when it was unpacked. */
|
|
16
|
+
export declare function qdrantReady(a?: QdrantAsset | null): boolean;
|
|
17
|
+
/**
|
|
18
|
+
* Fetch, verify and unpack the binary unless it is already there. Throws on a network failure, a size or hash
|
|
19
|
+
* mismatch (the download is deleted), or a platform Qdrant does not ship for; the caller retries with backoff.
|
|
20
|
+
*/
|
|
21
|
+
export declare function ensureQdrant(opts?: {
|
|
22
|
+
signal?: AbortSignal;
|
|
23
|
+
log?: (line: string) => void;
|
|
24
|
+
fetchImpl?: typeof fetch;
|
|
25
|
+
}): Promise<string>;
|