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.
@@ -126,6 +126,12 @@ export class QdrantProcess {
126
126
  stopping = false;
127
127
  deaths = [];
128
128
  restartTimer = null;
129
+ /** A restart's start while it runs: stopAndWait waits for it, and for what it began to be gone. */
130
+ restarting = null;
131
+ /** Counts stop()s: a start that sees it move while it starts gives up — before it spawns too, where `child` is null. */
132
+ stops = 0;
133
+ /** This instance's children that have not exited: stopAndWait waits for each, a restart's own included. */
134
+ children = new Set();
129
135
  opts;
130
136
  /** `dir` may be relative: Qdrant runs inside it, so every path it is given is made absolute first. */
131
137
  constructor(opts) {
@@ -139,11 +145,14 @@ export class QdrantProcess {
139
145
  * caller was told, and may not be there to stop it. */
140
146
  async start() {
141
147
  this.stopping = false;
148
+ const stops = this.stops;
142
149
  if (this.endpoint && this.child && this.child.exitCode === null)
143
150
  return this.endpoint;
144
151
  mkdirSync(this.opts.dir, { recursive: true, mode: 0o700 });
145
152
  await this.reapLeftover();
146
153
  const port = await freePort();
154
+ if (this.stops !== stops)
155
+ throw new Error('Qdrant was stopped while it started');
147
156
  const apiKey = randomBytes(24).toString('hex');
148
157
  const config = join(this.opts.dir, 'config.yaml');
149
158
  writeFileSync(config, qdrantConfig(this.opts.dir, port), { mode: 0o600 });
@@ -160,6 +169,10 @@ export class QdrantProcess {
160
169
  closeSync(fd);
161
170
  }
162
171
  this.child = child;
172
+ if (child.pid) {
173
+ this.children.add(child);
174
+ child.once('exit', () => this.children.delete(child));
175
+ }
163
176
  // A binary that is missing or cannot run is an 'error', never an 'exit' — unheard, it is thrown in the host.
164
177
  let failed = null;
165
178
  child.on('error', (err) => { failed ??= err; });
@@ -294,16 +307,21 @@ export class QdrantProcess {
294
307
  this.restartTimer = null;
295
308
  if (this.stopping)
296
309
  return;
297
- this.start().catch((err) => {
310
+ // Held until its start ends: a stop() meanwhile makes that start give up and stop what it began.
311
+ const run = this.start().then(() => undefined, (err) => {
312
+ if (this.stopping)
313
+ return; // given up for a stop: nothing is down
298
314
  this.opts.onDown?.(err.message);
299
- if (!this.stopping)
300
- this.restartLater(err.message);
301
- });
315
+ this.restartLater(err.message);
316
+ }).finally(() => { if (this.restarting === run)
317
+ this.restarting = null; });
318
+ this.restarting = run;
302
319
  }, wait);
303
320
  this.restartTimer.unref?.();
304
321
  }
305
322
  stop() {
306
323
  this.stopping = true;
324
+ this.stops++;
307
325
  if (this.restartTimer) {
308
326
  clearTimeout(this.restartTimer);
309
327
  this.restartTimer = null;
@@ -314,15 +332,19 @@ export class QdrantProcess {
314
332
  if (c)
315
333
  c.kill('SIGTERM');
316
334
  }
317
- /** Stop and wait for the process to be gone — before its storage is deleted. */
335
+ /** Stop and wait for the process to be gone — before its storage is deleted. A restart that was starting one ends
336
+ * first, and the Qdrant it began is waited for too: none is left running after a close. */
318
337
  async stopAndWait(ms = 10_000) {
319
- const c = this.child;
338
+ const restart = this.restarting;
320
339
  this.stop();
321
- if (!c || c.exitCode !== null || c.signalCode !== null)
322
- return;
323
- await new Promise((resolve) => {
340
+ const within = (p) => new Promise((resolve) => {
324
341
  const t = setTimeout(resolve, ms);
325
- c.once('exit', () => { clearTimeout(t); resolve(); });
342
+ const done = () => { clearTimeout(t); resolve(); };
343
+ p.then(done, done);
326
344
  });
345
+ if (restart)
346
+ await within(restart);
347
+ await Promise.all([...this.children].filter((c) => c.exitCode === null && c.signalCode === null)
348
+ .map((c) => within(new Promise((exited) => c.once('exit', exited)))));
327
349
  }
328
350
  }
@@ -69,8 +69,11 @@ export class MemoryRebuild {
69
69
  let skip = after !== null;
70
70
  let batch = [];
71
71
  for (const it of all) {
72
- if (cancelled())
72
+ if (cancelled()) {
73
+ // Stopped (a newer begin, a close): what is being written ends before this returns, not into the next index.
74
+ await Promise.allSettled(queue.map((q) => q.p));
73
75
  return false;
76
+ }
74
77
  if (skip) {
75
78
  if (it.uid === after)
76
79
  skip = false;
@@ -11,7 +11,7 @@ import { pointId } from './qdrant-client.js';
11
11
  import { queryText } from './embedder.js';
12
12
  import { explore } from './graph.js';
13
13
  import { RERANK_TOP, rerankedOrder } from './reranker.js';
14
- import { children, cut, querySparse } from './text.js';
14
+ import { children, cut, flat, querySparse } from './text.js';
15
15
  import { dateSpan, LONG_SUMMARY_CHUNKS } from './writer.js';
16
16
  import { collectionKey } from '../records.js';
17
17
  /** What search may show: active items, never a throwaway run, never superseded or archived knowledge. `kinds`: a
@@ -137,7 +137,7 @@ export async function memorySearch(d, q) {
137
137
  return out;
138
138
  }
139
139
  /** For each hit whose best point was a chunk of its text, that chunk — found again from the text (writer.ts chunks a
140
- * request's whole body; an item's body, and a summary too long for one key vector). */
140
+ * request's whole body; an item's body, and a summary too long for one key vector) — unless the key starts with it. */
141
141
  async function passages(d, hits) {
142
142
  const chunked = hits.filter((h) => h.point && h.point !== pointId(h.item, 0));
143
143
  if (!chunked.length)
@@ -153,7 +153,9 @@ async function passages(d, hits) {
153
153
  const summary = String(p.summary ?? '');
154
154
  const kids = p.tier === 'stm' ? children(body) : [...children(body), ...(children(summary).length >= LONG_SUMMARY_CHUNKS ? children(summary) : [])];
155
155
  const k = kids.findIndex((_, i) => pointId(h.item, i + 1) === h.point);
156
- if (k >= 0)
156
+ // A chunk that is the key's own start is not a passage found elsewhere: a short record's only chunk is its whole
157
+ // key, the two points tie, and either may come back first.
158
+ if (k >= 0 && !flat(summary).startsWith(kids[k]))
157
159
  out.set(h.item, kids[k]);
158
160
  }
159
161
  return out;
@@ -18,6 +18,7 @@ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
18
18
  import { dirname } from 'node:path';
19
19
  import { Consolidator, learnedAt, RELATED, relateBatches, SAME } from './consolidate.js';
20
20
  import { docText } from './embedder.js';
21
+ import { allOrAbort } from '../llm.js';
21
22
  import { pointId } from './qdrant-client.js';
22
23
  import { writeProfiles } from './profiles.js';
23
24
  import { writeLtm } from './writer.js';
@@ -115,10 +116,11 @@ export class Sleep {
115
116
  const inFlight = Math.max(1, this.d.relateInFlight ?? RELATE_IN_FLIGHT);
116
117
  let next = 0;
117
118
  let unanswered = false;
118
- await Promise.all(Array.from({ length: Math.min(inFlight, batches.length) }, async () => {
119
- while (!unanswered && next < batches.length && !this.d.aborted()) {
119
+ // A lane that throws stops the others, their asks too (allOrAbort).
120
+ await allOrAbort(Array.from({ length: Math.min(inFlight, batches.length) }, () => async (signal) => {
121
+ while (!unanswered && next < batches.length && !this.d.aborted() && !signal.aborted) {
120
122
  const b = batches[next++];
121
- const got = await worker.relate(b);
123
+ const got = await worker.relate(b, signal);
122
124
  if (!(got instanceof Map)) {
123
125
  unanswered = true;
124
126
  r.paused ??= got.paused;
@@ -154,8 +156,9 @@ export class Sleep {
154
156
  r.merged++;
155
157
  });
156
158
  let at = 0;
157
- await Promise.all(Array.from({ length: Math.max(1, this.d.applyAtOnce ?? 1) }, async () => {
158
- while (at < pairs.length && !this.d.aborted())
159
+ // An answer that fails to apply stops the other lanes at their next pair.
160
+ await allOrAbort(Array.from({ length: Math.max(1, this.d.applyAtOnce ?? 1) }, () => async (signal) => {
161
+ while (at < pairs.length && !this.d.aborted() && !signal.aborted)
159
162
  await apply(pairs[at++]);
160
163
  }));
161
164
  if (this.d.aborted())
@@ -12,6 +12,8 @@ export declare function fold(text: string): string;
12
12
  /** Words for the keyword channel, in any language: folded (fold()); identifiers kept whole AND split at `_ . - /`; in
13
13
  * scripts without spaces (Chinese, Japanese, Thai…) the segmenter's words. */
14
14
  export declare function tokens(text: string): string[];
15
+ /** Whitespace runs as one space, the ends trimmed. */
16
+ export declare const flat: (s: string) => string;
15
17
  /** A text's first `n` words, as written (whitespace flattened). */
16
18
  export declare function firstWords(text: string, n: number): string;
17
19
  /** 32-bit FNV-1a: a term's index in the sparse vector. */
package/dist/core/text.js CHANGED
@@ -44,7 +44,8 @@ function wordSpans(text) {
44
44
  at.push([seg.index, seg.index + seg.segment.length]);
45
45
  return at;
46
46
  }
47
- const flat = (s) => s.replace(/\s+/g, ' ').trim();
47
+ /** Whitespace runs as one space, the ends trimmed. */
48
+ export const flat = (s) => s.replace(/\s+/g, ' ').trim();
48
49
  /** A text's first `n` words, as written (whitespace flattened). */
49
50
  export function firstWords(text, n) {
50
51
  const at = wordSpans(text);
@@ -8,7 +8,8 @@
8
8
  * front-matter fields go into metadata (`author` also to the record's author). A file longer than `maxChars` is cut
9
9
  * into parts with ids `path#1`, `path#2`, … sharing the thread `path`.
10
10
  */
11
- import { readdir, readFile, stat } from 'node:fs/promises';
11
+ import { constants } from 'node:fs';
12
+ import { lstat, open, readdir } from 'node:fs/promises';
12
13
  import { basename, extname, join, resolve } from 'node:path';
13
14
  import { pathToFileURL } from 'node:url';
14
15
  import { timeOf } from '../records.js';
@@ -151,6 +152,24 @@ function decodeFile(bytes) {
151
152
  return null; // binary
152
153
  return decodeBytes(bytes);
153
154
  }
155
+ /**
156
+ * A file opened for reading, never through a link in its last part (`O_NOFOLLOW`), and without waiting on a pipe swapped
157
+ * in for it (`O_NONBLOCK`, which a regular file ignores); `'link'` when it is one. Where there is no `O_NOFOLLOW`
158
+ * (Windows), a link is refused by looking first.
159
+ */
160
+ async function openNoFollow(path) {
161
+ const { O_RDONLY, O_NOFOLLOW, O_NONBLOCK } = constants;
162
+ if (O_NOFOLLOW === undefined)
163
+ return (await lstat(path)).isSymbolicLink() ? 'link' : open(path, 'r');
164
+ try {
165
+ return await open(path, O_RDONLY | O_NOFOLLOW | (O_NONBLOCK ?? 0));
166
+ }
167
+ catch (err) {
168
+ if (err.code === 'ELOOP')
169
+ return 'link';
170
+ throw err;
171
+ }
172
+ }
154
173
  /** Records from the text files in a folder and its subfolders. */
155
174
  export async function* fromFiles(dir, opts = {}) {
156
175
  const root = resolve(dir);
@@ -163,12 +182,31 @@ export async function* fromFiles(dir, opts = {}) {
163
182
  if (opts.exclude?.(path))
164
183
  continue;
165
184
  const abs = join(root, path);
166
- const info = await stat(abs);
167
- if (info.size > maxBytes) {
168
- opts.onSkip?.(path, `larger than ${maxBytes} bytes`);
185
+ // Opened without following a link, and measured and read from what was opened: a note swapped for a link after the
186
+ // folder was listed (to a key outside it, say) is not read through it.
187
+ const file = await openNoFollow(abs);
188
+ if (file === 'link') {
189
+ opts.onSkip?.(path, 'not a file: it became a link after the folder was read');
169
190
  continue;
170
191
  }
171
- const raw = decodeFile(await readFile(abs));
192
+ let info;
193
+ let bytes;
194
+ try {
195
+ info = await file.stat();
196
+ if (!info.isFile()) {
197
+ opts.onSkip?.(path, 'not a file: it changed after the folder was read');
198
+ continue;
199
+ }
200
+ if (info.size > maxBytes) {
201
+ opts.onSkip?.(path, `larger than ${maxBytes} bytes`);
202
+ continue;
203
+ }
204
+ bytes = await file.readFile();
205
+ }
206
+ finally {
207
+ await file.close();
208
+ }
209
+ const raw = decodeFile(bytes);
172
210
  if (raw === null) {
173
211
  opts.onSkip?.(path, 'not text');
174
212
  continue;
@@ -1,3 +1,9 @@
1
+ /**
2
+ * JSON Lines: one JSON object per line, read a line at a time. With no `map`, each line is a record as it is
3
+ * (`{ "id": "…", "text": "…", "title": …, "at": … }`); with one, `map(obj)` turns each object into a record, or into
4
+ * null to leave it out. Lines that are not JSON or not a record are skipped, counted and reported through `onSkip`; so is a
5
+ * line longer than 64 MiB, which is never held whole.
6
+ */
1
7
  import { type MemoryRecord } from '../records.js';
2
8
  /** Turns one parsed line into a record, or null to leave it out. */
3
9
  export type JsonlMap = (obj: unknown, lineNumber: number) => MemoryRecord | null | undefined;
@@ -1,11 +1,11 @@
1
1
  /**
2
2
  * JSON Lines: one JSON object per line, read a line at a time. With no `map`, each line is a record as it is
3
3
  * (`{ "id": "…", "text": "…", "title": …, "at": … }`); with one, `map(obj)` turns each object into a record, or into
4
- * null to leave it out. Lines that are not JSON or not a record are skipped, counted and reported through `onSkip`.
4
+ * null to leave it out. Lines that are not JSON or not a record are skipped, counted and reported through `onSkip`; so is a
5
+ * line longer than 64 MiB, which is never held whole.
5
6
  */
6
- import { createReadStream } from 'node:fs';
7
- import { createInterface } from 'node:readline';
8
7
  import { invalidRecord } from '../records.js';
8
+ import { byteLines, LineTooLong } from './lines.js';
9
9
  import { record } from './text.js';
10
10
  /** Records from a JSON Lines file; `map` may be given here or in the options. */
11
11
  export async function* fromJsonl(path, map, opts = {}) {
@@ -15,10 +15,15 @@ export async function* fromJsonl(path, map, opts = {}) {
15
15
  stats.skipped++;
16
16
  o.onSkip?.(line, reason, n);
17
17
  };
18
- const lines = createInterface({ input: createReadStream(path, { encoding: 'utf8' }), crlfDelay: Infinity });
19
18
  let n = 0;
20
- for await (const raw of lines) {
19
+ for await (const bytes of byteLines(path)) {
21
20
  n++;
21
+ if (bytes instanceof LineTooLong) {
22
+ stats.lines++;
23
+ skip('', bytes.reason, n);
24
+ continue;
25
+ }
26
+ const raw = bytes.toString('utf8');
22
27
  const line = n === 1 ? raw.replace(/^/, '') : raw;
23
28
  if (!line.trim())
24
29
  continue;
@@ -0,0 +1,13 @@
1
+ /** The longest line read: one past it is passed over, with a reason. */
2
+ export declare const MAX_LINE_BYTES: number;
3
+ /** A line longer than the bound, in its place: its bytes are passed over, never held. */
4
+ export declare class LineTooLong {
5
+ readonly reason: string;
6
+ constructor(reason: string);
7
+ }
8
+ /**
9
+ * A file's lines as `readline` gives them — ended by `\n`, `\r\n` or a lone `\r`, without their ends, and no empty line
10
+ * after the last end — as bytes, each decoded by the caller. One longer than `max` bytes comes as a `LineTooLong`, and
11
+ * the lines after it follow.
12
+ */
13
+ export declare function byteLines(path: string, max?: number): AsyncGenerator<Buffer | LineTooLong>;
@@ -0,0 +1,70 @@
1
+ /**
2
+ * A file read a line at a time, as bytes, with a bound on a line: Node's `readline` has none but V8's longest string
3
+ * (and fails there), so one hostile line held the whole file in memory.
4
+ */
5
+ import { createReadStream } from 'node:fs';
6
+ /** The longest line read: one past it is passed over, with a reason. */
7
+ export const MAX_LINE_BYTES = 64 * 1024 * 1024;
8
+ /** A line longer than the bound, in its place: its bytes are passed over, never held. */
9
+ export class LineTooLong {
10
+ reason;
11
+ constructor(reason) {
12
+ this.reason = reason;
13
+ }
14
+ }
15
+ /**
16
+ * A file's lines as `readline` gives them — ended by `\n`, `\r\n` or a lone `\r`, without their ends, and no empty line
17
+ * after the last end — as bytes, each decoded by the caller. One longer than `max` bytes comes as a `LineTooLong`, and
18
+ * the lines after it follow.
19
+ */
20
+ export async function* byteLines(path, max = MAX_LINE_BYTES) {
21
+ let parts = [];
22
+ let held = 0;
23
+ let read = 0;
24
+ let long = false;
25
+ let afterCr = false;
26
+ const line = () => {
27
+ const out = long ? new LineTooLong(`a line longer than ${max} bytes (${read})`) : Buffer.concat(parts, held);
28
+ parts = [];
29
+ held = 0;
30
+ read = 0;
31
+ long = false;
32
+ return out;
33
+ };
34
+ for await (const chunk of createReadStream(path)) {
35
+ let at = 0;
36
+ while (at < chunk.length) {
37
+ // A `\n` right after a `\r` is the same end.
38
+ if (afterCr) {
39
+ afterCr = false;
40
+ if (chunk[at] === 0x0a) {
41
+ at++;
42
+ continue;
43
+ }
44
+ }
45
+ const lf = chunk.indexOf(0x0a, at);
46
+ const cr = chunk.indexOf(0x0d, at);
47
+ const end = lf < 0 ? cr : cr < 0 ? lf : Math.min(lf, cr);
48
+ const piece = chunk.subarray(at, end < 0 ? chunk.length : end);
49
+ if (!long) {
50
+ if (held + piece.length > max) {
51
+ long = true;
52
+ parts = [];
53
+ held = 0;
54
+ }
55
+ else if (piece.length) {
56
+ parts.push(Buffer.from(piece)); // a copy: the stream's chunk is not this line's to keep
57
+ held += piece.length;
58
+ }
59
+ }
60
+ read += piece.length;
61
+ if (end < 0)
62
+ break;
63
+ afterCr = chunk[end] === 0x0d;
64
+ at = end + 1;
65
+ yield line();
66
+ }
67
+ }
68
+ if (read > 0)
69
+ yield line();
70
+ }
@@ -11,11 +11,10 @@
11
11
  * metadata { from, to, cc, inReplyTo }.
12
12
  */
13
13
  import { createHash } from 'node:crypto';
14
- import { createReadStream } from 'node:fs';
15
14
  import { readFile } from 'node:fs/promises';
16
15
  import { basename, extname } from 'node:path';
17
- import { createInterface } from 'node:readline';
18
16
  import { timeOf } from '../records.js';
17
+ import { byteLines, LineTooLong } from './lines.js';
19
18
  import { decodeBytes, htmlToText, record } from './text.js';
20
19
  function entityOf(lines) {
21
20
  const headers = new Map();
@@ -307,8 +306,6 @@ function messageRecord(lines, fromLine, source, opts) {
307
306
  export async function* fromMbox(path, opts = {}) {
308
307
  const source = opts.source ?? basename(path, extname(path));
309
308
  const max = opts.maxMessageBytes ?? 32 * 1024 * 1024;
310
- // Latin-1 keeps every byte as one character: each part is decoded later in its own charset.
311
- const lines = createInterface({ input: createReadStream(path, { encoding: 'latin1' }), crlfDelay: Infinity });
312
309
  let msg = null;
313
310
  let fromLine = '';
314
311
  let size = 0;
@@ -323,7 +320,15 @@ export async function* fromMbox(path, opts = {}) {
323
320
  index++;
324
321
  return r;
325
322
  };
326
- for await (const line of lines) {
323
+ for await (const bytes of byteLines(path)) {
324
+ // A line past the bound (64 MiB): the message is past its size from here, as a long one is.
325
+ if (bytes instanceof LineTooLong) {
326
+ size = max + 1;
327
+ previousBlank = false;
328
+ continue;
329
+ }
330
+ // Latin-1 keeps every byte as one character: each part is decoded later in its own charset.
331
+ const line = bytes.toString('latin1');
327
332
  // A new message: a `From ` line at the start or after an empty line, carrying a time (`From - Mon Jan 1 00:00:00 2024`).
328
333
  if (line.startsWith('From ') && previousBlank && (msg === null || /\d:\d\d/.test(line))) {
329
334
  const r = finish();
package/dist/llm.d.ts CHANGED
@@ -12,6 +12,9 @@ export interface LlmRequest {
12
12
  timeoutMs?: number;
13
13
  /** What a failure cost, said after its reason ("so no summary was written"). */
14
14
  consequence?: string;
15
+ /** Aborted when no one will read the answer any more (an ask beside it failed, the run stopped): an ask that honours
16
+ * it stops paying for one. */
17
+ signal?: AbortSignal;
15
18
  }
16
19
  export type LlmAnswer = {
17
20
  answer: Record<string, unknown>;
@@ -48,6 +51,12 @@ export interface OpenAiAskOptions {
48
51
  retries?: number;
49
52
  fetchImpl?: typeof fetch;
50
53
  }
54
+ /**
55
+ * `Promise.all` for asks side by side, each handed a signal: the first that throws aborts the others — an ask that
56
+ * honours `signal` stops, and none is paid for an answer no one reads — and they have all settled when it is thrown.
57
+ * `outer` (the caller's) aborts them too.
58
+ */
59
+ export declare function allOrAbort<T>(jobs: Array<(signal: AbortSignal) => Promise<T>>, outer?: AbortSignal): Promise<T[]>;
51
60
  export declare function openAiAsk(o?: OpenAiAskOptions): Ask;
52
61
  /** The model the environment names (MEMTRAILS_MODEL and a key), asked when no `ask` is given. */
53
62
  export declare const defaultAsk: Ask;
package/dist/llm.js CHANGED
@@ -8,6 +8,40 @@
8
8
  import { redactString } from './core/redact.js';
9
9
  const OPENAI = 'https://api.openai.com/v1';
10
10
  const why = (reason, req) => `${reason}${req.consequence ? `, ${req.consequence}` : ''}`;
11
+ /** A wait that an abort ends early. */
12
+ const pause = (ms, signal) => new Promise((ok) => {
13
+ if (signal?.aborted)
14
+ return ok();
15
+ const done = () => { clearTimeout(t); signal?.removeEventListener('abort', done); ok(); };
16
+ const t = setTimeout(done, ms);
17
+ signal?.addEventListener('abort', done, { once: true });
18
+ });
19
+ /**
20
+ * `Promise.all` for asks side by side, each handed a signal: the first that throws aborts the others — an ask that
21
+ * honours `signal` stops, and none is paid for an answer no one reads — and they have all settled when it is thrown.
22
+ * `outer` (the caller's) aborts them too.
23
+ */
24
+ export async function allOrAbort(jobs, outer) {
25
+ const c = new AbortController();
26
+ const follow = () => c.abort(outer?.reason);
27
+ if (outer?.aborted)
28
+ follow();
29
+ else
30
+ outer?.addEventListener('abort', follow, { once: true });
31
+ // Each started in order, at once, as Promise.all over async functions would.
32
+ const runs = jobs.map(async (job) => job(c.signal));
33
+ try {
34
+ return await Promise.all(runs);
35
+ }
36
+ catch (err) {
37
+ c.abort(err);
38
+ await Promise.allSettled(runs);
39
+ throw err;
40
+ }
41
+ finally {
42
+ outer?.removeEventListener('abort', follow);
43
+ }
44
+ }
11
45
  export function openAiAsk(o = {}) {
12
46
  return async (req) => {
13
47
  const model = req.model ?? o.model ?? process.env.MEMTRAILS_MODEL?.trim();
@@ -24,18 +58,24 @@ export function openAiAsk(o = {}) {
24
58
  text: { format: { type: 'json_schema', name: req.name ?? 'memory', strict: true, schema: req.schema } },
25
59
  });
26
60
  const tries = Math.max(1, (o.retries ?? 5) + 1);
61
+ const stopped = { unavailable: why('the ask was stopped', req) };
27
62
  let last = '';
28
63
  for (let attempt = 0; attempt < tries; attempt++) {
29
64
  if (attempt)
30
- await new Promise((ok) => setTimeout(ok, Math.min(60_000, 1000 * 2 ** (attempt - 1)) * (0.5 + Math.random())));
65
+ await pause(Math.min(60_000, 1000 * 2 ** (attempt - 1)) * (0.5 + Math.random()), req.signal);
66
+ if (req.signal?.aborted)
67
+ return stopped;
68
+ const timeout = AbortSignal.timeout(req.timeoutMs ?? 180_000);
31
69
  let res;
32
70
  try {
33
71
  res = await (o.fetchImpl ?? fetch)(`${base}/responses`, {
34
- method: 'POST', body, signal: AbortSignal.timeout(req.timeoutMs ?? 180_000),
72
+ method: 'POST', body, signal: req.signal ? AbortSignal.any([req.signal, timeout]) : timeout,
35
73
  headers: { authorization: `Bearer ${key}`, 'content-type': 'application/json' },
36
74
  });
37
75
  }
38
76
  catch (err) {
77
+ if (req.signal?.aborted)
78
+ return stopped;
39
79
  last = err.message || err.name;
40
80
  continue;
41
81
  }
package/dist/memory.d.ts CHANGED
@@ -106,7 +106,8 @@ export interface Memory {
106
106
  words: number;
107
107
  };
108
108
  /** Stops a consolidate, sleep, rebuild or indexWords at its next step (a rebuild resumes at the next open) and waits
109
- * for it, then releases Qdrant, the model and the home. */
109
+ * for it and for the adds and searches in flight, then releases Qdrant, the model and the home. Every call after it
110
+ * is refused ("memory is closed"). */
110
111
  close(): Promise<void>;
111
112
  }
112
113
  export declare function openMemory(o: MemoryOptions): Promise<Memory>;