memtrails 0.4.0 → 0.4.2
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/README.md +11 -0
- package/dist/core/consolidate.d.ts +2 -2
- package/dist/core/consolidate.js +49 -24
- package/dist/core/extract.d.ts +3 -0
- package/dist/core/extract.js +7 -0
- package/dist/core/gather.js +4 -2
- package/dist/core/model.d.ts +2 -1
- package/dist/core/model.js +16 -6
- package/dist/core/profiles.js +5 -3
- package/dist/core/qdrant-binary.d.ts +13 -0
- package/dist/core/qdrant-binary.js +23 -8
- package/dist/core/qdrant-client.d.ts +0 -3
- package/dist/core/qdrant-client.js +0 -12
- package/dist/core/qdrant-process.d.ts +8 -1
- package/dist/core/qdrant-process.js +32 -10
- package/dist/core/rebuild.js +4 -1
- package/dist/core/recall.js +5 -3
- package/dist/core/sleep.js +8 -5
- package/dist/core/text.d.ts +2 -0
- package/dist/core/text.js +2 -1
- package/dist/importers/files.js +43 -5
- package/dist/importers/jsonl.d.ts +6 -0
- package/dist/importers/jsonl.js +10 -5
- package/dist/importers/lines.d.ts +13 -0
- package/dist/importers/lines.js +70 -0
- package/dist/importers/mbox.js +10 -5
- package/dist/llm.d.ts +9 -0
- package/dist/llm.js +42 -2
- package/dist/memory.d.ts +2 -1
- package/dist/memory.js +81 -22
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,36 @@ versions follow [Semantic Versioning](https://semver.org/).
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [0.4.2] — 2026-11-05
|
|
9
|
+
|
|
10
|
+
- Fixes: the first start no longer fails when run as root (as in most containers) with "is not this user's alone":
|
|
11
|
+
the downloaded Qdrant binary was unpacked with the archive's owner and then refused. TypeScript and Python; the Rust
|
|
12
|
+
version was not affected and is released at the same version.
|
|
13
|
+
|
|
14
|
+
## [0.4.1] — 2026-11-05
|
|
15
|
+
|
|
16
|
+
- Native Python (`pip install memtrails`, asyncio) and Rust (`cargo add memtrails`, tokio) versions, with the same
|
|
17
|
+
results and the same files as this library: held to shared answers (`conformance/`), and a memory folder written by
|
|
18
|
+
any of the three is searched the same by the others (`interop/`).
|
|
19
|
+
- Fixes: search no longer shows a short record's own summary again as the passage that matched (`match`, and after
|
|
20
|
+
" … " in `searchText`); a match is now only ever a passage beyond the start of what is shown.
|
|
21
|
+
- Fixes: the downloaded Qdrant binary and embedding model are only run or loaded from folders that are this user's
|
|
22
|
+
alone. The cache folder (and the model's folder) is made private when it is created or found open to others, and
|
|
23
|
+
refused with a clear error when it belongs to someone else or is a link. `fromFiles` no longer reads through a note
|
|
24
|
+
that was replaced by a link after the folder was listed, and `fromJsonl` and `fromMbox` skip a line longer than
|
|
25
|
+
64 MiB (with the reason) instead of holding it in memory.
|
|
26
|
+
- Fixes: a rebuild that is stopped (a close, a newer rebuild) returns only once the batches it was writing are written,
|
|
27
|
+
so none of them lands in the index after it.
|
|
28
|
+
- Fixes: `close()` while memory's own Qdrant is being restarted (after it died) stops that restart and waits for the
|
|
29
|
+
Qdrant it began; one could come up after the close and stay running.
|
|
30
|
+
- Fixes: an add no longer runs beside a rebuild (a record could be logged but never indexed, then skipped as unchanged
|
|
31
|
+
when added again); `close()` waits for the adds and searches in flight, and every call after it is refused with
|
|
32
|
+
"memory is closed".
|
|
33
|
+
- `LlmRequest.signal` (optional): aborted when no one will read the answer any more; `openAiAsk` stops its call and its
|
|
34
|
+
retries. Asks made side by side (consolidation's windows, cues and reconcile batches, gathering's parts, sleep's
|
|
35
|
+
relate batches, profiles) stop the others when one fails. Consolidation that fails or is stopped starts no further
|
|
36
|
+
extraction and returns only once the ones being read are over; before, they ran on, paid for, after it returned.
|
|
37
|
+
|
|
8
38
|
## [0.4.0] — 2026-11-04
|
|
9
39
|
|
|
10
40
|
- Prompt examples are invented now. The extraction and gathering prompts had a few example sentences taken from LoCoMo
|
package/README.md
CHANGED
|
@@ -58,6 +58,17 @@ Node.js 22 or newer, on macOS or Linux (x64 or arm64). Windows has the pieces it
|
|
|
58
58
|
(218 MB, from Hugging Face) and the Qdrant binary (~30 MB, from Qdrant's GitHub release) into `~/.cache/memtrails`,
|
|
59
59
|
each checked against a pinned SHA-256. Set `MEMTRAILS_CACHE` to put them elsewhere.
|
|
60
60
|
|
|
61
|
+
### Python and Rust
|
|
62
|
+
|
|
63
|
+
The same library, written natively in each language, fully async, with the same results and the same files: a memory
|
|
64
|
+
folder written by one opens in the others (checked on every change by [`interop/`](https://github.com/cmaftuleac/memtrails/tree/main/interop) and held to the shared
|
|
65
|
+
answers in [`conformance/`](https://github.com/cmaftuleac/memtrails/tree/main/conformance)).
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
pip install memtrails # Python 3.10–3.13, asyncio — see python/README.md
|
|
69
|
+
cargo add memtrails # Rust 1.88+, tokio — see rust/README.md
|
|
70
|
+
```
|
|
71
|
+
|
|
61
72
|
## Adding data
|
|
62
73
|
|
|
63
74
|
A **record** is anything with text. Only `id` and `text` are required.
|
|
@@ -34,7 +34,7 @@ export interface ConsolidateDeps extends WriterDeps {
|
|
|
34
34
|
/** What a window's reading may keep, checked before anything is reconciled or written (e.g. facts only
|
|
35
35
|
* from the person's own words and results the agent confirmed, each with where it came from). An answer it cannot
|
|
36
36
|
* give leaves the window for the next run, as an extraction that was not answered does. */
|
|
37
|
-
guard?: (win: StmEntry[], x: Extraction) => Promise<Extraction | {
|
|
37
|
+
guard?: (win: StmEntry[], x: Extraction, signal?: AbortSignal) => Promise<Extraction | {
|
|
38
38
|
unavailable: string;
|
|
39
39
|
} | {
|
|
40
40
|
busy: true;
|
|
@@ -153,7 +153,7 @@ export declare class Consolidator {
|
|
|
153
153
|
relate(batch: Array<{
|
|
154
154
|
it: MemoryItem;
|
|
155
155
|
known: MemoryItem;
|
|
156
|
-
}
|
|
156
|
+
}>, signal?: AbortSignal): Promise<Map<string, Decision> | {
|
|
157
157
|
paused: string;
|
|
158
158
|
}>;
|
|
159
159
|
}
|
package/dist/core/consolidate.js
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* Then the window's entries are marked done and leave the short-term index: what mattered of them is long-term now.
|
|
16
16
|
*/
|
|
17
17
|
import { redactString } from './redact.js';
|
|
18
|
-
import { defaultAsk } from '../llm.js';
|
|
18
|
+
import { allOrAbort, defaultAsk } from '../llm.js';
|
|
19
19
|
import { episodeBody } from './episodes.js';
|
|
20
20
|
import { docText } from './embedder.js';
|
|
21
21
|
import { cleanConcepts, cleanCues, extractWindow, observeWindow, windowsOf } from './extract.js';
|
|
@@ -262,6 +262,8 @@ export class Consolidator {
|
|
|
262
262
|
}
|
|
263
263
|
const limit = Math.max(1, this.d.concurrency ?? EXTRACT_CONCURRENCY);
|
|
264
264
|
const extracting = [];
|
|
265
|
+
/** Aborted once the lanes end — failed, paused, stopped or done: what is still being read is read for no one. */
|
|
266
|
+
const stop = new AbortController();
|
|
265
267
|
// A pool: the next window starts when ANY one is read, not when the oldest is — one slow answer (measured: an
|
|
266
268
|
// answer that ran on for 19,000 lines) held every other place idle while the windows after it waited their turn.
|
|
267
269
|
// They are still reconciled in order.
|
|
@@ -272,17 +274,18 @@ export class Consolidator {
|
|
|
272
274
|
while (!stopped && reading < limit && next < wins.length && !this.d.aborted()) {
|
|
273
275
|
const win = wins[next];
|
|
274
276
|
const head = win[0];
|
|
275
|
-
const p = this.read(win, { cwd: head.cwd, engine: head.engine, title: head.title });
|
|
277
|
+
const p = this.read(win, { cwd: head.cwd, engine: head.engine, title: head.title }, stop.signal);
|
|
276
278
|
reading++;
|
|
277
|
-
// One left behind by a pause or a stop is not an unhandled rejection; the awaited one still throws.
|
|
278
|
-
|
|
279
|
+
// One left behind by a pause or a stop is not an unhandled rejection; the awaited one still throws. One that
|
|
280
|
+
// throws starts no other: the run throws when it is reached, and no one would read their answers.
|
|
281
|
+
p.then(() => { reading--; launch(); }, () => { reading--; stopped = true; });
|
|
279
282
|
extracting[next++] = p;
|
|
280
283
|
}
|
|
281
284
|
};
|
|
282
285
|
launch();
|
|
283
286
|
const together = Math.max(1, Math.min(limit, this.d.reconcileConcurrency ?? 1));
|
|
284
287
|
/** Window i, once read: reconciled, observed, marked done — or 'stop' when the run pauses. */
|
|
285
|
-
const settle = async (i) => {
|
|
288
|
+
const settle = async (i, signal) => {
|
|
286
289
|
const x = await extracting[i].catch((err) => { stopped = true; throw err; });
|
|
287
290
|
const win = wins[i];
|
|
288
291
|
const head = win[0];
|
|
@@ -319,7 +322,7 @@ export class Consolidator {
|
|
|
319
322
|
...kept.map((k) => episodeItem(byUid.get(k.uid), k, now)),
|
|
320
323
|
...x.candidates.map((c) => itemOf(c, byUid.get(c.provenance.at(-1) ?? '') ?? head, now, c.provenance.flatMap((u) => byUid.get(u) ?? []))),
|
|
321
324
|
];
|
|
322
|
-
await this.reconcile(fresh, r);
|
|
325
|
+
await this.reconcile(fresh, r, signal);
|
|
323
326
|
await this.observe(x.observations.map((o) => observationItem(o, byUid.get(o.provenance.at(-1) ?? '') ?? head, now)), r);
|
|
324
327
|
r.dropped += x.dropped;
|
|
325
328
|
r.episodes += kept.length;
|
|
@@ -341,19 +344,30 @@ export class Consolidator {
|
|
|
341
344
|
};
|
|
342
345
|
let at = 0;
|
|
343
346
|
let halt = false;
|
|
344
|
-
const lane = async () => {
|
|
345
|
-
while (!halt && at < wins.length && extracting[at] && !this.d.aborted()) {
|
|
347
|
+
const lane = async (signal) => {
|
|
348
|
+
while (!halt && at < wins.length && extracting[at] && !this.d.aborted() && !signal.aborted) {
|
|
349
|
+
// No window is started after a failure: the run throws, and no one would read its answer.
|
|
346
350
|
try {
|
|
347
|
-
if (await settle(at
|
|
351
|
+
if (await settle(at++, signal) === 'stop')
|
|
348
352
|
halt = true;
|
|
349
353
|
}
|
|
350
354
|
catch (err) {
|
|
351
355
|
halt = true;
|
|
356
|
+
stopped = true;
|
|
352
357
|
throw err;
|
|
353
358
|
}
|
|
354
359
|
}
|
|
355
360
|
};
|
|
356
|
-
|
|
361
|
+
try {
|
|
362
|
+
await allOrAbort(Array.from({ length: together }, () => lane), stop.signal);
|
|
363
|
+
}
|
|
364
|
+
finally {
|
|
365
|
+
// What is still being read once the lanes end is aborted, and over before the run goes on: no paid ask outlives
|
|
366
|
+
// it, and none starts after it (`stopped`).
|
|
367
|
+
stopped = true;
|
|
368
|
+
stop.abort();
|
|
369
|
+
await Promise.allSettled(extracting.filter((p) => p !== undefined));
|
|
370
|
+
}
|
|
357
371
|
if (halt && r.paused !== undefined)
|
|
358
372
|
return r;
|
|
359
373
|
this.vecs.clear();
|
|
@@ -368,22 +382,27 @@ export class Consolidator {
|
|
|
368
382
|
return r;
|
|
369
383
|
}
|
|
370
384
|
/** One window's reading, and what the guard (when there is one) lets it keep. */
|
|
371
|
-
async read(win, meta) {
|
|
372
|
-
const x = await this.extract(win, meta);
|
|
373
|
-
|
|
385
|
+
async read(win, meta, signal) {
|
|
386
|
+
const x = await this.extract(win, meta, signal);
|
|
387
|
+
if (!this.d.guard || !('candidates' in x))
|
|
388
|
+
return x;
|
|
389
|
+
// Read for no one once the run stopped: the guard (it may ask a model too) is not asked.
|
|
390
|
+
if (signal?.aborted)
|
|
391
|
+
return { unavailable: 'the run stopped' };
|
|
392
|
+
return this.d.guard(win, x, signal);
|
|
374
393
|
}
|
|
375
394
|
/** One window's extraction: the one ask — and, when observations are read apart, the window's small windows too, each
|
|
376
395
|
* with the one before it as context. Any part not answered leaves the whole window for the next run. */
|
|
377
|
-
async extract(win, meta) {
|
|
396
|
+
async extract(win, meta, signal) {
|
|
378
397
|
const chars = this.d.observeChars;
|
|
379
398
|
const known = this.d.resolve ? await this.knownFor(win) : undefined;
|
|
380
399
|
if (!chars)
|
|
381
|
-
return extractWindow(win, meta, { ask: this.d.ask, known });
|
|
400
|
+
return extractWindow(win, meta, { ask: this.d.ask, known, ...(signal ? { signal } : {}) });
|
|
382
401
|
const subs = windowsOf(win, chars);
|
|
383
|
-
const [x, ...seen] = await
|
|
384
|
-
extractWindow(win, meta, { ask: this.d.ask, observations: false, known }),
|
|
385
|
-
...subs.map((sub, k) => observeWindow(sub, k ? subs[k - 1] : [], meta, { ask: this.d.ask, chars, known })),
|
|
386
|
-
]);
|
|
402
|
+
const [x, ...seen] = await allOrAbort([
|
|
403
|
+
(s) => extractWindow(win, meta, { ask: this.d.ask, observations: false, known, signal: s }),
|
|
404
|
+
...subs.map((sub, k) => (s) => observeWindow(sub, k ? subs[k - 1] : [], meta, { ask: this.d.ask, chars, known, signal: s })),
|
|
405
|
+
], signal);
|
|
387
406
|
if (!('candidates' in x))
|
|
388
407
|
return x;
|
|
389
408
|
const failed = seen.find((o) => !('observations' in o));
|
|
@@ -445,7 +464,8 @@ export class Consolidator {
|
|
|
445
464
|
for (let i = 0; i < batches.length; i += limit) {
|
|
446
465
|
if (this.d.aborted())
|
|
447
466
|
break;
|
|
448
|
-
|
|
467
|
+
// One that throws stops the others (allOrAbort).
|
|
468
|
+
const answers = await allOrAbort(batches.slice(i, i + limit).map((b) => (signal) => this.cueBatch(b, signal)));
|
|
449
469
|
const paused = answers.find((a) => 'paused' in a);
|
|
450
470
|
const got = answers.flatMap((a) => ('items' in a ? a.items : []));
|
|
451
471
|
if (got.length) {
|
|
@@ -461,11 +481,12 @@ export class Consolidator {
|
|
|
461
481
|
}
|
|
462
482
|
return { cued };
|
|
463
483
|
}
|
|
464
|
-
async cueBatch(items) {
|
|
484
|
+
async cueBatch(items, signal) {
|
|
465
485
|
const prompt = items.map((it, n) => `[i${n + 1}] (${it.kind}) ${it.title}\n${it.summary}${it.appliesWhen ? `\nNeeded when: ${it.appliesWhen}` : ''}`).join('\n\n');
|
|
466
486
|
const a = await (this.d.ask ?? defaultAsk)({
|
|
467
487
|
purpose: 'memory', system: CUE_SYSTEM, prompt: redactString(prompt), schema: CUE_SCHEMA,
|
|
468
488
|
name: 'memory_cues', maxTokens: 550 * items.length + 200, timeoutMs: 120_000, consequence: 'so these memories wait for their cues',
|
|
489
|
+
...(signal ? { signal } : {}),
|
|
469
490
|
});
|
|
470
491
|
if ('unavailable' in a)
|
|
471
492
|
return { paused: a.unavailable };
|
|
@@ -482,7 +503,7 @@ export class Consolidator {
|
|
|
482
503
|
return { items: out };
|
|
483
504
|
}
|
|
484
505
|
/** Each item against what long-term memory holds — written as it goes, so a later item sees an earlier one. */
|
|
485
|
-
async reconcile(items, r) {
|
|
506
|
+
async reconcile(items, r, signal) {
|
|
486
507
|
const ask = [];
|
|
487
508
|
// Every key of the window in one embedding call (the cache serves each below and again when it is written).
|
|
488
509
|
await this.embed(items.map((it) => docText(it.title, it.summary)), embedBudget(items.length));
|
|
@@ -513,7 +534,10 @@ export class Consolidator {
|
|
|
513
534
|
}
|
|
514
535
|
// The pairs are fixed before any is asked about: every batch asked at once, the answers applied in order.
|
|
515
536
|
const batches = relateBatches(ask);
|
|
516
|
-
|
|
537
|
+
// One that throws stops the others (allOrAbort); a run stopped meanwhile (a lane beside this one failed) applies none.
|
|
538
|
+
const answers = await allOrAbort(batches.map((b) => (s) => this.relate(b, s)), signal);
|
|
539
|
+
if (signal?.aborted)
|
|
540
|
+
throw new Error('consolidation stopped');
|
|
517
541
|
for (const [b, batch] of batches.entries()) {
|
|
518
542
|
const got = answers[b];
|
|
519
543
|
for (const [n, { it, known }] of batch.entries()) {
|
|
@@ -661,7 +685,7 @@ export class Consolidator {
|
|
|
661
685
|
this.d.ltm.put([old]);
|
|
662
686
|
}
|
|
663
687
|
/** The model's decision for each pair, by its label (`p1`, …) — or why it did not answer. */
|
|
664
|
-
async relate(batch) {
|
|
688
|
+
async relate(batch, signal) {
|
|
665
689
|
// Whole texts: a merge the model writes from a shortened body loses the rest of it.
|
|
666
690
|
const prompt = batch.map(({ it, known }, n) => `[p${n + 1}]\nNEW (${it.kind}): ${it.title}\n${it.summary}\n${it.body}\n\nKNOWN (${known.kind}): ${known.title}\n${known.summary}\n${known.body}`).join('\n\n');
|
|
667
691
|
// Room for every merged text in full (≈ 2.5 characters a token, the two sides together), with the decisions.
|
|
@@ -669,6 +693,7 @@ export class Consolidator {
|
|
|
669
693
|
const a = await (this.d.ask ?? defaultAsk)({
|
|
670
694
|
purpose: 'memory', system: RELATE_SYSTEM, prompt: redactString(prompt), schema: RELATE_SCHEMA,
|
|
671
695
|
name: 'memory_reconcile', maxTokens: 900 * batch.length + 200 + room, timeoutMs: 300_000, consequence: 'so these memories are kept apart for now',
|
|
696
|
+
...(signal ? { signal } : {}),
|
|
672
697
|
});
|
|
673
698
|
if ('unavailable' in a)
|
|
674
699
|
return { paused: a.unavailable };
|
package/dist/core/extract.d.ts
CHANGED
|
@@ -111,6 +111,8 @@ export interface ExtractOptions {
|
|
|
111
111
|
observations?: boolean;
|
|
112
112
|
/** Passages of other conversations related to this part (consolidate.ts): what a description refers to is named. */
|
|
113
113
|
known?: string[];
|
|
114
|
+
/** Aborted when the answer is no longer wanted: passed to each ask, and no further part is asked. */
|
|
115
|
+
signal?: AbortSignal;
|
|
114
116
|
}
|
|
115
117
|
/** Read one window. `unavailable`/`busy` come back as they are, so the worker can pause. */
|
|
116
118
|
export declare function extractWindow(win: ReadableRequest[], meta: {
|
|
@@ -133,6 +135,7 @@ export declare function observeWindow(win: ReadableRequest[], before: ReadableRe
|
|
|
133
135
|
ask?: Ask;
|
|
134
136
|
chars: number;
|
|
135
137
|
known?: string[];
|
|
138
|
+
signal?: AbortSignal;
|
|
136
139
|
}): Promise<{
|
|
137
140
|
observations: Observation[];
|
|
138
141
|
} | {
|
package/dist/core/extract.js
CHANGED
|
@@ -336,6 +336,7 @@ export function partsOf(text, max = WINDOW_CHARS) {
|
|
|
336
336
|
return out;
|
|
337
337
|
}
|
|
338
338
|
const outcomeIn = (x) => (OUTCOMES.includes(String(x?.outcome)) ? x.outcome : undefined);
|
|
339
|
+
const STOPPED = { unavailable: 'the ask was stopped, so this part of the conversation waits to be consolidated' };
|
|
339
340
|
/** What the model reads before the part: where it comes from, the calendar of its days, and what other conversations
|
|
340
341
|
* say that it may refer to. */
|
|
341
342
|
function headerOf(win, meta, known) {
|
|
@@ -349,10 +350,13 @@ function headerOf(win, meta, known) {
|
|
|
349
350
|
}
|
|
350
351
|
async function askExtraction(prompt, opts) {
|
|
351
352
|
const withObservations = opts.observations !== false;
|
|
353
|
+
if (opts.signal?.aborted)
|
|
354
|
+
return STOPPED;
|
|
352
355
|
const r = await (opts.ask ?? defaultAsk)({
|
|
353
356
|
purpose: 'memory', system: withObservations ? SYSTEM : SYSTEM_WITHOUT_OBSERVATIONS, prompt: redactString(prompt),
|
|
354
357
|
schema: (withObservations ? SCHEMA : SCHEMA_WITHOUT_OBSERVATIONS), name: 'memory_consolidation',
|
|
355
358
|
maxTokens: EXTRACT_MAX_TOKENS, timeoutMs: 300_000, consequence: 'so this part of the conversation waits to be consolidated',
|
|
359
|
+
...(opts.signal ? { signal: opts.signal } : {}),
|
|
356
360
|
});
|
|
357
361
|
if ('unavailable' in r || 'busy' in r)
|
|
358
362
|
return r;
|
|
@@ -440,9 +444,12 @@ export async function observeWindow(win, before, meta, opts) {
|
|
|
440
444
|
for (const [k, text] of parts.entries()) {
|
|
441
445
|
const earlier = k === 0 ? context : parts[k - 1].slice(-Math.floor(opts.chars / 2));
|
|
442
446
|
const prompt = `${header}\n\n${earlier ? `JUST BEFORE (context only):\n${earlier}\n\n` : ''}TO READ${parts.length > 1 ? ` (part ${k + 1} of ${parts.length})` : ''}:\n${text}`;
|
|
447
|
+
if (opts.signal?.aborted)
|
|
448
|
+
return STOPPED;
|
|
443
449
|
const r = await (opts.ask ?? defaultAsk)({
|
|
444
450
|
purpose: 'memory', system: OBSERVE_SYSTEM, prompt: redactString(prompt), schema: OBSERVE_SCHEMA,
|
|
445
451
|
name: 'memory_observations', maxTokens: 16_000, timeoutMs: 300_000, consequence: 'so this part of the conversation waits to be consolidated',
|
|
452
|
+
...(opts.signal ? { signal: opts.signal } : {}),
|
|
446
453
|
});
|
|
447
454
|
if ('unavailable' in r || 'busy' in r)
|
|
448
455
|
return r;
|
package/dist/core/gather.js
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* the model did not answer keeps all of its candidates.
|
|
15
15
|
*/
|
|
16
16
|
import { redactString } from './redact.js';
|
|
17
|
-
import { defaultAsk } from '../llm.js';
|
|
17
|
+
import { allOrAbort, defaultAsk } from '../llm.js';
|
|
18
18
|
import { PROFILE_SHOWN } from './profiles.js';
|
|
19
19
|
import { memorySearch, MAX_SEARCH } from './recall.js';
|
|
20
20
|
import { cut } from './text.js';
|
|
@@ -70,12 +70,14 @@ async function pick(ask, question, list, notes = false) {
|
|
|
70
70
|
const size = notes ? NOTES_PART : PART;
|
|
71
71
|
for (let i = 0; i < list.length; i += size)
|
|
72
72
|
parts.push(list.slice(i, i + size));
|
|
73
|
-
|
|
73
|
+
// The parts asked at once; one that throws stops the others (allOrAbort).
|
|
74
|
+
const answers = await allOrAbort(parts.map((part) => async (signal) => {
|
|
74
75
|
const prompt = `Question: ${question}\n\nMemories:\n${part.map((h, n) => line(h, n + 1)).join('\n')}`;
|
|
75
76
|
const a = await ask({
|
|
76
77
|
purpose: 'memory', system: notes ? SYSTEM + NOTES_RULE : SYSTEM, prompt: redactString(prompt),
|
|
77
78
|
schema: (notes ? NOTES_SCHEMA : SCHEMA),
|
|
78
79
|
name: 'memory_gather', maxTokens: 40 * part.length + (notes ? 1400 : 600), timeoutMs: 120_000, consequence: 'so these memories are kept unread',
|
|
80
|
+
signal,
|
|
79
81
|
});
|
|
80
82
|
if (!('answer' in a))
|
|
81
83
|
return { keep: part.map((h) => h.id), searches: [], notes: [], paused: 'unavailable' in a ? a.unavailable : 'the fast model is busy' };
|
package/dist/core/model.d.ts
CHANGED
|
@@ -7,7 +7,8 @@ export declare const MODEL_FILES: ReadonlyArray<{
|
|
|
7
7
|
sha256: string;
|
|
8
8
|
}>;
|
|
9
9
|
export declare function defaultModelDir(): string;
|
|
10
|
-
/** Every file in place at its pinned size,
|
|
10
|
+
/** Every file in place at its pinned size, the marker written after the last of them was verified, and no one else could
|
|
11
|
+
* have changed any of them since (the folders, the files and the marker this user's alone, as the Qdrant binary's). */
|
|
11
12
|
export declare function modelReady(dir?: string): boolean;
|
|
12
13
|
/**
|
|
13
14
|
* The model in `dir` (fetched and checked unless it is already there); the directory, ready for `modelDir`. Each file
|
package/dist/core/model.js
CHANGED
|
@@ -4,10 +4,10 @@
|
|
|
4
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
5
|
* memtrails does not ship it.
|
|
6
6
|
*/
|
|
7
|
-
import {
|
|
7
|
+
import { chmodSync, existsSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
8
8
|
import { dirname, join } from 'node:path';
|
|
9
9
|
import { cacheDir } from './paths.js';
|
|
10
|
-
import { downloadVerified, withLock } from './qdrant-binary.js';
|
|
10
|
+
import { downloadVerified, ownedSafely, privateDir, withLock } from './qdrant-binary.js';
|
|
11
11
|
export const MODEL_REPO = 'onnx-community/embeddinggemma-300m-ONNX';
|
|
12
12
|
export const MODEL_REVISION = '5090578d9565bb06545b4552f76e6bc2c93e4a66';
|
|
13
13
|
export const MODEL_FILES = [
|
|
@@ -20,12 +20,16 @@ export function defaultModelDir() {
|
|
|
20
20
|
return join(cacheDir(), `embeddinggemma-300m-${MODEL_REVISION.slice(0, 8)}`);
|
|
21
21
|
}
|
|
22
22
|
const marker = (dir) => join(dir, '.verified');
|
|
23
|
+
/** The model's folder and the folders its files are in. */
|
|
24
|
+
const folders = (dir) => [...new Set([dir, ...MODEL_FILES.map((f) => dirname(join(dir, f.rel)))])];
|
|
23
25
|
const stamp = MODEL_FILES.map((f) => `${f.rel}:${f.size}:${f.sha256}`).join('\n');
|
|
24
|
-
/** Every file in place at its pinned size,
|
|
26
|
+
/** Every file in place at its pinned size, the marker written after the last of them was verified, and no one else could
|
|
27
|
+
* have changed any of them since (the folders, the files and the marker this user's alone, as the Qdrant binary's). */
|
|
25
28
|
export function modelReady(dir = defaultModelDir()) {
|
|
26
29
|
try {
|
|
27
30
|
return readFileSync(marker(dir), 'utf8') === stamp &&
|
|
28
|
-
MODEL_FILES.every((f) => existsSync(join(dir, f.rel)) && statSync(join(dir, f.rel)).size === f.size)
|
|
31
|
+
MODEL_FILES.every((f) => existsSync(join(dir, f.rel)) && statSync(join(dir, f.rel)).size === f.size) &&
|
|
32
|
+
[...folders(dir), marker(dir), ...MODEL_FILES.map((f) => join(dir, f.rel))].every(ownedSafely);
|
|
29
33
|
}
|
|
30
34
|
catch {
|
|
31
35
|
return false;
|
|
@@ -40,7 +44,12 @@ export async function ensureModel(opts = {}) {
|
|
|
40
44
|
const dir = opts.dir ?? defaultModelDir();
|
|
41
45
|
if (modelReady(dir))
|
|
42
46
|
return dir;
|
|
43
|
-
|
|
47
|
+
// Made private, or refused when others could change them: what is verified in them is loaded later. A set already
|
|
48
|
+
// there in folders made before this is ready once they are private (checked again under the lock).
|
|
49
|
+
if (!opts.dir)
|
|
50
|
+
privateDir(cacheDir());
|
|
51
|
+
for (const d of folders(dir))
|
|
52
|
+
privateDir(d);
|
|
44
53
|
return withLock(dir, async () => {
|
|
45
54
|
if (modelReady(dir))
|
|
46
55
|
return dir; // another run finished it while this one waited
|
|
@@ -56,7 +65,8 @@ export async function ensureModel(opts = {}) {
|
|
|
56
65
|
rmSync(tmp, { recursive: true, force: true });
|
|
57
66
|
}
|
|
58
67
|
}
|
|
59
|
-
writeFileSync(marker(dir), stamp);
|
|
68
|
+
writeFileSync(marker(dir), stamp, { mode: 0o644 });
|
|
69
|
+
chmodSync(marker(dir), 0o644); // whatever the umask: a marker others could write is not trusted
|
|
60
70
|
opts.log?.('[memtrails] embedding model ready');
|
|
61
71
|
return dir;
|
|
62
72
|
}, { signal: opts.signal });
|
package/dist/core/profiles.js
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
*/
|
|
18
18
|
import { createHash } from 'node:crypto';
|
|
19
19
|
import { redactString } from './redact.js';
|
|
20
|
-
import { defaultAsk } from '../llm.js';
|
|
20
|
+
import { allOrAbort, defaultAsk } from '../llm.js';
|
|
21
21
|
import { stamped } from './consolidate.js';
|
|
22
22
|
import { conceptKey, cut } from './text.js';
|
|
23
23
|
import { writeLtm } from './writer.js';
|
|
@@ -123,12 +123,14 @@ export async function writeProfiles(d, max = PROFILES_PER_SLEEP) {
|
|
|
123
123
|
}
|
|
124
124
|
const todo = subjects.filter((s) => { const m = d.ltm.meta(s.uid); return m?.status !== 'active' || m.fp !== s.fingerprint; }).slice(0, max);
|
|
125
125
|
let next = 0;
|
|
126
|
-
|
|
127
|
-
|
|
126
|
+
// A lane that throws stops the others, their asks too (allOrAbort).
|
|
127
|
+
await allOrAbort(Array.from({ length: Math.min(PROFILE_IN_FLIGHT, todo.length) }, () => async (signal) => {
|
|
128
|
+
while (!r.paused && next < todo.length && !d.aborted() && !signal.aborted) {
|
|
128
129
|
const s = todo[next++];
|
|
129
130
|
const a = await (d.ask ?? defaultAsk)({
|
|
130
131
|
purpose: 'memory', system: PROFILE_SYSTEM, prompt: redactString(promptOf(s)), schema: PROFILE_SCHEMA,
|
|
131
132
|
name: 'memory_profile', maxTokens: 3000, timeoutMs: 180_000, consequence: "so this subject's profile waits for the next night",
|
|
133
|
+
signal,
|
|
132
134
|
});
|
|
133
135
|
if (!('answer' in a)) {
|
|
134
136
|
r.paused = 'unavailable' in a ? a.unavailable : 'busy';
|
|
@@ -12,6 +12,19 @@ export declare function qdrantAsset(platform?: string, arch?: string): QdrantAss
|
|
|
12
12
|
export declare function qdrantDir(): string;
|
|
13
13
|
export declare function qdrantBinaryPath(a?: QdrantAsset | null): string | null;
|
|
14
14
|
export declare const QDRANT_RELEASE_BASE = "https://github.com/qdrant/qdrant/releases/download/v1.20.0";
|
|
15
|
+
/**
|
|
16
|
+
* Run only what this user alone could have put there: the cache, the folder, the binary and its marker owned by this
|
|
17
|
+
* user, no symlink, writable by no one else. The archive's hash is pinned but not the unpacked binary's (that needs every
|
|
18
|
+
* platform's archive to compute), so on a shared machine this is what stops a binary swapped in after unpacking.
|
|
19
|
+
* Windows has no owner bits; there the user's profile ACLs keep others out.
|
|
20
|
+
*/
|
|
21
|
+
export declare function ownedSafely(path: string): boolean;
|
|
22
|
+
/**
|
|
23
|
+
* The folder made (0700) or made private, and this user's alone — or refused. What is verified in it is run or loaded
|
|
24
|
+
* later: a folder others could write to lets them swap a binary between its check and its run, or a download between
|
|
25
|
+
* its hash and its unpacking. (`mkdirSync`'s mode applies only to a folder it makes.)
|
|
26
|
+
*/
|
|
27
|
+
export declare function privateDir(dir: string): void;
|
|
15
28
|
/** The binary is in place, its archive was verified when it was unpacked, and no one else could have changed it since. */
|
|
16
29
|
export declare function qdrantReady(a?: QdrantAsset | null): boolean;
|
|
17
30
|
/** A download given up after this long without a byte (the answer's headers included). */
|
|
@@ -36,17 +36,29 @@ export function qdrantBinaryPath(a = qdrantAsset()) {
|
|
|
36
36
|
export const QDRANT_RELEASE_BASE = `https://github.com/qdrant/qdrant/releases/download/v${QDRANT_VERSION}`;
|
|
37
37
|
const assetUrl = (a) => `${QDRANT_RELEASE_BASE}/${a.asset}`;
|
|
38
38
|
/**
|
|
39
|
-
* Run only what this user alone could have put there: the folder, the binary and its marker owned by this
|
|
40
|
-
* symlink, writable by no one else. The archive's hash is pinned but not the unpacked binary's (that needs every
|
|
39
|
+
* Run only what this user alone could have put there: the cache, the folder, the binary and its marker owned by this
|
|
40
|
+
* user, no symlink, writable by no one else. The archive's hash is pinned but not the unpacked binary's (that needs every
|
|
41
41
|
* platform's archive to compute), so on a shared machine this is what stops a binary swapped in after unpacking.
|
|
42
42
|
* Windows has no owner bits; there the user's profile ACLs keep others out.
|
|
43
43
|
*/
|
|
44
|
-
function ownedSafely(path) {
|
|
44
|
+
export function ownedSafely(path) {
|
|
45
45
|
if (typeof process.getuid !== 'function')
|
|
46
|
-
return
|
|
46
|
+
return !lstatSync(path).isSymbolicLink();
|
|
47
47
|
const st = lstatSync(path);
|
|
48
48
|
return !st.isSymbolicLink() && st.uid === process.getuid() && (st.mode & 0o022) === 0;
|
|
49
49
|
}
|
|
50
|
+
/**
|
|
51
|
+
* The folder made (0700) or made private, and this user's alone — or refused. What is verified in it is run or loaded
|
|
52
|
+
* later: a folder others could write to lets them swap a binary between its check and its run, or a download between
|
|
53
|
+
* its hash and its unpacking. (`mkdirSync`'s mode applies only to a folder it makes.)
|
|
54
|
+
*/
|
|
55
|
+
export function privateDir(dir) {
|
|
56
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
57
|
+
chmodSync(dir, 0o700);
|
|
58
|
+
if (!ownedSafely(dir)) {
|
|
59
|
+
throw new Error(`${dir} is not this user's alone (its owner, its permissions, or it is a link): set MEMTRAILS_CACHE to a folder of your own`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
50
62
|
/** The binary is in place, its archive was verified when it was unpacked, and no one else could have changed it since. */
|
|
51
63
|
export function qdrantReady(a = qdrantAsset()) {
|
|
52
64
|
const bin = qdrantBinaryPath(a);
|
|
@@ -55,7 +67,7 @@ export function qdrantReady(a = qdrantAsset()) {
|
|
|
55
67
|
const marker = join(qdrantDir(), '.verified');
|
|
56
68
|
try {
|
|
57
69
|
return readFileSync(marker, 'utf8').trim() === `${a.size}:${a.sha256}` && statSync(bin).size > 0 &&
|
|
58
|
-
[qdrantDir(), bin, marker].every(ownedSafely);
|
|
70
|
+
[dirname(qdrantDir()), qdrantDir(), bin, marker].every(ownedSafely);
|
|
59
71
|
}
|
|
60
72
|
catch {
|
|
61
73
|
return false;
|
|
@@ -65,10 +77,13 @@ export function qdrantReady(a = qdrantAsset()) {
|
|
|
65
77
|
function tarProgram() {
|
|
66
78
|
return process.platform === 'win32' ? join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'tar.exe') : 'tar';
|
|
67
79
|
}
|
|
68
|
-
/**
|
|
80
|
+
/**
|
|
81
|
+
* Unpack one archive with the system's tar (bsdtar on macOS and Windows reads zip too). Run as root, tar would give
|
|
82
|
+
* the files the archive's owner, and the binary would then not be this user's alone.
|
|
83
|
+
*/
|
|
69
84
|
function untar(archive, into) {
|
|
70
85
|
return new Promise((resolve, reject) => {
|
|
71
|
-
const child = spawn(tarProgram(), ['-xf', archive, '-C', into], { stdio: 'ignore' });
|
|
86
|
+
const child = spawn(tarProgram(), ['--no-same-owner', '-xf', archive, '-C', into], { stdio: 'ignore' });
|
|
72
87
|
child.once('error', reject);
|
|
73
88
|
child.once('exit', (code) => (code === 0 ? resolve() : reject(new Error(`tar exited ${code}`))));
|
|
74
89
|
});
|
|
@@ -210,7 +225,7 @@ export async function ensureQdrant(opts = {}) {
|
|
|
210
225
|
return bin;
|
|
211
226
|
const dir = qdrantDir();
|
|
212
227
|
const cache = dirname(dir);
|
|
213
|
-
|
|
228
|
+
privateDir(cache);
|
|
214
229
|
return withLock(dir, async () => {
|
|
215
230
|
if (qdrantReady(a))
|
|
216
231
|
return bin; // another run finished it while this one waited
|
|
@@ -62,9 +62,6 @@ export declare class QdrantClient {
|
|
|
62
62
|
score: number;
|
|
63
63
|
payload: Record<string, unknown>;
|
|
64
64
|
}>>;
|
|
65
|
-
/** Each of these items' best cosine to one vector among its points of one part (key, child, cue) — the ranker's
|
|
66
|
-
* per-family similarities. An item with no such point is not in the map. */
|
|
67
|
-
bestPerItem(vector: number[], items: string[], part: string, timeoutMs?: number): Promise<Map<string, number>>;
|
|
68
65
|
/** `nearest` for several vectors in one call. */
|
|
69
66
|
/** `fields`: only these payload fields (and the item), for a caller that needs no more — a walk over
|
|
70
67
|
* many neighbours reads ids, not the bodies of long requests. */
|
|
@@ -177,18 +177,6 @@ export class QdrantClient {
|
|
|
177
177
|
});
|
|
178
178
|
return (r?.points ?? []).map((p) => ({ item: String(p.payload?.item ?? ''), score: p.score, payload: p.payload ?? {} }));
|
|
179
179
|
}
|
|
180
|
-
/** Each of these items' best cosine to one vector among its points of one part (key, child, cue) — the ranker's
|
|
181
|
-
* per-family similarities. An item with no such point is not in the map. */
|
|
182
|
-
async bestPerItem(vector, items, part, timeoutMs = 2000) {
|
|
183
|
-
if (!items.length)
|
|
184
|
-
return new Map();
|
|
185
|
-
const r = await this.call('POST', `${this.at}/points/query/groups`, {
|
|
186
|
-
query: vector, using: 'dense', params: RESCORE,
|
|
187
|
-
filter: { must: [{ key: 'item', match: { any: items } }, { key: 'part', match: { value: part } }] },
|
|
188
|
-
group_by: 'item', group_size: 1, limit: items.length, with_payload: false,
|
|
189
|
-
}, timeoutMs);
|
|
190
|
-
return new Map((r?.groups ?? []).map((g) => [String(g.id), g.hits[0]?.score ?? 0]));
|
|
191
|
-
}
|
|
192
180
|
/** `nearest` for several vectors in one call. */
|
|
193
181
|
/** `fields`: only these payload fields (and the item), for a caller that needs no more — a walk over
|
|
194
182
|
* many neighbours reads ids, not the bodies of long requests. */
|
|
@@ -16,6 +16,12 @@ export declare class QdrantProcess {
|
|
|
16
16
|
private stopping;
|
|
17
17
|
private deaths;
|
|
18
18
|
private restartTimer;
|
|
19
|
+
/** A restart's start while it runs: stopAndWait waits for it, and for what it began to be gone. */
|
|
20
|
+
private restarting;
|
|
21
|
+
/** Counts stop()s: a start that sees it move while it starts gives up — before it spawns too, where `child` is null. */
|
|
22
|
+
private stops;
|
|
23
|
+
/** This instance's children that have not exited: stopAndWait waits for each, a restart's own included. */
|
|
24
|
+
private readonly children;
|
|
19
25
|
private readonly opts;
|
|
20
26
|
/** `dir` may be relative: Qdrant runs inside it, so every path it is given is made absolute first. */
|
|
21
27
|
constructor(opts: {
|
|
@@ -46,6 +52,7 @@ export declare class QdrantProcess {
|
|
|
46
52
|
/** Started again after a growing pause; a restart that fails counts as another death. */
|
|
47
53
|
private restartLater;
|
|
48
54
|
stop(): void;
|
|
49
|
-
/** Stop and wait for the process to be gone — before its storage is deleted.
|
|
55
|
+
/** Stop and wait for the process to be gone — before its storage is deleted. A restart that was starting one ends
|
|
56
|
+
* first, and the Qdrant it began is waited for too: none is left running after a close. */
|
|
50
57
|
stopAndWait(ms?: number): Promise<void>;
|
|
51
58
|
}
|