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.
- package/CHANGELOG.md +39 -0
- package/README.md +20 -13
- package/dist/core/consolidate.d.ts +23 -8
- package/dist/core/consolidate.js +52 -25
- package/dist/core/episodes.d.ts +1 -1
- package/dist/core/episodes.js +2 -8
- package/dist/core/extract.js +5 -5
- package/dist/core/gather.d.ts +7 -0
- package/dist/core/gather.js +38 -14
- package/dist/core/model.d.ts +8 -4
- package/dist/core/model.js +29 -40
- package/dist/core/profiles.d.ts +1 -1
- package/dist/core/profiles.js +7 -6
- package/dist/core/qdrant-binary.d.ts +39 -5
- package/dist/core/qdrant-binary.js +185 -48
- package/dist/core/qdrant-client.d.ts +6 -18
- package/dist/core/qdrant-client.js +38 -90
- package/dist/core/qdrant-process.d.ts +22 -1
- package/dist/core/qdrant-process.js +190 -22
- package/dist/core/rebuild.d.ts +5 -1
- package/dist/core/rebuild.js +19 -4
- package/dist/core/recall-log.d.ts +3 -0
- package/dist/core/recall-log.js +6 -1
- package/dist/core/recall.js +14 -5
- 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 +19 -11
- package/dist/core/store.d.ts +7 -1
- package/dist/core/store.js +101 -33
- package/dist/core/text.d.ts +4 -2
- package/dist/core/text.js +17 -5
- 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 +53 -10
- package/dist/importers/git.js +3 -1
- package/dist/importers/mbox.js +22 -7
- package/dist/importers/slack.js +5 -2
- package/dist/importers/text.js +85 -14
- package/dist/llm.d.ts +1 -8
- package/dist/llm.js +11 -5
- package/dist/memory.d.ts +9 -3
- package/dist/memory.js +216 -48
- 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/core/writer.d.ts
CHANGED
|
@@ -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;
|
package/dist/core/writer.js
CHANGED
|
@@ -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
|
-
|
|
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',
|
|
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)
|
package/dist/importers/files.js
CHANGED
|
@@ -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 =
|
|
56
|
+
const kv = keyValue(lines[i], toml);
|
|
44
57
|
if (!kv)
|
|
45
58
|
continue;
|
|
46
|
-
const key = kv[
|
|
47
|
-
const value =
|
|
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
|
-
|
|
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
|
-
|
|
76
|
-
|
|
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
|
|
110
|
+
return first((l, i) => (l.trim() && rstRule(lines[i + 1]) ? l.trim() : undefined));
|
|
79
111
|
if (ext === '.adoc' || ext === '.asciidoc')
|
|
80
|
-
return /^=[ \t]
|
|
81
|
-
|
|
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
|
-
|
|
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 });
|
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) => {
|
package/dist/importers/mbox.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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')
|
|
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);
|
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;
|
|
@@ -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
|
|
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(
|
|
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
|
-
|
|
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
|
|
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>;
|