memtrails 0.2.0 → 0.4.0

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 (48) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.md +20 -13
  3. package/dist/core/consolidate.d.ts +23 -8
  4. package/dist/core/consolidate.js +52 -25
  5. package/dist/core/episodes.d.ts +1 -1
  6. package/dist/core/episodes.js +2 -8
  7. package/dist/core/extract.js +5 -5
  8. package/dist/core/gather.d.ts +7 -0
  9. package/dist/core/gather.js +38 -14
  10. package/dist/core/model.d.ts +8 -4
  11. package/dist/core/model.js +29 -40
  12. package/dist/core/profiles.d.ts +1 -1
  13. package/dist/core/profiles.js +7 -6
  14. package/dist/core/qdrant-binary.d.ts +39 -5
  15. package/dist/core/qdrant-binary.js +185 -48
  16. package/dist/core/qdrant-client.d.ts +6 -18
  17. package/dist/core/qdrant-client.js +38 -90
  18. package/dist/core/qdrant-process.d.ts +22 -1
  19. package/dist/core/qdrant-process.js +190 -22
  20. package/dist/core/rebuild.d.ts +5 -1
  21. package/dist/core/rebuild.js +19 -4
  22. package/dist/core/recall-log.d.ts +3 -0
  23. package/dist/core/recall-log.js +6 -1
  24. package/dist/core/recall.js +14 -5
  25. package/dist/core/redact.d.ts +8 -0
  26. package/dist/core/redact.js +48 -9
  27. package/dist/core/sleep.d.ts +4 -3
  28. package/dist/core/sleep.js +19 -11
  29. package/dist/core/store.d.ts +7 -1
  30. package/dist/core/store.js +101 -33
  31. package/dist/core/text.d.ts +4 -2
  32. package/dist/core/text.js +17 -5
  33. package/dist/core/words.js +6 -2
  34. package/dist/core/writer.d.ts +2 -1
  35. package/dist/core/writer.js +9 -3
  36. package/dist/importers/files.js +53 -10
  37. package/dist/importers/git.js +3 -1
  38. package/dist/importers/mbox.js +22 -7
  39. package/dist/importers/slack.js +5 -2
  40. package/dist/importers/text.js +85 -14
  41. package/dist/llm.d.ts +1 -8
  42. package/dist/llm.js +11 -5
  43. package/dist/memory.d.ts +9 -3
  44. package/dist/memory.js +216 -48
  45. package/dist/records.js +6 -3
  46. package/dist/tools/agent-tools.d.ts +2 -5
  47. package/dist/tools/agent-tools.js +37 -21
  48. package/package.json +1 -1
@@ -18,7 +18,8 @@ export declare function writeStm(d: WriterDeps, entries: StmEntry[]): Promise<vo
18
18
  * either there are, so the two never meet. */
19
19
  export declare const cuePoint: (k: number) => number;
20
20
  /** The time a date stands for, as the first and last millisecond: a day, a month or a year (an observation's
21
- * `eventAt`, or a date an agent searches by) — null for anything else. */
21
+ * `eventAt`, or a date an agent searches by) — null for anything else, a month or day that does not exist too
22
+ * (2024-02-30 is not 1 March). */
22
23
  export declare function dateSpan(d: string | undefined): {
23
24
  from: number;
24
25
  to: number;
@@ -36,11 +36,12 @@ export async function writeStm(d, entries) {
36
36
  const points = [];
37
37
  let at = 0;
38
38
  for (const { e, body, kids } of plan) {
39
- const common = { item: stmItem(e.uid), tier: 'stm', status: 'active', kind: 'episode', scope: 'project', project: e.project, session: e.session, ts: e.ts, synthetic: e.synthetic };
39
+ // The record's kind (`engine`) on every point: a kinds filter must find a record by any of its chunks.
40
+ const common = { item: stmItem(e.uid), tier: 'stm', status: 'active', kind: 'episode', engine: e.engine, scope: 'project', project: e.project, session: e.session, ts: e.ts, synthetic: e.synthetic };
40
41
  points.push({
41
42
  id: pointId(stmItem(e.uid), 0),
42
43
  vector: { dense: Array.from(vecs[at]), bm25: docSparse(`${stmTitle(e)}\n${body}`) },
43
- payload: { ...common, part: 'key', engine: e.engine, cwd: e.cwd ?? '', where: e.title, title: stmTitle(e), summary: rawKey(e), body, files: e.files, importance: 0, ...(e.record ? { record: e.record } : {}) },
44
+ payload: { ...common, part: 'key', cwd: e.cwd ?? '', where: e.title, title: stmTitle(e), summary: rawKey(e), body, files: e.files, importance: 0, ...(e.record ? { record: e.record } : {}) },
44
45
  });
45
46
  kids.forEach((_, k) => points.push({ id: pointId(stmItem(e.uid), k + 1), vector: { dense: Array.from(vecs[at + 1 + k]) }, payload: { ...common, part: 'child' } }));
46
47
  at += 1 + kids.length;
@@ -53,12 +54,17 @@ export async function writeStm(d, entries) {
53
54
  * either there are, so the two never meet. */
54
55
  export const cuePoint = (k) => -(k + 1);
55
56
  /** The time a date stands for, as the first and last millisecond: a day, a month or a year (an observation's
56
- * `eventAt`, or a date an agent searches by) — null for anything else. */
57
+ * `eventAt`, or a date an agent searches by) — null for anything else, a month or day that does not exist too
58
+ * (2024-02-30 is not 1 March). */
57
59
  export function dateSpan(d) {
58
60
  const m = /^(\d{4})(?:-(\d{2})(?:-(\d{2}))?)?$/.exec(d ?? '');
59
61
  if (!m)
60
62
  return null;
61
63
  const [y, mo, dd] = [Number(m[1]), m[2] ? Number(m[2]) - 1 : null, m[3] ? Number(m[3]) : null];
64
+ if (mo !== null && (mo < 0 || mo > 11))
65
+ return null;
66
+ if (mo !== null && dd !== null && (dd < 1 || new Date(Date.UTC(y, mo, dd)).getUTCMonth() !== mo))
67
+ return null;
62
68
  if (mo === null)
63
69
  return { from: Date.UTC(y, 0, 1), to: Date.UTC(y + 1, 0, 1) - 1 };
64
70
  if (dd === null)
@@ -31,6 +31,19 @@ function scalar(raw) {
31
31
  function listValue(items) {
32
32
  return items.map((i) => scalar(i)).filter((i) => i !== null && i !== '').join(', ');
33
33
  }
34
+ /**
35
+ * One front-matter line as key and value: TOML `key = value`, YAML `key: value` (a `:` then a space or the end). Split
36
+ * at the first `=` or `:` by hand: `^([^=]*?)\s*=` tried a run of spaces at every position, quadratic on a long line.
37
+ */
38
+ function keyValue(line, toml) {
39
+ const at = line.indexOf(toml ? '=' : ':');
40
+ if (at <= 0 || (toml ? /[\s=#[]/ : /[\s:#-]/).test(line[0]))
41
+ return null;
42
+ const value = line.slice(at + 1);
43
+ if (!toml && value && !/^\s/.test(value))
44
+ return null;
45
+ return [line.slice(0, at).trimEnd(), value];
46
+ }
34
47
  /** YAML (`---`) or TOML (`+++`) front matter, flat fields only: scalars, lists (joined with `, `) and block text. */
35
48
  export function frontMatter(text) {
36
49
  const m = /^(---|\+\+\+)[ \t]*\r?\n([\s\S]*?)\r?\n(?:---|\+\+\+|\.\.\.)[ \t]*(?:\r?\n|$)/.exec(text);
@@ -40,11 +53,11 @@ export function frontMatter(text) {
40
53
  const fields = {};
41
54
  const lines = m[2].split(/\r?\n/);
42
55
  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]);
56
+ const kv = keyValue(lines[i], toml);
44
57
  if (!kv)
45
58
  continue;
46
- const key = kv[1].trim().replace(/^["']|["']$/g, '');
47
- const value = (kv[2] ?? '').trim();
59
+ const key = kv[0].trim().replace(/^["']|["']$/g, '');
60
+ const value = kv[1].trim();
48
61
  if (value.startsWith('[') && value.endsWith(']')) {
49
62
  fields[key] = listValue(value.slice(1, -1).split(','));
50
63
  }
@@ -64,24 +77,54 @@ export function frontMatter(text) {
64
77
  fields[key] = listValue(items);
65
78
  }
66
79
  else {
67
- fields[key] = scalar(value.replace(/\s+#.*$/, ''));
80
+ const comment = value.search(/\s#/); // not /\s+#.*$/: quadratic on a long run of spaces
81
+ fields[key] = scalar(comment < 0 ? value : value.slice(0, comment));
68
82
  }
69
83
  }
70
84
  return { fields, body: text.slice(m[0].length) };
71
85
  }
86
+ /** 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. */
87
+ const TITLE_SCAN = 64 * 1024;
88
+ /** An RST underline (or overline): one punctuation character, three or more times, then spaces at most. */
89
+ function rstRule(line) {
90
+ const t = line?.trimEnd() ?? ''; // not /[ \t]+$/: quadratic on a long run of spaces
91
+ return t.length >= 3 && '=-~^"\'`#*+'.includes(t[0]) && t === t[0].repeat(t.length);
92
+ }
72
93
  function titleOf(ext, body, raw) {
73
94
  if (ext === '.html' || ext === '.htm' || ext === '.xhtml')
74
95
  return htmlTitle(raw);
75
- if (ext === '.org')
76
- return /^#\+title:[ \t]*(.+)$/im.exec(body)?.[1].trim() || /^\*+[ \t]+(.+)$/m.exec(body)?.[1].trim();
96
+ const lines = body.slice(0, TITLE_SCAN).split(/\r\n?|\n/);
97
+ const first = (f) => {
98
+ for (let i = 0; i < lines.length; i++) {
99
+ const t = f(lines[i], i);
100
+ if (t !== undefined)
101
+ return t;
102
+ }
103
+ return undefined;
104
+ };
105
+ if (ext === '.org') {
106
+ return first((l) => (/^#\+title:/i.test(l) && l.length > 8 ? l.slice(8).trim() : undefined)) ||
107
+ first((l) => (/^\*+[ \t]/.test(l) && l.replace(/^\*+/, '').trim() ? l.replace(/^\*+/, '').trim() : undefined));
108
+ }
77
109
  if (ext === '.rst')
78
- return /^(?:([=\-~^"'`#*+])\1+\r?\n)?[ \t]*(\S[^\n]*?)[ \t]*\r?\n([=\-~^"'`#*+])\3{2,}[ \t]*$/m.exec(body)?.[2];
110
+ return first((l, i) => (l.trim() && rstRule(lines[i + 1]) ? l.trim() : undefined));
79
111
  if (ext === '.adoc' || ext === '.asciidoc')
80
- return /^=[ \t]+(.+)$/m.exec(body)?.[1].trim();
81
- const atx = /^#[ \t]+(.+?)(?:[ \t]+#+)?[ \t]*$/m.exec(body)?.[1].trim();
112
+ return first((l) => (/^=[ \t]/.test(l) && l.slice(1).trim() ? l.slice(1).trim() : undefined));
113
+ // `# Title`, a closing run of `#` after a space dropped (by hand: `[ \t]+#+$` is quadratic on a run of spaces).
114
+ const atx = first((l) => {
115
+ if (!/^#[ \t]/.test(l))
116
+ return undefined;
117
+ const t = l.slice(1).trim();
118
+ let j = t.length;
119
+ while (j > 0 && t[j - 1] === '#')
120
+ j--;
121
+ const title = j > 0 && j < t.length && /[ \t]/.test(t[j - 1]) ? t.slice(0, j).trimEnd() : t;
122
+ return title || undefined;
123
+ });
82
124
  if (atx)
83
125
  return atx;
84
- return /^(\S[^\n]*?)[ \t]*\r?\n=+[ \t]*$/m.exec(body)?.[1]; // a Setext `Title\n=====`
126
+ // A Setext `Title\n=====`.
127
+ return first((l, i) => (/^\S/.test(l) && /^=+[ \t]*$/.test(lines[i + 1] ?? '') ? l.trimEnd() : undefined));
85
128
  }
86
129
  async function* walk(root, rel, o) {
87
130
  const entries = await readdir(join(root, rel), { withFileTypes: true });
@@ -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) => {
@@ -148,7 +148,10 @@ function parseParams(raw) {
148
148
  function quotedPrintable(lines) {
149
149
  let s = '';
150
150
  for (const [i, raw] of lines.entries()) {
151
- const line = raw.replace(/[ \t\r]+$/, '');
151
+ let end = raw.length;
152
+ while (end > 0 && (raw[end - 1] === ' ' || raw[end - 1] === '\t' || raw[end - 1] === '\r'))
153
+ end--; // /[ \t\r]+$/ is quadratic on a run of spaces
154
+ const line = raw.slice(0, end);
152
155
  if (line.endsWith('='))
153
156
  s += line.slice(0, -1);
154
157
  else
@@ -257,12 +260,24 @@ function ids(raw) {
257
260
  const bracketed = [...v.matchAll(/<([^<>\s]+)>/g)].map((m) => m[1]);
258
261
  return bracketed.length ? bracketed : v.split(/\s+/).filter((x) => x.includes('@'));
259
262
  }
263
+ /** `Name <address>` → Name, `address (Name)` → Name. By hand: `^\s*(\S+@\S+)\s*\(` is quadratic on `a@a@a@…`. */
260
264
  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();
265
+ const t = from.trim();
266
+ const lt = t.lastIndexOf('<');
267
+ if (t.endsWith('>') && lt >= 0 && !t.slice(lt + 1, -1).includes('>')) {
268
+ return t.slice(0, lt).trim().replace(/^"(.*)"$/, '$1').trim() || t.slice(lt + 1, -1).trim();
269
+ }
270
+ const open = t.indexOf('(');
271
+ const address = open > 0 ? t.slice(0, open).trim() : '';
272
+ if (t.endsWith(')') && t.length - open > 2 && /^\S+$/.test(address) && address.indexOf('@') > 0 && !address.endsWith('@')) {
273
+ return t.slice(open + 1, -1).trim();
274
+ }
275
+ return t;
276
+ }
277
+ /** `(comments)` taken out of a header, up to its last `)`: past it, each `(` would scan to the end and fail. */
278
+ function dropComments(v) {
279
+ const end = v.lastIndexOf(')') + 1;
280
+ return v.slice(0, end).replace(/\([^)]*\)/g, ' ') + v.slice(end);
266
281
  }
267
282
  /** One message's lines (raw bytes as Latin-1 characters, no line endings) as a record, or null when it says nothing. */
268
283
  function messageRecord(lines, fromLine, source, opts) {
@@ -277,7 +292,7 @@ function messageRecord(lines, fromLine, source, opts) {
277
292
  if (!text && !subject)
278
293
  return null;
279
294
  const from = h('from');
280
- const date = h('date').replace(/\([^)]*\)/g, ' ').trim();
295
+ const date = dropComments(h('date')).trim();
281
296
  const at = timeOf(date) ?? timeOf(fromLine.replace(/^From \S+\s+/, '').trim());
282
297
  const own = ids(e.headers.get('message-id'))[0];
283
298
  const id = own ?? createHash('sha256').update([from, date, subject, text.slice(0, 4000)].join('\u0000')).digest('hex').slice(0, 32);
@@ -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;
@@ -41,7 +34,7 @@ export interface LlmUsage {
41
34
  outputTokens: number;
42
35
  }
43
36
  export interface OpenAiAskOptions {
44
- /** Default: $MEMTRAILS_LLM_API_KEY, else $OPENAI_API_KEY. */
37
+ /** Default: $MEMTRAILS_LLM_API_KEY, else $OPENAI_API_KEY — the latter only for OpenAI's own address. */
45
38
  apiKey?: string;
46
39
  /** Default: $MEMTRAILS_MODEL. */
47
40
  model?: string;
package/dist/llm.js CHANGED
@@ -5,16 +5,19 @@
5
5
  * `openAiAsk` speaks the OpenAI Responses API, so any provider or local server that serves it works (`baseUrl`). Bring
6
6
  * any other model by passing your own `Ask`. Every call's tokens can be counted (`onUsage`).
7
7
  */
8
+ import { redactString } from './core/redact.js';
9
+ const OPENAI = 'https://api.openai.com/v1';
8
10
  const why = (reason, req) => `${reason}${req.consequence ? `, ${req.consequence}` : ''}`;
9
11
  export function openAiAsk(o = {}) {
10
12
  return async (req) => {
11
13
  const model = req.model ?? o.model ?? process.env.MEMTRAILS_MODEL?.trim();
12
- const key = o.apiKey ?? (process.env.MEMTRAILS_LLM_API_KEY?.trim() || process.env.OPENAI_API_KEY?.trim());
14
+ const base = (o.baseUrl ?? (process.env.MEMTRAILS_LLM_BASE_URL?.trim() || OPENAI)).replace(/\/+$/, '');
15
+ // An OpenAI key goes to OpenAI only: with another address set, it would hand the key to whoever serves that.
16
+ const key = o.apiKey ?? (process.env.MEMTRAILS_LLM_API_KEY?.trim() || (base === OPENAI ? process.env.OPENAI_API_KEY?.trim() : undefined));
13
17
  if (!model)
14
18
  return { unavailable: why('no model is set (the model option, or MEMTRAILS_MODEL)', req) };
15
19
  if (!key)
16
- return { unavailable: why('no API key is set (the apiKey option, MEMTRAILS_LLM_API_KEY or OPENAI_API_KEY)', req) };
17
- const base = (o.baseUrl ?? (process.env.MEMTRAILS_LLM_BASE_URL?.trim() || 'https://api.openai.com/v1')).replace(/\/+$/, '');
20
+ return { unavailable: why(`no API key is set (the apiKey option or MEMTRAILS_LLM_API_KEY${base === OPENAI ? ', or OPENAI_API_KEY' : ''})`, req) };
18
21
  const body = JSON.stringify({
19
22
  model, instructions: req.system, input: req.prompt, max_output_tokens: req.maxTokens ?? 12_000,
20
23
  ...(o.reasoningEffort ? { reasoning: { effort: o.reasoningEffort } } : {}),
@@ -40,8 +43,11 @@ export function openAiAsk(o = {}) {
40
43
  last = `HTTP ${res.status}`;
41
44
  continue;
42
45
  }
43
- if (!res.ok)
44
- return { unavailable: why(`the model answered HTTP ${res.status}`, req) };
46
+ if (!res.ok) {
47
+ // What the provider said (a wrong model name, a schema it refuses) — not only its status.
48
+ const said = redactString((await res.text().catch(() => '')).replace(/\s+/g, ' ').trim()).slice(0, 300);
49
+ return { unavailable: why(`the model answered HTTP ${res.status}${said ? `: ${said}` : ''}`, req) };
50
+ }
45
51
  try {
46
52
  const r = (await res.json());
47
53
  o.onUsage?.({ model: r.model ?? model, name: req.name, inputTokens: r.usage?.input_tokens ?? 0,
package/dist/memory.d.ts CHANGED
@@ -14,14 +14,16 @@ export interface MemoryOptions {
14
14
  * - default: on this machine, the model fetched on first use into the cache (218 MB);
15
15
  * - `{ modelDir }`: on this machine, from a directory you provide;
16
16
  * - `{ server }`: a server that serves it over the OpenAI embeddings API (text-embeddings-inference, Ollama, …).
17
- * Word by word then needs `{ modelDir }` too, or it is off. */
17
+ * Word by word then needs `{ modelDir }` too, or it is off.
18
+ * With neither `server` nor `modelDir`, MEMTRAILS_EMBED_URL (+ _KEY, _MODEL) names a server when it is set. */
18
19
  embedding?: {
19
20
  modelDir?: string;
20
21
  server?: EmbedRoute;
21
22
  threads?: number;
22
23
  };
23
24
  /** How the best 30 of a search are reordered: `'words'` (default with a local model: word by word, no server), a
24
- * cross-encoder server (`RerankRoute`), your own function, or `false` for the search's own order. */
25
+ * cross-encoder server (`RerankRoute`), your own function, or `false` for the search's own order. Not given:
26
+ * MEMTRAILS_RERANK_URL (+ _KEY, _MODEL) names a server when it is set. */
25
27
  rerank?: 'words' | RerankRoute | RecallDeps['rerank'] | false;
26
28
  /** The language model for consolidate, sleep and gather: your own `Ask`, or options for the OpenAI-compatible one.
27
29
  * Default: the one the environment names (MEMTRAILS_MODEL and a key). */
@@ -84,7 +86,9 @@ export interface Memory {
84
86
  limit?: number;
85
87
  }): Promise<Explored>;
86
88
  /** A set for a question that counts, lists or totals: a wide search, then the language model keeps every instance. */
87
- gather(q: SearchQuery): Promise<Gathered>;
89
+ gather(q: SearchQuery, o?: {
90
+ notes?: boolean;
91
+ }): Promise<Gathered>;
88
92
  /** Read what was added since last time and keep typed knowledge, dated observations and kept episodes. Needs a
89
93
  * language model. Returns how many windows were read. */
90
94
  consolidate(): Promise<ConsolidationReport>;
@@ -101,6 +105,8 @@ export interface Memory {
101
105
  items: number;
102
106
  words: number;
103
107
  };
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. */
104
110
  close(): Promise<void>;
105
111
  }
106
112
  export declare function openMemory(o: MemoryOptions): Promise<Memory>;