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
|
@@ -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
|
-
|
|
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
|
-
|
|
300
|
-
|
|
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
|
|
338
|
+
const restart = this.restarting;
|
|
320
339
|
this.stop();
|
|
321
|
-
|
|
322
|
-
return;
|
|
323
|
-
await new Promise((resolve) => {
|
|
340
|
+
const within = (p) => new Promise((resolve) => {
|
|
324
341
|
const t = setTimeout(resolve, ms);
|
|
325
|
-
|
|
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
|
}
|
package/dist/core/rebuild.js
CHANGED
|
@@ -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;
|
package/dist/core/recall.js
CHANGED
|
@@ -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
|
-
|
|
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;
|
package/dist/core/sleep.js
CHANGED
|
@@ -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
|
-
|
|
119
|
-
|
|
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
|
-
|
|
158
|
-
|
|
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())
|
package/dist/core/text.d.ts
CHANGED
|
@@ -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
|
-
|
|
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);
|
package/dist/importers/files.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
|
|
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;
|
package/dist/importers/jsonl.js
CHANGED
|
@@ -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
|
|
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
|
+
}
|
package/dist/importers/mbox.js
CHANGED
|
@@ -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
|
|
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
|
|
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.
|
|
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>;
|