memtrails 0.3.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 +56 -0
- package/README.md +25 -9
- package/dist/core/consolidate.d.ts +24 -9
- package/dist/core/consolidate.js +101 -49
- package/dist/core/episodes.d.ts +1 -1
- package/dist/core/episodes.js +2 -8
- package/dist/core/extract.d.ts +3 -0
- package/dist/core/extract.js +12 -5
- package/dist/core/gather.d.ts +3 -0
- package/dist/core/gather.js +16 -10
- package/dist/core/model.d.ts +9 -4
- package/dist/core/model.js +39 -40
- package/dist/core/profiles.d.ts +1 -1
- package/dist/core/profiles.js +12 -9
- package/dist/core/qdrant-binary.d.ts +52 -5
- package/dist/core/qdrant-binary.js +197 -48
- package/dist/core/qdrant-client.d.ts +6 -21
- package/dist/core/qdrant-client.js +36 -100
- package/dist/core/qdrant-process.d.ts +30 -2
- package/dist/core/qdrant-process.js +217 -27
- package/dist/core/rebuild.d.ts +5 -1
- package/dist/core/rebuild.js +23 -5
- package/dist/core/recall-log.d.ts +3 -0
- package/dist/core/recall-log.js +6 -1
- package/dist/core/recall.js +19 -8
- package/dist/core/redact.d.ts +8 -0
- package/dist/core/redact.js +48 -9
- package/dist/core/sleep.d.ts +4 -3
- package/dist/core/sleep.js +27 -16
- package/dist/core/store.d.ts +7 -1
- package/dist/core/store.js +101 -33
- package/dist/core/text.d.ts +6 -2
- package/dist/core/text.js +19 -6
- package/dist/core/words.js +6 -2
- package/dist/core/writer.d.ts +2 -1
- package/dist/core/writer.js +9 -3
- package/dist/importers/files.js +96 -15
- package/dist/importers/git.js +3 -1
- 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 +32 -12
- package/dist/importers/slack.js +5 -2
- package/dist/importers/text.js +85 -14
- package/dist/llm.d.ts +10 -8
- package/dist/llm.js +53 -7
- package/dist/memory.d.ts +7 -2
- package/dist/memory.js +284 -57
- package/dist/records.js +6 -3
- package/dist/tools/agent-tools.d.ts +2 -5
- package/dist/tools/agent-tools.js +37 -21
- package/package.json +1 -1
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';
|
|
@@ -31,6 +32,19 @@ function scalar(raw) {
|
|
|
31
32
|
function listValue(items) {
|
|
32
33
|
return items.map((i) => scalar(i)).filter((i) => i !== null && i !== '').join(', ');
|
|
33
34
|
}
|
|
35
|
+
/**
|
|
36
|
+
* One front-matter line as key and value: TOML `key = value`, YAML `key: value` (a `:` then a space or the end). Split
|
|
37
|
+
* at the first `=` or `:` by hand: `^([^=]*?)\s*=` tried a run of spaces at every position, quadratic on a long line.
|
|
38
|
+
*/
|
|
39
|
+
function keyValue(line, toml) {
|
|
40
|
+
const at = line.indexOf(toml ? '=' : ':');
|
|
41
|
+
if (at <= 0 || (toml ? /[\s=#[]/ : /[\s:#-]/).test(line[0]))
|
|
42
|
+
return null;
|
|
43
|
+
const value = line.slice(at + 1);
|
|
44
|
+
if (!toml && value && !/^\s/.test(value))
|
|
45
|
+
return null;
|
|
46
|
+
return [line.slice(0, at).trimEnd(), value];
|
|
47
|
+
}
|
|
34
48
|
/** YAML (`---`) or TOML (`+++`) front matter, flat fields only: scalars, lists (joined with `, `) and block text. */
|
|
35
49
|
export function frontMatter(text) {
|
|
36
50
|
const m = /^(---|\+\+\+)[ \t]*\r?\n([\s\S]*?)\r?\n(?:---|\+\+\+|\.\.\.)[ \t]*(?:\r?\n|$)/.exec(text);
|
|
@@ -40,11 +54,11 @@ export function frontMatter(text) {
|
|
|
40
54
|
const fields = {};
|
|
41
55
|
const lines = m[2].split(/\r?\n/);
|
|
42
56
|
for (let i = 0; i < lines.length; i++) {
|
|
43
|
-
const kv =
|
|
57
|
+
const kv = keyValue(lines[i], toml);
|
|
44
58
|
if (!kv)
|
|
45
59
|
continue;
|
|
46
|
-
const key = kv[
|
|
47
|
-
const value =
|
|
60
|
+
const key = kv[0].trim().replace(/^["']|["']$/g, '');
|
|
61
|
+
const value = kv[1].trim();
|
|
48
62
|
if (value.startsWith('[') && value.endsWith(']')) {
|
|
49
63
|
fields[key] = listValue(value.slice(1, -1).split(','));
|
|
50
64
|
}
|
|
@@ -64,24 +78,54 @@ export function frontMatter(text) {
|
|
|
64
78
|
fields[key] = listValue(items);
|
|
65
79
|
}
|
|
66
80
|
else {
|
|
67
|
-
|
|
81
|
+
const comment = value.search(/\s#/); // not /\s+#.*$/: quadratic on a long run of spaces
|
|
82
|
+
fields[key] = scalar(comment < 0 ? value : value.slice(0, comment));
|
|
68
83
|
}
|
|
69
84
|
}
|
|
70
85
|
return { fields, body: text.slice(m[0].length) };
|
|
71
86
|
}
|
|
87
|
+
/** Title rules see at most this much of a file, a line at a time: per-line regexes over a whole hostile file were slow. */
|
|
88
|
+
const TITLE_SCAN = 64 * 1024;
|
|
89
|
+
/** An RST underline (or overline): one punctuation character, three or more times, then spaces at most. */
|
|
90
|
+
function rstRule(line) {
|
|
91
|
+
const t = line?.trimEnd() ?? ''; // not /[ \t]+$/: quadratic on a long run of spaces
|
|
92
|
+
return t.length >= 3 && '=-~^"\'`#*+'.includes(t[0]) && t === t[0].repeat(t.length);
|
|
93
|
+
}
|
|
72
94
|
function titleOf(ext, body, raw) {
|
|
73
95
|
if (ext === '.html' || ext === '.htm' || ext === '.xhtml')
|
|
74
96
|
return htmlTitle(raw);
|
|
75
|
-
|
|
76
|
-
|
|
97
|
+
const lines = body.slice(0, TITLE_SCAN).split(/\r\n?|\n/);
|
|
98
|
+
const first = (f) => {
|
|
99
|
+
for (let i = 0; i < lines.length; i++) {
|
|
100
|
+
const t = f(lines[i], i);
|
|
101
|
+
if (t !== undefined)
|
|
102
|
+
return t;
|
|
103
|
+
}
|
|
104
|
+
return undefined;
|
|
105
|
+
};
|
|
106
|
+
if (ext === '.org') {
|
|
107
|
+
return first((l) => (/^#\+title:/i.test(l) && l.length > 8 ? l.slice(8).trim() : undefined)) ||
|
|
108
|
+
first((l) => (/^\*+[ \t]/.test(l) && l.replace(/^\*+/, '').trim() ? l.replace(/^\*+/, '').trim() : undefined));
|
|
109
|
+
}
|
|
77
110
|
if (ext === '.rst')
|
|
78
|
-
return
|
|
111
|
+
return first((l, i) => (l.trim() && rstRule(lines[i + 1]) ? l.trim() : undefined));
|
|
79
112
|
if (ext === '.adoc' || ext === '.asciidoc')
|
|
80
|
-
return /^=[ \t]
|
|
81
|
-
|
|
113
|
+
return first((l) => (/^=[ \t]/.test(l) && l.slice(1).trim() ? l.slice(1).trim() : undefined));
|
|
114
|
+
// `# Title`, a closing run of `#` after a space dropped (by hand: `[ \t]+#+$` is quadratic on a run of spaces).
|
|
115
|
+
const atx = first((l) => {
|
|
116
|
+
if (!/^#[ \t]/.test(l))
|
|
117
|
+
return undefined;
|
|
118
|
+
const t = l.slice(1).trim();
|
|
119
|
+
let j = t.length;
|
|
120
|
+
while (j > 0 && t[j - 1] === '#')
|
|
121
|
+
j--;
|
|
122
|
+
const title = j > 0 && j < t.length && /[ \t]/.test(t[j - 1]) ? t.slice(0, j).trimEnd() : t;
|
|
123
|
+
return title || undefined;
|
|
124
|
+
});
|
|
82
125
|
if (atx)
|
|
83
126
|
return atx;
|
|
84
|
-
|
|
127
|
+
// A Setext `Title\n=====`.
|
|
128
|
+
return first((l, i) => (/^\S/.test(l) && /^=+[ \t]*$/.test(lines[i + 1] ?? '') ? l.trimEnd() : undefined));
|
|
85
129
|
}
|
|
86
130
|
async function* walk(root, rel, o) {
|
|
87
131
|
const entries = await readdir(join(root, rel), { withFileTypes: true });
|
|
@@ -108,6 +152,24 @@ function decodeFile(bytes) {
|
|
|
108
152
|
return null; // binary
|
|
109
153
|
return decodeBytes(bytes);
|
|
110
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
|
+
}
|
|
111
173
|
/** Records from the text files in a folder and its subfolders. */
|
|
112
174
|
export async function* fromFiles(dir, opts = {}) {
|
|
113
175
|
const root = resolve(dir);
|
|
@@ -120,12 +182,31 @@ export async function* fromFiles(dir, opts = {}) {
|
|
|
120
182
|
if (opts.exclude?.(path))
|
|
121
183
|
continue;
|
|
122
184
|
const abs = join(root, path);
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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');
|
|
126
190
|
continue;
|
|
127
191
|
}
|
|
128
|
-
|
|
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);
|
|
129
210
|
if (raw === null) {
|
|
130
211
|
opts.onSkip?.(path, 'not text');
|
|
131
212
|
continue;
|
package/dist/importers/git.js
CHANGED
|
@@ -60,7 +60,9 @@ export async function* fromGit(repoDir, opts = {}) {
|
|
|
60
60
|
}
|
|
61
61
|
args.push('--', ...(opts.paths ?? []));
|
|
62
62
|
const maxFiles = opts.maxFiles ?? 50;
|
|
63
|
-
|
|
63
|
+
// `-C`, not `cwd`: on Windows a command is looked up in its working folder first, so `cwd: repoDir` ran a `git.exe`
|
|
64
|
+
// planted in the repository being imported.
|
|
65
|
+
const child = spawn(opts.git ?? 'git', ['-C', resolve(repoDir), ...args], { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
64
66
|
let stderr = '';
|
|
65
67
|
child.stderr.setEncoding('utf8');
|
|
66
68
|
child.stderr.on('data', (d) => {
|
|
@@ -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();
|
|
@@ -148,7 +147,10 @@ function parseParams(raw) {
|
|
|
148
147
|
function quotedPrintable(lines) {
|
|
149
148
|
let s = '';
|
|
150
149
|
for (const [i, raw] of lines.entries()) {
|
|
151
|
-
|
|
150
|
+
let end = raw.length;
|
|
151
|
+
while (end > 0 && (raw[end - 1] === ' ' || raw[end - 1] === '\t' || raw[end - 1] === '\r'))
|
|
152
|
+
end--; // /[ \t\r]+$/ is quadratic on a run of spaces
|
|
153
|
+
const line = raw.slice(0, end);
|
|
152
154
|
if (line.endsWith('='))
|
|
153
155
|
s += line.slice(0, -1);
|
|
154
156
|
else
|
|
@@ -257,12 +259,24 @@ function ids(raw) {
|
|
|
257
259
|
const bracketed = [...v.matchAll(/<([^<>\s]+)>/g)].map((m) => m[1]);
|
|
258
260
|
return bracketed.length ? bracketed : v.split(/\s+/).filter((x) => x.includes('@'));
|
|
259
261
|
}
|
|
262
|
+
/** `Name <address>` → Name, `address (Name)` → Name. By hand: `^\s*(\S+@\S+)\s*\(` is quadratic on `a@a@a@…`. */
|
|
260
263
|
function personOf(from) {
|
|
261
|
-
const
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
264
|
+
const t = from.trim();
|
|
265
|
+
const lt = t.lastIndexOf('<');
|
|
266
|
+
if (t.endsWith('>') && lt >= 0 && !t.slice(lt + 1, -1).includes('>')) {
|
|
267
|
+
return t.slice(0, lt).trim().replace(/^"(.*)"$/, '$1').trim() || t.slice(lt + 1, -1).trim();
|
|
268
|
+
}
|
|
269
|
+
const open = t.indexOf('(');
|
|
270
|
+
const address = open > 0 ? t.slice(0, open).trim() : '';
|
|
271
|
+
if (t.endsWith(')') && t.length - open > 2 && /^\S+$/.test(address) && address.indexOf('@') > 0 && !address.endsWith('@')) {
|
|
272
|
+
return t.slice(open + 1, -1).trim();
|
|
273
|
+
}
|
|
274
|
+
return t;
|
|
275
|
+
}
|
|
276
|
+
/** `(comments)` taken out of a header, up to its last `)`: past it, each `(` would scan to the end and fail. */
|
|
277
|
+
function dropComments(v) {
|
|
278
|
+
const end = v.lastIndexOf(')') + 1;
|
|
279
|
+
return v.slice(0, end).replace(/\([^)]*\)/g, ' ') + v.slice(end);
|
|
266
280
|
}
|
|
267
281
|
/** One message's lines (raw bytes as Latin-1 characters, no line endings) as a record, or null when it says nothing. */
|
|
268
282
|
function messageRecord(lines, fromLine, source, opts) {
|
|
@@ -277,7 +291,7 @@ function messageRecord(lines, fromLine, source, opts) {
|
|
|
277
291
|
if (!text && !subject)
|
|
278
292
|
return null;
|
|
279
293
|
const from = h('from');
|
|
280
|
-
const date = h('date')
|
|
294
|
+
const date = dropComments(h('date')).trim();
|
|
281
295
|
const at = timeOf(date) ?? timeOf(fromLine.replace(/^From \S+\s+/, '').trim());
|
|
282
296
|
const own = ids(e.headers.get('message-id'))[0];
|
|
283
297
|
const id = own ?? createHash('sha256').update([from, date, subject, text.slice(0, 4000)].join('\u0000')).digest('hex').slice(0, 32);
|
|
@@ -292,8 +306,6 @@ function messageRecord(lines, fromLine, source, opts) {
|
|
|
292
306
|
export async function* fromMbox(path, opts = {}) {
|
|
293
307
|
const source = opts.source ?? basename(path, extname(path));
|
|
294
308
|
const max = opts.maxMessageBytes ?? 32 * 1024 * 1024;
|
|
295
|
-
// Latin-1 keeps every byte as one character: each part is decoded later in its own charset.
|
|
296
|
-
const lines = createInterface({ input: createReadStream(path, { encoding: 'latin1' }), crlfDelay: Infinity });
|
|
297
309
|
let msg = null;
|
|
298
310
|
let fromLine = '';
|
|
299
311
|
let size = 0;
|
|
@@ -308,7 +320,15 @@ export async function* fromMbox(path, opts = {}) {
|
|
|
308
320
|
index++;
|
|
309
321
|
return r;
|
|
310
322
|
};
|
|
311
|
-
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');
|
|
312
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`).
|
|
313
333
|
if (line.startsWith('From ') && previousBlank && (msg === null || /\d:\d\d/.test(line))) {
|
|
314
334
|
const r = finish();
|
package/dist/importers/slack.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* channel's name. kind `message`. Joins, leaves, topic changes and the like are left out.
|
|
10
10
|
*/
|
|
11
11
|
import { readdir, readFile, stat } from 'node:fs/promises';
|
|
12
|
-
import { basename, join, resolve } from 'node:path';
|
|
12
|
+
import { basename, dirname, join, resolve } from 'node:path';
|
|
13
13
|
import { record } from './text.js';
|
|
14
14
|
/** Message subtypes left out by default: events about the channel, not things said in it. */
|
|
15
15
|
export const SLACK_SKIPPED_SUBTYPES = ['channel_join', 'channel_leave', 'channel_topic', 'channel_purpose', 'channel_name',
|
|
@@ -89,7 +89,10 @@ export async function* fromSlackExport(dir, opts = {}) {
|
|
|
89
89
|
for (const c of conversations) {
|
|
90
90
|
if (wanted && !wanted.has(c.name) && !wanted.has(c.id))
|
|
91
91
|
continue;
|
|
92
|
-
|
|
92
|
+
// The folder's name comes from the export's own lists: `../x`, `a/b` or an absolute path must not lead out of it.
|
|
93
|
+
const folder = resolve(root, c.folder);
|
|
94
|
+
if (!c.folder || dirname(folder) !== root)
|
|
95
|
+
continue;
|
|
93
96
|
if (!(await stat(folder).catch(() => undefined))?.isDirectory())
|
|
94
97
|
continue;
|
|
95
98
|
const days = (await readdir(folder)).filter((f) => /^\d{4}-\d{2}-\d{2}\.json$/.test(f)).sort();
|
package/dist/importers/text.js
CHANGED
|
@@ -53,13 +53,23 @@ export function decodeEntities(s) {
|
|
|
53
53
|
}
|
|
54
54
|
const BLOCK = 'address|article|aside|blockquote|caption|center|dd|details|dialog|div|dl|dt|fieldset|figcaption|figure|footer|form|' +
|
|
55
55
|
'h[1-6]|header|hr|main|nav|ol|p|pre|section|summary|table|tbody|thead|tfoot|tr|ul';
|
|
56
|
+
/**
|
|
57
|
+
* `f` applied to `s` up to its last `>`, the rest kept. Every tag pattern here ends in `[^>]*>`: past the last `>` each
|
|
58
|
+
* `<` is a start that scans to the end and fails, so a run of unclosed `<a<a<a…` was quadratic; before it, each scan
|
|
59
|
+
* stops at the next `>` and matches.
|
|
60
|
+
*/
|
|
61
|
+
function beforeLastGt(s, f) {
|
|
62
|
+
const end = s.lastIndexOf('>') + 1;
|
|
63
|
+
return end === s.length ? f(s) : f(s.slice(0, end)) + s.slice(end);
|
|
64
|
+
}
|
|
56
65
|
/** Removes every `<tag …>…</tag>` (nested ones too), for `blockquote`: the quoted earlier message in an HTML reply. */
|
|
57
66
|
function dropElements(html, tag) {
|
|
58
67
|
const re = new RegExp(`<(/?)${tag}\\b[^>]*>`, 'gi');
|
|
68
|
+
const head = html.slice(0, html.lastIndexOf('>') + 1); // see beforeLastGt
|
|
59
69
|
let out = '';
|
|
60
70
|
let depth = 0;
|
|
61
71
|
let from = 0;
|
|
62
|
-
for (let m = re.exec(
|
|
72
|
+
for (let m = re.exec(head); m; m = re.exec(head)) {
|
|
63
73
|
if (!m[1]) {
|
|
64
74
|
if (depth === 0)
|
|
65
75
|
out += html.slice(from, m.index);
|
|
@@ -73,11 +83,76 @@ function dropElements(html, tag) {
|
|
|
73
83
|
}
|
|
74
84
|
return depth === 0 ? out + html.slice(from) : out;
|
|
75
85
|
}
|
|
86
|
+
/**
|
|
87
|
+
* Each `<tag …>…</tag>` found by `open` (the tag's name in group 1) replaced by `f(name, element)`, scanning once:
|
|
88
|
+
* `<(script|…)\b[\s\S]*?<\/\1>` rescanned to the end for every unclosed opening tag, quadratic on `<script<script…`.
|
|
89
|
+
* An unclosed one has `element` null; `f` then gives what replaces it and everything after it, or null to leave it as
|
|
90
|
+
* written. A tag with no closing tag after it has none after a later opening tag either, so it is looked for once.
|
|
91
|
+
* The text between elements goes through `outside`.
|
|
92
|
+
*/
|
|
93
|
+
function replaceElements(s, open, f, outside = (t) => t) {
|
|
94
|
+
const closes = new Map();
|
|
95
|
+
const unclosed = new Set();
|
|
96
|
+
let out = '';
|
|
97
|
+
let from = 0;
|
|
98
|
+
open.lastIndex = 0;
|
|
99
|
+
for (let m = open.exec(s); m; m = open.exec(s)) {
|
|
100
|
+
const name = m[1].toLowerCase();
|
|
101
|
+
let end = -1;
|
|
102
|
+
if (!unclosed.has(name)) {
|
|
103
|
+
let close = closes.get(name);
|
|
104
|
+
if (!close)
|
|
105
|
+
closes.set(name, (close = new RegExp(`</${name}\\s*>`, 'gi')));
|
|
106
|
+
close.lastIndex = m.index + m[0].length;
|
|
107
|
+
const c = close.exec(s);
|
|
108
|
+
if (c)
|
|
109
|
+
end = c.index + c[0].length;
|
|
110
|
+
else
|
|
111
|
+
unclosed.add(name);
|
|
112
|
+
}
|
|
113
|
+
const replaced = f(name, end < 0 ? null : s.slice(m.index, end));
|
|
114
|
+
if (replaced === null)
|
|
115
|
+
continue; // left as written: look on after the opening tag
|
|
116
|
+
out += outside(s.slice(from, m.index)) + replaced;
|
|
117
|
+
if (end < 0)
|
|
118
|
+
return out;
|
|
119
|
+
from = open.lastIndex = end;
|
|
120
|
+
}
|
|
121
|
+
return out + outside(s.slice(from));
|
|
122
|
+
}
|
|
123
|
+
/** `<![CDATA[…]]>` as its text; an unclosed one, and everything after it, left as written. */
|
|
124
|
+
function unwrapCdata(s) {
|
|
125
|
+
let out = '';
|
|
126
|
+
let from = 0;
|
|
127
|
+
for (let i = s.indexOf('<![CDATA[', from); i >= 0; i = s.indexOf('<![CDATA[', from)) {
|
|
128
|
+
const j = s.indexOf(']]>', i + 9);
|
|
129
|
+
if (j < 0)
|
|
130
|
+
break;
|
|
131
|
+
out += s.slice(from, i) + s.slice(i + 9, j);
|
|
132
|
+
from = j + 3;
|
|
133
|
+
}
|
|
134
|
+
return out + s.slice(from);
|
|
135
|
+
}
|
|
136
|
+
const HIDDEN = /<(script|style|head|noscript|template|svg|math|iframe|object)\b/gi;
|
|
137
|
+
const PRE = /<(pre)\b/gi;
|
|
138
|
+
/** Title regexes see at most this much of a page: a title is near the top, and a hostile page is not read to its end. */
|
|
139
|
+
const TITLE_SCAN = 64 * 1024;
|
|
76
140
|
/** The `<title>` of an HTML page, else its first `<h1>`, as text; undefined when it has neither. */
|
|
77
141
|
export function htmlTitle(html) {
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
142
|
+
const top = html.slice(0, TITLE_SCAN);
|
|
143
|
+
const head = top.slice(0, top.lastIndexOf('>') + 1); // see beforeLastGt
|
|
144
|
+
for (const tag of ['title', 'h1']) {
|
|
145
|
+
const open = new RegExp(`<${tag}\\b[^>]*>`, 'i').exec(head);
|
|
146
|
+
if (!open)
|
|
147
|
+
continue;
|
|
148
|
+
const bodyStart = open.index + open[0].length;
|
|
149
|
+
const close = new RegExp(`</${tag}\\s*>`, 'gi');
|
|
150
|
+
close.lastIndex = bodyStart;
|
|
151
|
+
const end = close.exec(head);
|
|
152
|
+
if (!end)
|
|
153
|
+
continue;
|
|
154
|
+
const inner = beforeLastGt(head.slice(bodyStart, end.index), (h) => h.replace(/<[^>]*>/g, ' '));
|
|
155
|
+
const t = decodeEntities(inner).replace(/\s+/g, ' ').trim();
|
|
81
156
|
if (t)
|
|
82
157
|
return t;
|
|
83
158
|
}
|
|
@@ -88,24 +163,20 @@ export function htmlTitle(html) {
|
|
|
88
163
|
* as `- `, tags removed, entities decoded, `<pre>` kept as written. `dropQuotes` also drops `<blockquote>`s.
|
|
89
164
|
*/
|
|
90
165
|
export function htmlToText(html, opts = {}) {
|
|
91
|
-
let s = html
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
.replace(/<(script|style|head|noscript|template|svg|math|iframe|object)\b[\s\S]*?<\/\1\s*>/gi, ' ')
|
|
95
|
-
.replace(/<(script|style)\b[\s\S]*$/i, ' '); // one left open runs to the end
|
|
166
|
+
let s = unwrapCdata(html.replace(/<!--[\s\S]*?(-->|$)/g, ' '));
|
|
167
|
+
// Hidden elements dropped; a script or style left open runs to the end, any other unclosed one is left to the tag rules.
|
|
168
|
+
s = replaceElements(s, HIDDEN, (name, element) => (element !== null || name === 'script' || name === 'style' ? ' ' : null));
|
|
96
169
|
if (opts.dropQuotes)
|
|
97
170
|
s = dropElements(s, 'blockquote');
|
|
98
171
|
// Whitespace in the source is one space, except inside <pre>.
|
|
99
|
-
s = s
|
|
100
|
-
|
|
101
|
-
.join('');
|
|
102
|
-
s = s
|
|
172
|
+
s = replaceElements(s, PRE, (_name, element) => element?.replace(/\r\n?/g, '\n') ?? null, (t) => t.replace(/\s+/g, ' '));
|
|
173
|
+
s = beforeLastGt(s, (h) => h
|
|
103
174
|
.replace(/<br\b[^>]*>/gi, '\n')
|
|
104
175
|
.replace(/<li\b[^>]*>/gi, '\n- ')
|
|
105
176
|
.replace(new RegExp(`</?(?:${BLOCK})\\b[^>]*>`, 'gi'), '\n')
|
|
106
177
|
.replace(/<\/t[dh]\s*>/gi, '\t')
|
|
107
178
|
.replace(/<\/?[a-zA-Z][^>]*>/g, '')
|
|
108
|
-
.replace(/<[!?][^>]*>/g, '');
|
|
179
|
+
.replace(/<[!?][^>]*>/g, ''));
|
|
109
180
|
s = decodeEntities(s);
|
|
110
181
|
return tidy(s);
|
|
111
182
|
}
|
package/dist/llm.d.ts
CHANGED
|
@@ -1,10 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The language model memory asks, for consolidation, sleep and gathering — never for adding or searching. One structured
|
|
3
|
-
* ask: a system text, a prompt and a JSON Schema the answer must follow (strict structured output).
|
|
4
|
-
*
|
|
5
|
-
* `openAiAsk` speaks the OpenAI Responses API, so any provider or local server that serves it works (`baseUrl`). Bring
|
|
6
|
-
* any other model by passing your own `Ask`. Every call's tokens can be counted (`onUsage`).
|
|
7
|
-
*/
|
|
8
1
|
export interface LlmRequest {
|
|
9
2
|
/** What this is for, in a word (`memory`). */
|
|
10
3
|
purpose: string;
|
|
@@ -19,6 +12,9 @@ export interface LlmRequest {
|
|
|
19
12
|
timeoutMs?: number;
|
|
20
13
|
/** What a failure cost, said after its reason ("so no summary was written"). */
|
|
21
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;
|
|
22
18
|
}
|
|
23
19
|
export type LlmAnswer = {
|
|
24
20
|
answer: Record<string, unknown>;
|
|
@@ -41,7 +37,7 @@ export interface LlmUsage {
|
|
|
41
37
|
outputTokens: number;
|
|
42
38
|
}
|
|
43
39
|
export interface OpenAiAskOptions {
|
|
44
|
-
/** Default: $MEMTRAILS_LLM_API_KEY, else $OPENAI_API_KEY. */
|
|
40
|
+
/** Default: $MEMTRAILS_LLM_API_KEY, else $OPENAI_API_KEY — the latter only for OpenAI's own address. */
|
|
45
41
|
apiKey?: string;
|
|
46
42
|
/** Default: $MEMTRAILS_MODEL. */
|
|
47
43
|
model?: string;
|
|
@@ -55,6 +51,12 @@ export interface OpenAiAskOptions {
|
|
|
55
51
|
retries?: number;
|
|
56
52
|
fetchImpl?: typeof fetch;
|
|
57
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[]>;
|
|
58
60
|
export declare function openAiAsk(o?: OpenAiAskOptions): Ask;
|
|
59
61
|
/** The model the environment names (MEMTRAILS_MODEL and a key), asked when no `ask` is given. */
|
|
60
62
|
export declare const defaultAsk: Ask;
|