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.
Files changed (53) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/README.md +25 -9
  3. package/dist/core/consolidate.d.ts +24 -9
  4. package/dist/core/consolidate.js +101 -49
  5. package/dist/core/episodes.d.ts +1 -1
  6. package/dist/core/episodes.js +2 -8
  7. package/dist/core/extract.d.ts +3 -0
  8. package/dist/core/extract.js +12 -5
  9. package/dist/core/gather.d.ts +3 -0
  10. package/dist/core/gather.js +16 -10
  11. package/dist/core/model.d.ts +9 -4
  12. package/dist/core/model.js +39 -40
  13. package/dist/core/profiles.d.ts +1 -1
  14. package/dist/core/profiles.js +12 -9
  15. package/dist/core/qdrant-binary.d.ts +52 -5
  16. package/dist/core/qdrant-binary.js +197 -48
  17. package/dist/core/qdrant-client.d.ts +6 -21
  18. package/dist/core/qdrant-client.js +36 -100
  19. package/dist/core/qdrant-process.d.ts +30 -2
  20. package/dist/core/qdrant-process.js +217 -27
  21. package/dist/core/rebuild.d.ts +5 -1
  22. package/dist/core/rebuild.js +23 -5
  23. package/dist/core/recall-log.d.ts +3 -0
  24. package/dist/core/recall-log.js +6 -1
  25. package/dist/core/recall.js +19 -8
  26. package/dist/core/redact.d.ts +8 -0
  27. package/dist/core/redact.js +48 -9
  28. package/dist/core/sleep.d.ts +4 -3
  29. package/dist/core/sleep.js +27 -16
  30. package/dist/core/store.d.ts +7 -1
  31. package/dist/core/store.js +101 -33
  32. package/dist/core/text.d.ts +6 -2
  33. package/dist/core/text.js +19 -6
  34. package/dist/core/words.js +6 -2
  35. package/dist/core/writer.d.ts +2 -1
  36. package/dist/core/writer.js +9 -3
  37. package/dist/importers/files.js +96 -15
  38. package/dist/importers/git.js +3 -1
  39. package/dist/importers/jsonl.d.ts +6 -0
  40. package/dist/importers/jsonl.js +10 -5
  41. package/dist/importers/lines.d.ts +13 -0
  42. package/dist/importers/lines.js +70 -0
  43. package/dist/importers/mbox.js +32 -12
  44. package/dist/importers/slack.js +5 -2
  45. package/dist/importers/text.js +85 -14
  46. package/dist/llm.d.ts +10 -8
  47. package/dist/llm.js +53 -7
  48. package/dist/memory.d.ts +7 -2
  49. package/dist/memory.js +284 -57
  50. package/dist/records.js +6 -3
  51. package/dist/tools/agent-tools.d.ts +2 -5
  52. package/dist/tools/agent-tools.js +37 -21
  53. package/package.json +1 -1
@@ -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';
@@ -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 = toml ? /^([^\s=#[][^=]*?)\s*=\s*(.*)$/.exec(lines[i]) : /^([^\s:#-][^:]*?)\s*:(?:\s+(.*)|\s*)$/.exec(lines[i]);
57
+ const kv = keyValue(lines[i], toml);
44
58
  if (!kv)
45
59
  continue;
46
- const key = kv[1].trim().replace(/^["']|["']$/g, '');
47
- const value = (kv[2] ?? '').trim();
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
- fields[key] = scalar(value.replace(/\s+#.*$/, ''));
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
- if (ext === '.org')
76
- return /^#\+title:[ \t]*(.+)$/im.exec(body)?.[1].trim() || /^\*+[ \t]+(.+)$/m.exec(body)?.[1].trim();
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 /^(?:([=\-~^"'`#*+])\1+\r?\n)?[ \t]*(\S[^\n]*?)[ \t]*\r?\n([=\-~^"'`#*+])\3{2,}[ \t]*$/m.exec(body)?.[2];
111
+ return first((l, i) => (l.trim() && rstRule(lines[i + 1]) ? l.trim() : undefined));
79
112
  if (ext === '.adoc' || ext === '.asciidoc')
80
- return /^=[ \t]+(.+)$/m.exec(body)?.[1].trim();
81
- const atx = /^#[ \t]+(.+?)(?:[ \t]+#+)?[ \t]*$/m.exec(body)?.[1].trim();
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
- return /^(\S[^\n]*?)[ \t]*\r?\n=+[ \t]*$/m.exec(body)?.[1]; // a Setext `Title\n=====`
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
- const info = await stat(abs);
124
- if (info.size > maxBytes) {
125
- 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');
126
190
  continue;
127
191
  }
128
- 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);
129
210
  if (raw === null) {
130
211
  opts.onSkip?.(path, 'not text');
131
212
  continue;
@@ -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
- const child = spawn(opts.git ?? 'git', args, { cwd: repoDir, stdio: ['ignore', 'pipe', 'pipe'] });
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;
@@ -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();
@@ -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
- const line = raw.replace(/[ \t\r]+$/, '');
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 named = /^(.*?)\s*<([^<>]*)>\s*$/.exec(from);
262
- if (named)
263
- return named[1].replace(/^"(.*)"$/, '$1').trim() || named[2].trim();
264
- const commented = /^\s*(\S+@\S+)\s*\((.+)\)\s*$/.exec(from);
265
- return commented ? commented[2].trim() : from.trim();
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').replace(/\([^)]*\)/g, ' ').trim();
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 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');
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();
@@ -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
- const folder = join(root, c.folder);
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();
@@ -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(html); m; m = re.exec(html)) {
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
- for (const re of [/<title\b[^>]*>([\s\S]*?)<\/title\s*>/i, /<h1\b[^>]*>([\s\S]*?)<\/h1\s*>/i]) {
79
- const m = re.exec(html);
80
- const t = m ? decodeEntities(m[1].replace(/<[^>]*>/g, ' ')).replace(/\s+/g, ' ').trim() : '';
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
- .replace(/<!--[\s\S]*?(-->|$)/g, ' ')
93
- .replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, '$1')
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.split(/(<pre\b[\s\S]*?<\/pre\s*>)/i)
100
- .map((piece, i) => (i % 2 ? piece.replace(/\r\n?/g, '\n') : piece.replace(/\s+/g, ' ')))
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;