memtrails 0.0.0-stage → 0.2.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 (76) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/LICENSE +21 -0
  3. package/README.md +221 -2
  4. package/dist/core/consolidate.d.ts +144 -0
  5. package/dist/core/consolidate.js +660 -0
  6. package/dist/core/embedder.d.ts +30 -0
  7. package/dist/core/embedder.js +137 -0
  8. package/dist/core/episodes.d.ts +32 -0
  9. package/dist/core/episodes.js +53 -0
  10. package/dist/core/extract.d.ts +142 -0
  11. package/dist/core/extract.js +452 -0
  12. package/dist/core/gather.d.ts +17 -0
  13. package/dist/core/gather.js +98 -0
  14. package/dist/core/graph.d.ts +48 -0
  15. package/dist/core/graph.js +208 -0
  16. package/dist/core/local-embedder.d.ts +40 -0
  17. package/dist/core/local-embedder.js +98 -0
  18. package/dist/core/model.d.ts +16 -0
  19. package/dist/core/model.js +74 -0
  20. package/dist/core/paths.d.ts +1 -0
  21. package/dist/core/paths.js +7 -0
  22. package/dist/core/profiles.d.ts +30 -0
  23. package/dist/core/profiles.js +147 -0
  24. package/dist/core/qdrant-binary.d.ts +25 -0
  25. package/dist/core/qdrant-binary.js +108 -0
  26. package/dist/core/qdrant-client.d.ts +115 -0
  27. package/dist/core/qdrant-client.js +327 -0
  28. package/dist/core/qdrant-process.d.ts +30 -0
  29. package/dist/core/qdrant-process.js +160 -0
  30. package/dist/core/rebuild.d.ts +31 -0
  31. package/dist/core/rebuild.js +109 -0
  32. package/dist/core/recall-log.d.ts +29 -0
  33. package/dist/core/recall-log.js +82 -0
  34. package/dist/core/recall.d.ts +91 -0
  35. package/dist/core/recall.js +172 -0
  36. package/dist/core/redact.d.ts +2 -0
  37. package/dist/core/redact.js +48 -0
  38. package/dist/core/reranker.d.ts +26 -0
  39. package/dist/core/reranker.js +67 -0
  40. package/dist/core/room.d.ts +20 -0
  41. package/dist/core/room.js +63 -0
  42. package/dist/core/sleep.d.ts +44 -0
  43. package/dist/core/sleep.js +199 -0
  44. package/dist/core/store.d.ts +198 -0
  45. package/dist/core/store.js +190 -0
  46. package/dist/core/text.d.ts +31 -0
  47. package/dist/core/text.js +101 -0
  48. package/dist/core/words.d.ts +32 -0
  49. package/dist/core/words.js +127 -0
  50. package/dist/core/writer.d.ts +36 -0
  51. package/dist/core/writer.js +146 -0
  52. package/dist/importers/files.d.ts +31 -0
  53. package/dist/importers/files.js +157 -0
  54. package/dist/importers/git.d.ts +30 -0
  55. package/dist/importers/git.js +118 -0
  56. package/dist/importers/index.d.ts +14 -0
  57. package/dist/importers/index.js +26 -0
  58. package/dist/importers/jsonl.d.ts +20 -0
  59. package/dist/importers/jsonl.js +65 -0
  60. package/dist/importers/mbox.d.ts +20 -0
  61. package/dist/importers/mbox.js +347 -0
  62. package/dist/importers/slack.d.ts +21 -0
  63. package/dist/importers/slack.js +134 -0
  64. package/dist/importers/text.d.ts +31 -0
  65. package/dist/importers/text.js +191 -0
  66. package/dist/index.d.ts +28 -0
  67. package/dist/index.js +22 -0
  68. package/dist/llm.d.ts +60 -0
  69. package/dist/llm.js +60 -0
  70. package/dist/memory.d.ts +106 -0
  71. package/dist/memory.js +262 -0
  72. package/dist/records.d.ts +62 -0
  73. package/dist/records.js +107 -0
  74. package/dist/tools/agent-tools.d.ts +52 -0
  75. package/dist/tools/agent-tools.js +142 -0
  76. package/package.json +78 -4
@@ -0,0 +1,20 @@
1
+ import { type MemoryRecord } from '../records.js';
2
+ export interface MboxOptions {
3
+ /** Keep lines quoted from earlier messages (`> …`). */
4
+ keepQuoted?: boolean;
5
+ /** Keep the signature (what follows a `-- ` line). */
6
+ keepSignature?: boolean;
7
+ /** Default: the file's name without its extension. */
8
+ source?: string;
9
+ collection?: string;
10
+ /** A message's body past this many bytes is not read (its headers are). Default 32 MiB. */
11
+ maxMessageBytes?: number;
12
+ /** Called for each message left out (no subject and no text), with its place in the file. */
13
+ onSkip?: (reason: string, index: number) => void;
14
+ }
15
+ /** RFC 2047 encoded-words decoded; words side by side join with no space, and their bytes are read together. */
16
+ export declare function decodeEncodedWords(s: string): string;
17
+ /** Records from an mbox file, a message at a time. */
18
+ export declare function fromMbox(path: string, opts?: MboxOptions): AsyncIterable<MemoryRecord>;
19
+ /** The record of one `.eml` file (an RFC 5322 message). */
20
+ export declare function fromEml(path: string, opts?: MboxOptions): AsyncIterable<MemoryRecord>;
@@ -0,0 +1,347 @@
1
+ /**
2
+ * E-mail: an mbox file (RFC 4155: each message opens with a `From ` line; `>From ` in a body is unescaped), read a
3
+ * message at a time, or one `.eml` file. Headers are unfolded and their RFC 2047 encoded-words (Q and B, any charset)
4
+ * decoded; MIME parts are taken apart (text/plain preferred, else text/html reduced to text), base64 and
5
+ * quoted-printable decoded, and every charset the platform knows read as such.
6
+ *
7
+ * id: the Message-ID (else a hash of the message). title: the Subject. text: the body, with the names of attachments
8
+ * after it; lines quoted from earlier messages (`>`) and the signature (after `-- `) are left out unless `keepQuoted` /
9
+ * `keepSignature`, since the earlier message is a record of its own. author: the sender's name, else address. at: the
10
+ * Date. kind `email`. thread: the first id in References, else In-Reply-To, else the message's own id.
11
+ * metadata { from, to, cc, inReplyTo }.
12
+ */
13
+ import { createHash } from 'node:crypto';
14
+ import { createReadStream } from 'node:fs';
15
+ import { readFile } from 'node:fs/promises';
16
+ import { basename, extname } from 'node:path';
17
+ import { createInterface } from 'node:readline';
18
+ import { timeOf } from '../records.js';
19
+ import { decodeBytes, htmlToText, record } from './text.js';
20
+ function entityOf(lines) {
21
+ const headers = new Map();
22
+ let i = 0;
23
+ let name = '';
24
+ for (; i < lines.length; i++) {
25
+ const line = lines[i];
26
+ if (line === '' || line === '\r')
27
+ break;
28
+ if (/^[ \t]/.test(line)) {
29
+ if (name)
30
+ headers.set(name, `${headers.get(name)}${line.replace(/^[ \t]+/, ' ')}`);
31
+ continue;
32
+ }
33
+ const colon = line.indexOf(':');
34
+ if (colon <= 0)
35
+ continue;
36
+ const key = line.slice(0, colon).trim().toLowerCase();
37
+ if (headers.has(key)) {
38
+ name = ''; // a later copy of a header: the first is kept
39
+ continue;
40
+ }
41
+ name = key;
42
+ headers.set(key, line.slice(colon + 1).trim());
43
+ }
44
+ return { headers, body: lines.slice(i + 1) };
45
+ }
46
+ function qBytes(s) {
47
+ const out = [];
48
+ for (let i = 0; i < s.length; i++) {
49
+ if (s[i] === '_')
50
+ out.push(0x20);
51
+ else if (s[i] === '=' && /^[0-9A-Fa-f]{2}$/.test(s.slice(i + 1, i + 3))) {
52
+ out.push(parseInt(s.slice(i + 1, i + 3), 16));
53
+ i += 2;
54
+ }
55
+ else
56
+ out.push(s.charCodeAt(i) & 0xff);
57
+ }
58
+ return out;
59
+ }
60
+ /** RFC 2047 encoded-words decoded; words side by side join with no space, and their bytes are read together. */
61
+ export function decodeEncodedWords(s) {
62
+ let out = '';
63
+ let last = 0;
64
+ let pending = null;
65
+ const flush = () => {
66
+ if (pending)
67
+ out += decodeBytes(Uint8Array.from(pending.bytes), pending.charset);
68
+ pending = null;
69
+ };
70
+ for (const m of s.matchAll(/=\?([^?\s]+)\?([bBqQ])\?([^?\s]*)\?=/g)) {
71
+ const gap = s.slice(last, m.index);
72
+ if (!pending || !/^\s*$/.test(gap)) {
73
+ flush();
74
+ out += gap;
75
+ }
76
+ const charset = m[1].replace(/\*.*$/, '').toLowerCase();
77
+ const bytes = m[2].toUpperCase() === 'B' ? [...Buffer.from(m[3], 'base64')] : qBytes(m[3]);
78
+ if (pending && pending.charset !== charset)
79
+ flush();
80
+ if (pending)
81
+ pending.bytes.push(...bytes);
82
+ else
83
+ pending = { charset, bytes };
84
+ last = m.index + m[0].length;
85
+ }
86
+ flush();
87
+ return out + s.slice(last);
88
+ }
89
+ /** A raw header as text: bytes sent unencoded read as UTF-8 (else Windows-1252), then encoded-words decoded. */
90
+ function headerText(raw) {
91
+ if (!raw)
92
+ return '';
93
+ return decodeEncodedWords(decodeBytes(Buffer.from(raw, 'latin1'))).replace(/\s+/g, ' ').trim();
94
+ }
95
+ /** `type/sub; a=1; b="x y"` as its value and parameters (RFC 2231 continued and encoded ones joined and decoded). */
96
+ function parseParams(raw) {
97
+ const pieces = [];
98
+ let cur = '';
99
+ let quoted = false;
100
+ for (const ch of raw ?? '') {
101
+ if (ch === '"')
102
+ quoted = !quoted;
103
+ if (ch === ';' && !quoted) {
104
+ pieces.push(cur);
105
+ cur = '';
106
+ }
107
+ else
108
+ cur += ch;
109
+ }
110
+ pieces.push(cur);
111
+ const value = (pieces.shift() ?? '').trim().toLowerCase();
112
+ const params = {};
113
+ const sections = {};
114
+ for (const p of pieces) {
115
+ const eq = p.indexOf('=');
116
+ if (eq <= 0)
117
+ continue;
118
+ const key = p.slice(0, eq).trim().toLowerCase();
119
+ let v = p.slice(eq + 1).trim();
120
+ if (v.startsWith('"') && v.endsWith('"') && v.length > 1)
121
+ v = v.slice(1, -1).replace(/\\(.)/g, '$1');
122
+ const ext = /^(.+?)\*(\d+)?(\*)?$/.exec(key);
123
+ if (ext)
124
+ (sections[ext[1]] ??= []).push({ n: Number(ext[2] ?? 0), encoded: ext[2] === undefined || !!ext[3], v });
125
+ else
126
+ params[key] = key === 'name' || key === 'filename' ? headerText(v) : v;
127
+ }
128
+ for (const [key, list] of Object.entries(sections)) {
129
+ list.sort((a, b) => a.n - b.n);
130
+ let charset = '';
131
+ const bytes = [];
132
+ for (const [i, s] of list.entries()) {
133
+ let v = s.v;
134
+ if (s.encoded && i === 0) {
135
+ const m = /^([^']*)'[^']*'(.*)$/.exec(v);
136
+ if (m)
137
+ [charset, v] = [m[1], m[2]];
138
+ }
139
+ if (s.encoded)
140
+ bytes.push(...Buffer.from(v.replace(/%([0-9A-Fa-f]{2})/g, (_, h) => String.fromCharCode(parseInt(h, 16))), 'latin1'));
141
+ else
142
+ bytes.push(...Buffer.from(v, 'latin1'));
143
+ }
144
+ params[key] = decodeBytes(Uint8Array.from(bytes), charset);
145
+ }
146
+ return { value, params };
147
+ }
148
+ function quotedPrintable(lines) {
149
+ let s = '';
150
+ for (const [i, raw] of lines.entries()) {
151
+ const line = raw.replace(/[ \t\r]+$/, '');
152
+ if (line.endsWith('='))
153
+ s += line.slice(0, -1);
154
+ else
155
+ s += i < lines.length - 1 ? `${line}\n` : line;
156
+ }
157
+ return Buffer.from(s.replace(/=([0-9A-Fa-f]{2})/g, (_, h) => String.fromCharCode(parseInt(h, 16))), 'latin1');
158
+ }
159
+ function bodyBytes(e) {
160
+ const cte = (e.headers.get('content-transfer-encoding') ?? '').trim().toLowerCase();
161
+ if (cte === 'base64')
162
+ return Buffer.from(e.body.join(''), 'base64');
163
+ if (cte === 'quoted-printable')
164
+ return quotedPrintable(e.body);
165
+ return Buffer.from(e.body.join('\n'), 'latin1');
166
+ }
167
+ /** `format=flowed` (RFC 3676) text with its soft line breaks joined. */
168
+ function unflow(text, delsp) {
169
+ const out = [];
170
+ let cur = null;
171
+ for (const raw of text.split('\n')) {
172
+ const quote = /^>+/.exec(raw)?.[0] ?? '';
173
+ let line = raw.slice(quote.length);
174
+ if (line.startsWith(' '))
175
+ line = line.slice(1);
176
+ const soft = line.endsWith(' ') && line !== '-- ';
177
+ if (soft && delsp)
178
+ line = line.slice(0, -1);
179
+ cur = cur === null ? (quote ? `${quote} ${line}` : line) : cur + line;
180
+ if (!soft) {
181
+ out.push(cur);
182
+ cur = null;
183
+ }
184
+ }
185
+ if (cur !== null)
186
+ out.push(cur);
187
+ return out.join('\n');
188
+ }
189
+ function clean(text, html, opts) {
190
+ let lines = text.split('\n');
191
+ if (!opts.keepSignature) {
192
+ const sig = lines.findIndex((l) => l === '-- ' || (html && l === '--'));
193
+ if (sig >= 0)
194
+ lines = lines.slice(0, sig);
195
+ }
196
+ if (!opts.keepQuoted)
197
+ lines = lines.filter((l) => !/^ ?>/.test(l));
198
+ return lines.map((l) => (l === '-- ' ? l : l.trimEnd())).join('\n').replace(/\n{3,}/g, '\n\n').trim();
199
+ }
200
+ function contentOf(e, opts, depth) {
201
+ const type = parseParams(e.headers.get('content-type') ?? 'text/plain');
202
+ const disposition = parseParams(e.headers.get('content-disposition'));
203
+ const name = disposition.params.filename ?? type.params.name;
204
+ const attached = disposition.value === 'attachment';
205
+ const mime = type.value || 'text/plain';
206
+ if (mime.startsWith('multipart/') && type.params.boundary && depth < 30) {
207
+ const kids = splitParts(e.body, type.params.boundary).map((k) => contentOf(k, opts, depth + 1));
208
+ const attachments = kids.flatMap((k) => k.attachments);
209
+ if (mime === 'multipart/alternative') {
210
+ const pick = kids.find((k) => k.text && !k.html) ?? kids.find((k) => k.text);
211
+ return { text: pick?.text ?? '', html: pick?.html ?? false, attachments: [...new Set(attachments)] };
212
+ }
213
+ const said = kids.filter((k) => k.text);
214
+ return { text: said.map((k) => k.text).join('\n\n'), html: said.length > 0 && said.every((k) => k.html), attachments };
215
+ }
216
+ if (mime === 'message/rfc822' && depth < 30) {
217
+ const inner = entityOf(bodyBytes(e).toString('latin1').split(/\r?\n/));
218
+ const c = contentOf(inner, opts, depth + 1);
219
+ const head = [['From', 'from'], ['Date', 'date'], ['Subject', 'subject']]
220
+ .map(([label, key]) => (inner.headers.has(key) ? `${label}: ${headerText(inner.headers.get(key))}` : ''))
221
+ .filter(Boolean);
222
+ return { text: [...head, '', c.text].join('\n').trim(), html: false, attachments: c.attachments };
223
+ }
224
+ if (!attached && (mime === 'text/plain' || mime === 'text/html' || mime === 'text/markdown')) {
225
+ const raw = decodeBytes(bodyBytes(e), type.params.charset).replace(/\r\n?/g, '\n');
226
+ if (mime === 'text/html')
227
+ return { text: clean(htmlToText(raw, { dropQuotes: !opts.keepQuoted }), true, opts), html: true, attachments: [] };
228
+ const flowed = type.params.format?.toLowerCase() === 'flowed';
229
+ const text = flowed ? unflow(raw, type.params.delsp?.toLowerCase() === 'yes') : raw;
230
+ return { text: clean(text, false, opts), html: false, attachments: [] };
231
+ }
232
+ return { text: '', html: false, attachments: name ? [name] : [] };
233
+ }
234
+ function splitParts(body, boundary) {
235
+ const open = `--${boundary}`;
236
+ const close = `--${boundary}--`;
237
+ const parts = [];
238
+ let cur = null;
239
+ for (const line of body) {
240
+ const t = line.trimEnd();
241
+ if (t === close)
242
+ break;
243
+ if (t === open) {
244
+ if (cur)
245
+ parts.push(cur);
246
+ cur = [];
247
+ }
248
+ else if (cur)
249
+ cur.push(line);
250
+ }
251
+ if (cur)
252
+ parts.push(cur);
253
+ return parts.map((p) => (p[0] === '' ? { headers: new Map(), body: p.slice(1) } : entityOf(p)));
254
+ }
255
+ function ids(raw) {
256
+ const v = raw ?? '';
257
+ const bracketed = [...v.matchAll(/<([^<>\s]+)>/g)].map((m) => m[1]);
258
+ return bracketed.length ? bracketed : v.split(/\s+/).filter((x) => x.includes('@'));
259
+ }
260
+ 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();
266
+ }
267
+ /** One message's lines (raw bytes as Latin-1 characters, no line endings) as a record, or null when it says nothing. */
268
+ function messageRecord(lines, fromLine, source, opts) {
269
+ while (lines.length && !lines[lines.length - 1].trim())
270
+ lines.pop();
271
+ const e = entityOf(lines);
272
+ const h = (key) => headerText(e.headers.get(key));
273
+ const content = contentOf(e, opts, 0);
274
+ const attachments = [...new Set(content.attachments)];
275
+ const text = [content.text, attachments.length ? `Attachments: ${attachments.join(', ')}` : ''].filter(Boolean).join('\n\n');
276
+ const subject = h('subject');
277
+ if (!text && !subject)
278
+ return null;
279
+ const from = h('from');
280
+ const date = h('date').replace(/\([^)]*\)/g, ' ').trim();
281
+ const at = timeOf(date) ?? timeOf(fromLine.replace(/^From \S+\s+/, '').trim());
282
+ const own = ids(e.headers.get('message-id'))[0];
283
+ const id = own ?? createHash('sha256').update([from, date, subject, text.slice(0, 4000)].join('\u0000')).digest('hex').slice(0, 32);
284
+ const inReplyTo = ids(e.headers.get('in-reply-to'))[0];
285
+ return record({
286
+ id, title: subject, text, kind: 'email', source, collection: opts.collection, author: from ? personOf(from) : undefined, at,
287
+ thread: ids(e.headers.get('references'))[0] ?? inReplyTo ?? id,
288
+ metadata: { from, to: h('to'), cc: h('cc'), inReplyTo },
289
+ });
290
+ }
291
+ /** Records from an mbox file, a message at a time. */
292
+ export async function* fromMbox(path, opts = {}) {
293
+ const source = opts.source ?? basename(path, extname(path));
294
+ 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
+ let msg = null;
298
+ let fromLine = '';
299
+ let size = 0;
300
+ let previousBlank = true;
301
+ let index = 0;
302
+ const finish = () => {
303
+ if (!msg)
304
+ return null;
305
+ const r = messageRecord(msg, fromLine, source, opts);
306
+ if (!r)
307
+ opts.onSkip?.('no subject and no text', index);
308
+ index++;
309
+ return r;
310
+ };
311
+ for await (const line of lines) {
312
+ // 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
+ if (line.startsWith('From ') && previousBlank && (msg === null || /\d:\d\d/.test(line))) {
314
+ const r = finish();
315
+ if (r)
316
+ yield r;
317
+ msg = [];
318
+ fromLine = line;
319
+ size = 0;
320
+ previousBlank = false;
321
+ continue;
322
+ }
323
+ previousBlank = line === '' || line === '\r';
324
+ if (msg === null) {
325
+ if (!line.trim())
326
+ continue;
327
+ msg = []; // a file that does not open with a `From ` line: one message from its first line
328
+ }
329
+ size += line.length + 1;
330
+ if (size > max)
331
+ continue;
332
+ msg.push(/^>+From /.test(line) ? line.slice(1) : line);
333
+ }
334
+ const r = finish();
335
+ if (r)
336
+ yield r;
337
+ }
338
+ /** The record of one `.eml` file (an RFC 5322 message). */
339
+ export async function* fromEml(path, opts = {}) {
340
+ const lines = (await readFile(path)).toString('latin1').split(/\r?\n/);
341
+ const fromLine = lines[0]?.startsWith('From ') ? (lines.shift() ?? '') : '';
342
+ const r = messageRecord(lines, fromLine, opts.source ?? basename(path, extname(path)), opts);
343
+ if (r)
344
+ yield r;
345
+ else
346
+ opts.onSkip?.('no subject and no text', 0);
347
+ }
@@ -0,0 +1,21 @@
1
+ import type { MemoryRecord } from '../records.js';
2
+ /** Message subtypes left out by default: events about the channel, not things said in it. */
3
+ export declare const SLACK_SKIPPED_SUBTYPES: string[];
4
+ export interface SlackOptions {
5
+ /** Only these conversations (by name or id). */
6
+ channels?: string[];
7
+ /** Default: the channel's name. */
8
+ collection?: string;
9
+ /** Default: the export folder's name. */
10
+ source?: string;
11
+ /** Subtypes to leave out. Default: SLACK_SKIPPED_SUBTYPES. */
12
+ skipSubtypes?: string[];
13
+ /** Leave out messages posted by bots and integrations. */
14
+ skipBots?: boolean;
15
+ /** The workspace's address (`https://example.slack.com`), for a link to each message. */
16
+ workspaceUrl?: string;
17
+ }
18
+ /** Slack's markup as plain text: mentions as names, channel links as `#name`, links as `label (url)`. */
19
+ export declare function slackText(text: string, users: Map<string, string>, channels: Map<string, string>): string;
20
+ /** Records from an unzipped Slack export folder. */
21
+ export declare function fromSlackExport(dir: string, opts?: SlackOptions): AsyncIterable<MemoryRecord>;
@@ -0,0 +1,134 @@
1
+ /**
2
+ * A Slack workspace export, unzipped: `users.json`, `channels.json` (and `groups.json`, `mpims.json`, `dms.json` when
3
+ * present), and a folder a conversation holding one `YYYY-MM-DD.json` a day. Read a day at a time, in order.
4
+ *
5
+ * One record a message that says something. id: `channel id:ts`. text: the message with `<@U123>` as `@Name`,
6
+ * `<#C1|name>` as `#name`, `<url|label>` as `label (url)`, then the names of its files and attachments. author: the
7
+ * person's real name, else display name, else user name (a bot: its name). at: the message's ts. thread: `channel:ts`
8
+ * of the thread's first message (a message outside a thread starts its own). collection: `opts.collection` or the
9
+ * channel's name. kind `message`. Joins, leaves, topic changes and the like are left out.
10
+ */
11
+ import { readdir, readFile, stat } from 'node:fs/promises';
12
+ import { basename, join, resolve } from 'node:path';
13
+ import { record } from './text.js';
14
+ /** Message subtypes left out by default: events about the channel, not things said in it. */
15
+ export const SLACK_SKIPPED_SUBTYPES = ['channel_join', 'channel_leave', 'channel_topic', 'channel_purpose', 'channel_name',
16
+ 'channel_archive', 'channel_unarchive', 'channel_convert_to_private', 'channel_convert_to_public', 'group_join', 'group_leave',
17
+ 'group_topic', 'group_purpose', 'group_name', 'group_archive', 'group_unarchive', 'pinned_item', 'unpinned_item', 'bot_add',
18
+ 'bot_remove', 'tombstone', 'reminder_add', 'joiner_notification', 'joiner_notification_for_inviter', 'message_deleted',
19
+ 'sh_room_created', 'channel_canvas_updated'];
20
+ async function readJson(path) {
21
+ try {
22
+ return JSON.parse((await readFile(path, 'utf8')).replace(/^/, ''));
23
+ }
24
+ catch (e) {
25
+ if (e.code === 'ENOENT')
26
+ return undefined;
27
+ throw new Error(`cannot read ${path}: ${e.message}`);
28
+ }
29
+ }
30
+ function personName(u) {
31
+ return [u.profile?.real_name, u.real_name, u.profile?.display_name, u.display_name, u.name].map((n) => n?.trim()).find(Boolean);
32
+ }
33
+ /** Slack's markup as plain text: mentions as names, channel links as `#name`, links as `label (url)`. */
34
+ export function slackText(text, users, channels) {
35
+ return text
36
+ .replace(/<([^<>\n]*)>/g, (whole, inner) => {
37
+ const bar = inner.indexOf('|');
38
+ const target = bar >= 0 ? inner.slice(0, bar) : inner;
39
+ const label = bar >= 0 ? inner.slice(bar + 1) : '';
40
+ if (target.startsWith('@'))
41
+ return `@${users.get(target.slice(1)) ?? (label.replace(/^@/, '') || target.slice(1))}`;
42
+ if (target.startsWith('#'))
43
+ return `#${label || channels.get(target.slice(1)) || target.slice(1)}`;
44
+ if (target.startsWith('!')) {
45
+ const special = target.slice(1);
46
+ if (/^(here|channel|everyone)$/.test(special))
47
+ return `@${special}`;
48
+ return label || `@${special.replace(/\^.*$/, '')}`;
49
+ }
50
+ if (!target)
51
+ return whole;
52
+ if (target.startsWith('mailto:'))
53
+ return label || target.slice(7);
54
+ return label && label !== target ? `${label} (${target})` : target;
55
+ })
56
+ .replace(/&lt;/g, '<')
57
+ .replace(/&gt;/g, '>')
58
+ .replace(/&amp;/g, '&');
59
+ }
60
+ /** Records from an unzipped Slack export folder. */
61
+ export async function* fromSlackExport(dir, opts = {}) {
62
+ const root = resolve(dir);
63
+ const source = opts.source ?? basename(root);
64
+ const skip = new Set(opts.skipSubtypes ?? SLACK_SKIPPED_SUBTYPES);
65
+ const users = new Map();
66
+ for (const u of (await readJson(join(root, 'users.json'))) ?? []) {
67
+ const n = personName(u);
68
+ if (u?.id && n)
69
+ users.set(u.id, n);
70
+ }
71
+ // Each conversation: its id, its name, and the folder its days are in (a direct message's folder is its id).
72
+ const conversations = [];
73
+ for (const [file, byId] of [['channels.json', false], ['groups.json', false], ['mpims.json', false], ['dms.json', true]]) {
74
+ for (const c of (await readJson(join(root, file))) ?? []) {
75
+ if (!c?.id)
76
+ continue;
77
+ const name = byId ? (c.members ?? []).map((m) => users.get(m) ?? m).join(', ') || c.id : c.name || c.id;
78
+ conversations.push({ id: c.id, name, folder: byId ? c.id : c.name || c.id });
79
+ }
80
+ }
81
+ if (!conversations.length) {
82
+ // No lists: every folder is a conversation named after it.
83
+ for (const e of (await readdir(root, { withFileTypes: true })).filter((e) => e.isDirectory() && !e.name.startsWith('.'))) {
84
+ conversations.push({ id: e.name, name: e.name, folder: e.name });
85
+ }
86
+ }
87
+ const channelNames = new Map(conversations.map((c) => [c.id, c.name]));
88
+ const wanted = opts.channels ? new Set(opts.channels.map((c) => c.replace(/^#/, ''))) : undefined;
89
+ for (const c of conversations) {
90
+ if (wanted && !wanted.has(c.name) && !wanted.has(c.id))
91
+ continue;
92
+ const folder = join(root, c.folder);
93
+ if (!(await stat(folder).catch(() => undefined))?.isDirectory())
94
+ continue;
95
+ const days = (await readdir(folder)).filter((f) => /^\d{4}-\d{2}-\d{2}\.json$/.test(f)).sort();
96
+ for (const day of days) {
97
+ const messages = (await readJson(join(folder, day))) ?? [];
98
+ if (!Array.isArray(messages))
99
+ continue;
100
+ for (const m of messages) {
101
+ if (!m || typeof m !== 'object' || !m.ts || (m.type && m.type !== 'message') || m.hidden)
102
+ continue;
103
+ if (m.subtype && skip.has(m.subtype))
104
+ continue;
105
+ const bot = m.subtype === 'bot_message' || (!!m.bot_id && !m.user);
106
+ if (bot && opts.skipBots)
107
+ continue;
108
+ const extras = [
109
+ ...(m.files ?? []).map((f) => f?.title || f?.name).filter((t) => !!t?.trim()).map((t) => `File: ${t}`),
110
+ ...(m.attachments ?? []).map((a) => {
111
+ const t = a?.title || a?.fallback;
112
+ if (!t?.trim())
113
+ return '';
114
+ const link = a.title_link || a.from_url;
115
+ return `Attachment: ${slackText(t, users, channelNames)}${link && !t.includes(link) ? ` (${link})` : ''}`;
116
+ }).filter(Boolean),
117
+ ];
118
+ const said = slackText(m.text ?? '', users, channelNames).trim();
119
+ const text = [said, ...extras].filter(Boolean).join('\n');
120
+ if (!text)
121
+ continue;
122
+ const author = (m.user && users.get(m.user)) || (m.user_profile && personName(m.user_profile)) || m.bot_profile?.name ||
123
+ m.username || m.user || undefined;
124
+ const ms = Math.round(Number(m.ts) * 1000);
125
+ yield record({
126
+ id: `${c.id}:${m.ts}`, text, kind: 'message', source, author, collection: opts.collection ?? c.name,
127
+ thread: `${c.name}:${m.thread_ts ?? m.ts}`, at: Number.isFinite(ms) ? ms : undefined,
128
+ url: opts.workspaceUrl ? `${opts.workspaceUrl.replace(/\/+$/, '')}/archives/${c.id}/p${m.ts.replace('.', '')}` : undefined,
129
+ metadata: { channel: c.name, ts: m.ts, threadTs: m.thread_ts, subtype: m.subtype, replies: m.reply_count },
130
+ });
131
+ }
132
+ }
133
+ }
134
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Small text tools the importers share: HTML reduced to text, character entities, bytes in any charset, long texts cut
3
+ * into parts. No dependency; any language and script is kept as written.
4
+ */
5
+ import type { MemoryRecord } from '../records.js';
6
+ /** `&amp;`, `&#233;`, `&#x1F600;`, `&eacute;` and the other common names, decoded; unknown names are left as written. */
7
+ export declare function decodeEntities(s: string): string;
8
+ /** The `<title>` of an HTML page, else its first `<h1>`, as text; undefined when it has neither. */
9
+ export declare function htmlTitle(html: string): string | undefined;
10
+ /**
11
+ * HTML as plain text: scripts, styles, comments and the head dropped, blocks and line breaks kept as lines, list items
12
+ * as `- `, tags removed, entities decoded, `<pre>` kept as written. `dropQuotes` also drops `<blockquote>`s.
13
+ */
14
+ export declare function htmlToText(html: string, opts?: {
15
+ dropQuotes?: boolean;
16
+ }): string;
17
+ /** Lines trimmed at the end, spaces and tabs inside a line kept to one, at most one empty line in a row. */
18
+ export declare function tidy(s: string): string;
19
+ /** Bytes as text in the charset named (any the platform knows); unnamed, ASCII or unknown: UTF-8, else Windows-1252. */
20
+ export declare function decodeBytes(bytes: Uint8Array, charset?: string): string;
21
+ /**
22
+ * A long text in parts of at most `maxChars`, each cut at a paragraph, else a line, else a sentence's end (in any
23
+ * script), else a space; never inside a character.
24
+ */
25
+ export declare function splitText(text: string, maxChars: number): string[];
26
+ /** A record whose fields, and metadata values, may be left undefined. */
27
+ export type RecordDraft = Omit<MemoryRecord, 'metadata'> & {
28
+ metadata?: Record<string, string | number | boolean | null | undefined>;
29
+ };
30
+ /** A record without its empty fields. */
31
+ export declare function record(r: RecordDraft): MemoryRecord;