memtrails 0.4.0 → 0.4.1

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 CHANGED
@@ -5,6 +5,30 @@ versions follow [Semantic Versioning](https://semver.org/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.4.1] — 2026-11-05
9
+
10
+ - Native Python (`pip install memtrails`, asyncio) and Rust (`cargo add memtrails`, tokio) versions, with the same
11
+ results and the same files as this library: held to shared answers (`conformance/`), and a memory folder written by
12
+ any of the three is searched the same by the others (`interop/`).
13
+ - Fixes: search no longer shows a short record's own summary again as the passage that matched (`match`, and after
14
+ " … " in `searchText`); a match is now only ever a passage beyond the start of what is shown.
15
+ - Fixes: the downloaded Qdrant binary and embedding model are only run or loaded from folders that are this user's
16
+ alone. The cache folder (and the model's folder) is made private when it is created or found open to others, and
17
+ refused with a clear error when it belongs to someone else or is a link. `fromFiles` no longer reads through a note
18
+ that was replaced by a link after the folder was listed, and `fromJsonl` and `fromMbox` skip a line longer than
19
+ 64 MiB (with the reason) instead of holding it in memory.
20
+ - Fixes: a rebuild that is stopped (a close, a newer rebuild) returns only once the batches it was writing are written,
21
+ so none of them lands in the index after it.
22
+ - Fixes: `close()` while memory's own Qdrant is being restarted (after it died) stops that restart and waits for the
23
+ Qdrant it began; one could come up after the close and stay running.
24
+ - Fixes: an add no longer runs beside a rebuild (a record could be logged but never indexed, then skipped as unchanged
25
+ when added again); `close()` waits for the adds and searches in flight, and every call after it is refused with
26
+ "memory is closed".
27
+ - `LlmRequest.signal` (optional): aborted when no one will read the answer any more; `openAiAsk` stops its call and its
28
+ retries. Asks made side by side (consolidation's windows, cues and reconcile batches, gathering's parts, sleep's
29
+ relate batches, profiles) stop the others when one fails. Consolidation that fails or is stopped starts no further
30
+ extraction and returns only once the ones being read are over; before, they ran on, paid for, after it returned.
31
+
8
32
  ## [0.4.0] — 2026-11-04
9
33
 
10
34
  - 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
- }>): Promise<Map<string, Decision> | {
156
+ }>, signal?: AbortSignal): Promise<Map<string, Decision> | {
157
157
  paused: string;
158
158
  }>;
159
159
  }
@@ -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
- p.then(() => { reading--; launch(); }, () => { reading--; });
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++) === 'stop')
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
- await Promise.all(Array.from({ length: together }, lane));
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
- return this.d.guard && 'candidates' in x ? this.d.guard(win, x) : x;
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 Promise.all([
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
- const answers = await Promise.all(batches.slice(i, i + limit).map((b) => this.cueBatch(b)));
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
- const answers = await Promise.all(batches.map((b) => this.relate(b)));
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 };
@@ -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
  } | {
@@ -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;
@@ -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
- const answers = await Promise.all(parts.map(async (part) => {
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' };
@@ -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, and the marker written after the last of them was verified. */
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
@@ -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 { existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
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, and the marker written after the last of them was verified. */
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
- mkdirSync(dir, { recursive: true });
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 });
@@ -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
- await Promise.all(Array.from({ length: Math.min(PROFILE_IN_FLIGHT, todo.length) }, async () => {
127
- while (!r.paused && next < todo.length && !d.aborted()) {
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 user, no
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 true;
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;
@@ -210,7 +222,7 @@ export async function ensureQdrant(opts = {}) {
210
222
  return bin;
211
223
  const dir = qdrantDir();
212
224
  const cache = dirname(dir);
213
- mkdirSync(cache, { recursive: true, mode: 0o700 });
225
+ privateDir(cache);
214
226
  return withLock(dir, async () => {
215
227
  if (qdrantReady(a))
216
228
  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
  }