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.
- package/CHANGELOG.md +30 -0
- package/LICENSE +21 -0
- package/README.md +221 -2
- package/dist/core/consolidate.d.ts +144 -0
- package/dist/core/consolidate.js +660 -0
- package/dist/core/embedder.d.ts +30 -0
- package/dist/core/embedder.js +137 -0
- package/dist/core/episodes.d.ts +32 -0
- package/dist/core/episodes.js +53 -0
- package/dist/core/extract.d.ts +142 -0
- package/dist/core/extract.js +452 -0
- package/dist/core/gather.d.ts +17 -0
- package/dist/core/gather.js +98 -0
- package/dist/core/graph.d.ts +48 -0
- package/dist/core/graph.js +208 -0
- package/dist/core/local-embedder.d.ts +40 -0
- package/dist/core/local-embedder.js +98 -0
- package/dist/core/model.d.ts +16 -0
- package/dist/core/model.js +74 -0
- package/dist/core/paths.d.ts +1 -0
- package/dist/core/paths.js +7 -0
- package/dist/core/profiles.d.ts +30 -0
- package/dist/core/profiles.js +147 -0
- package/dist/core/qdrant-binary.d.ts +25 -0
- package/dist/core/qdrant-binary.js +108 -0
- package/dist/core/qdrant-client.d.ts +115 -0
- package/dist/core/qdrant-client.js +327 -0
- package/dist/core/qdrant-process.d.ts +30 -0
- package/dist/core/qdrant-process.js +160 -0
- package/dist/core/rebuild.d.ts +31 -0
- package/dist/core/rebuild.js +109 -0
- package/dist/core/recall-log.d.ts +29 -0
- package/dist/core/recall-log.js +82 -0
- package/dist/core/recall.d.ts +91 -0
- package/dist/core/recall.js +172 -0
- package/dist/core/redact.d.ts +2 -0
- package/dist/core/redact.js +48 -0
- package/dist/core/reranker.d.ts +26 -0
- package/dist/core/reranker.js +67 -0
- package/dist/core/room.d.ts +20 -0
- package/dist/core/room.js +63 -0
- package/dist/core/sleep.d.ts +44 -0
- package/dist/core/sleep.js +199 -0
- package/dist/core/store.d.ts +198 -0
- package/dist/core/store.js +190 -0
- package/dist/core/text.d.ts +31 -0
- package/dist/core/text.js +101 -0
- package/dist/core/words.d.ts +32 -0
- package/dist/core/words.js +127 -0
- package/dist/core/writer.d.ts +36 -0
- package/dist/core/writer.js +146 -0
- package/dist/importers/files.d.ts +31 -0
- package/dist/importers/files.js +157 -0
- package/dist/importers/git.d.ts +30 -0
- package/dist/importers/git.js +118 -0
- package/dist/importers/index.d.ts +14 -0
- package/dist/importers/index.js +26 -0
- package/dist/importers/jsonl.d.ts +20 -0
- package/dist/importers/jsonl.js +65 -0
- package/dist/importers/mbox.d.ts +20 -0
- package/dist/importers/mbox.js +347 -0
- package/dist/importers/slack.d.ts +21 -0
- package/dist/importers/slack.js +134 -0
- package/dist/importers/text.d.ts +31 -0
- package/dist/importers/text.js +191 -0
- package/dist/index.d.ts +28 -0
- package/dist/index.js +22 -0
- package/dist/llm.d.ts +60 -0
- package/dist/llm.js +60 -0
- package/dist/memory.d.ts +106 -0
- package/dist/memory.js +262 -0
- package/dist/records.d.ts +62 -0
- package/dist/records.js +107 -0
- package/dist/tools/agent-tools.d.ts +52 -0
- package/dist/tools/agent-tools.js +142 -0
- package/package.json +78 -4
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { type QdrantClient } from './qdrant-client.js';
|
|
2
|
+
import type { MemoryItem, StmEntry } from './store.js';
|
|
3
|
+
export interface WriterDeps {
|
|
4
|
+
client: QdrantClient;
|
|
5
|
+
embed: (texts: string[], timeoutMs?: number) => Promise<Float32Array[]>;
|
|
6
|
+
}
|
|
7
|
+
/** Time for embedding this many texts: a 30 s floor, and more for a long batch (a long record is many chunks). */
|
|
8
|
+
export declare const embedBudget: (n: number) => number;
|
|
9
|
+
/** A short-term entry's item id in the index (its long-term episode, if consolidation keeps it, is `ep:<uid>`). */
|
|
10
|
+
export declare const stmItem: (uid: string) => string;
|
|
11
|
+
/** The raw key of a captured request: what it asked and the start of what was done — ~150 words. */
|
|
12
|
+
export declare function rawKey(e: Pick<StmEntry, 'request' | 'reply'>): string;
|
|
13
|
+
/** A captured request's own title, its first line: the conversation's title says where it was, not what it is (one
|
|
14
|
+
* long conversation's requests would all read alike, and match alike). */
|
|
15
|
+
export declare function stmTitle(e: Pick<StmEntry, 'request' | 'title'>): string;
|
|
16
|
+
export declare function writeStm(d: WriterDeps, entries: StmEntry[]): Promise<void>;
|
|
17
|
+
/** A cue's point number: the k-th cue is -(k + 1) — below the key (0) and the children (1, 2, …), however many of
|
|
18
|
+
* either there are, so the two never meet. */
|
|
19
|
+
export declare const cuePoint: (k: number) => number;
|
|
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. */
|
|
22
|
+
export declare function dateSpan(d: string | undefined): {
|
|
23
|
+
from: number;
|
|
24
|
+
to: number;
|
|
25
|
+
} | null;
|
|
26
|
+
/** Only the cues of these items, written again (the key and the children stay as they are). */
|
|
27
|
+
export declare function writeCues(d: WriterDeps, items: MemoryItem[]): Promise<void>;
|
|
28
|
+
/** Embed and write long-term items: their old points out, the key point, the children and the cues in. Cues are
|
|
29
|
+
* embedded as documents titled by the item (how they were measured); the item, not the cue, is what comes back. */
|
|
30
|
+
/** A summary this many chunks long (~380 words) is also chunked: its key vector reads only its start. */
|
|
31
|
+
export declare const LONG_SUMMARY_CHUNKS = 5;
|
|
32
|
+
export declare function writeLtm(d: WriterDeps, items: MemoryItem[]): Promise<void>;
|
|
33
|
+
/** The keys of an item's concepts, one per concept (text.ts conceptKey). */
|
|
34
|
+
export declare function conceptKeys(names: readonly string[] | undefined): string[];
|
|
35
|
+
/** Only the concepts of these items, written again (their keys on the key point). */
|
|
36
|
+
export declare function writeConcepts(d: WriterDeps, items: MemoryItem[]): Promise<void>;
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How memory reaches the index: points built, embedded in batches and written to Qdrant — both tiers.
|
|
3
|
+
*
|
|
4
|
+
* - a record (a short-term entry): ONE key point, its start as the key, and its whole text in chunks: no model,
|
|
5
|
+
* searchable the moment it is added;
|
|
6
|
+
* - a long-term item: a key point (content title + ~150-word summary, BM25 over title, summary, body and files) and
|
|
7
|
+
* its ~96-word children (small-to-big: searched, never shown — the item is what comes back), its cues, and the keys
|
|
8
|
+
* of its concepts on the key point (the concept graph's links: graph.ts).
|
|
9
|
+
*/
|
|
10
|
+
import { basename } from 'node:path';
|
|
11
|
+
import { docText } from './embedder.js';
|
|
12
|
+
import { episodeBody } from './episodes.js';
|
|
13
|
+
import { pointId } from './qdrant-client.js';
|
|
14
|
+
import { children, conceptKey, cut, docSparse, firstWords } from './text.js';
|
|
15
|
+
/** Time for embedding this many texts: a 30 s floor, and more for a long batch (a long record is many chunks). */
|
|
16
|
+
export const embedBudget = (n) => Math.max(30_000, n * 150);
|
|
17
|
+
/** A short-term entry's item id in the index (its long-term episode, if consolidation keeps it, is `ep:<uid>`). */
|
|
18
|
+
export const stmItem = (uid) => `stm:${uid}`;
|
|
19
|
+
/** The raw key of a captured request: what it asked and the start of what was done — ~150 words. */
|
|
20
|
+
export function rawKey(e) {
|
|
21
|
+
return firstWords(`${e.request}\n${e.reply}`, 150);
|
|
22
|
+
}
|
|
23
|
+
/** A captured request's own title, its first line: the conversation's title says where it was, not what it is (one
|
|
24
|
+
* long conversation's requests would all read alike, and match alike). */
|
|
25
|
+
export function stmTitle(e) {
|
|
26
|
+
const first = e.request.split('\n').map((l) => l.trim()).find(Boolean) ?? '';
|
|
27
|
+
return cut(first, 100) || e.title;
|
|
28
|
+
}
|
|
29
|
+
export async function writeStm(d, entries) {
|
|
30
|
+
if (!entries.length)
|
|
31
|
+
return;
|
|
32
|
+
// The raw key (its start), and the whole body in chunks: every word of a captured request can be found.
|
|
33
|
+
const plan = entries.map((e) => { const body = episodeBody(e); return { e, body, kids: children(body) }; });
|
|
34
|
+
const texts = plan.flatMap(({ e, kids }) => [docText(stmTitle(e), rawKey(e)), ...kids.map((k) => docText(stmTitle(e), k))]);
|
|
35
|
+
const vecs = await d.embed(texts, embedBudget(texts.length));
|
|
36
|
+
const points = [];
|
|
37
|
+
let at = 0;
|
|
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 };
|
|
40
|
+
points.push({
|
|
41
|
+
id: pointId(stmItem(e.uid), 0),
|
|
42
|
+
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
|
+
});
|
|
45
|
+
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
|
+
at += 1 + kids.length;
|
|
47
|
+
}
|
|
48
|
+
await d.client.deleteItems(entries.map((e) => stmItem(e.uid)));
|
|
49
|
+
for (let i = 0; i < points.length; i += 256)
|
|
50
|
+
await d.client.upsert(points.slice(i, i + 256), true);
|
|
51
|
+
}
|
|
52
|
+
/** A cue's point number: the k-th cue is -(k + 1) — below the key (0) and the children (1, 2, …), however many of
|
|
53
|
+
* either there are, so the two never meet. */
|
|
54
|
+
export const cuePoint = (k) => -(k + 1);
|
|
55
|
+
/** 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
|
+
export function dateSpan(d) {
|
|
58
|
+
const m = /^(\d{4})(?:-(\d{2})(?:-(\d{2}))?)?$/.exec(d ?? '');
|
|
59
|
+
if (!m)
|
|
60
|
+
return null;
|
|
61
|
+
const [y, mo, dd] = [Number(m[1]), m[2] ? Number(m[2]) - 1 : null, m[3] ? Number(m[3]) : null];
|
|
62
|
+
if (mo === null)
|
|
63
|
+
return { from: Date.UTC(y, 0, 1), to: Date.UTC(y + 1, 0, 1) - 1 };
|
|
64
|
+
if (dd === null)
|
|
65
|
+
return { from: Date.UTC(y, mo, 1), to: Date.UTC(y, mo + 1, 1) - 1 };
|
|
66
|
+
return { from: Date.UTC(y, mo, dd), to: Date.UTC(y, mo, dd + 1) - 1 };
|
|
67
|
+
}
|
|
68
|
+
/** What every point of a long-term item carries: what recall and the filters select on — for an observation, the time
|
|
69
|
+
* it happened too (memory_search's `from`/`to`): from its date, or from when what it tells began to when it ended. */
|
|
70
|
+
function ltmPayload(it) {
|
|
71
|
+
const spans = [it.eventAt, it.startAt, it.endAt].map(dateSpan).filter((s) => !!s);
|
|
72
|
+
const span = spans.length ? { from: Math.min(...spans.map((s) => s.from)), to: Math.max(...spans.map((s) => s.to)) } : null;
|
|
73
|
+
return {
|
|
74
|
+
item: it.uid, tier: 'ltm', status: it.status, kind: it.kind, scope: it.scope, project: it.project,
|
|
75
|
+
session: it.session ?? '', ts: it.ts, synthetic: false, ...(span ? { eventFrom: span.from, eventTo: span.to } : {}),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
/** Only the cues of these items, written again (the key and the children stay as they are). */
|
|
79
|
+
export async function writeCues(d, items) {
|
|
80
|
+
const live = items.filter((it) => it.status === 'active');
|
|
81
|
+
const texts = live.flatMap((it) => (it.cues ?? []).map((c) => docText(it.title, c)));
|
|
82
|
+
if (!texts.length) {
|
|
83
|
+
await d.client.deleteParts(live.map((it) => it.uid), 'cue');
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
const vecs = await d.embed(texts, embedBudget(texts.length));
|
|
87
|
+
const points = [];
|
|
88
|
+
let n = 0;
|
|
89
|
+
for (const it of live) {
|
|
90
|
+
(it.cues ?? []).forEach((_, k) => points.push({ id: pointId(it.uid, cuePoint(k)), vector: { dense: Array.from(vecs[n++]) }, payload: { ...ltmPayload(it), part: 'cue' } }));
|
|
91
|
+
}
|
|
92
|
+
await d.client.deleteParts(live.map((it) => it.uid), 'cue');
|
|
93
|
+
for (let i = 0; i < points.length; i += 256)
|
|
94
|
+
await d.client.upsert(points.slice(i, i + 256), true);
|
|
95
|
+
}
|
|
96
|
+
/** Embed and write long-term items: their old points out, the key point, the children and the cues in. Cues are
|
|
97
|
+
* embedded as documents titled by the item (how they were measured); the item, not the cue, is what comes back. */
|
|
98
|
+
/** A summary this many chunks long (~380 words) is also chunked: its key vector reads only its start. */
|
|
99
|
+
export const LONG_SUMMARY_CHUNKS = 5;
|
|
100
|
+
export async function writeLtm(d, items) {
|
|
101
|
+
const live = items.filter((it) => it.status === 'active');
|
|
102
|
+
if (!live.length) {
|
|
103
|
+
await d.client.deleteItems(items.map((it) => it.uid));
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
const texts = [];
|
|
107
|
+
const plan = [];
|
|
108
|
+
for (const it of live) {
|
|
109
|
+
// Every chunk of the body — and of a summary too long for one key vector (the embedder reads its start).
|
|
110
|
+
const kids = [...children(it.body), ...(children(it.summary).length >= LONG_SUMMARY_CHUNKS ? children(it.summary) : [])];
|
|
111
|
+
const cues = it.cues ?? [];
|
|
112
|
+
plan.push({ it, kids, cues, at: texts.length });
|
|
113
|
+
texts.push(docText(it.title, it.summary), ...kids.map((k) => docText(it.title, k)), ...cues.map((c) => docText(it.title, c)));
|
|
114
|
+
}
|
|
115
|
+
const vecs = await d.embed(texts, embedBudget(texts.length));
|
|
116
|
+
const points = [];
|
|
117
|
+
for (const { it, kids, cues, at } of plan) {
|
|
118
|
+
const common = ltmPayload(it);
|
|
119
|
+
points.push({
|
|
120
|
+
id: pointId(it.uid, 0),
|
|
121
|
+
vector: { dense: Array.from(vecs[at]), bm25: docSparse(`${it.title}\n${it.summary}\n${it.body}\n${it.files.map((f) => basename(f)).join(' ')}`) },
|
|
122
|
+
payload: {
|
|
123
|
+
...common, part: 'key', engine: it.engine, cwd: it.cwd ?? '', where: it.where, title: it.title, summary: it.summary,
|
|
124
|
+
body: it.body, files: it.files, importance: it.importance, appliesWhen: it.appliesWhen,
|
|
125
|
+
related: it.related ?? [], concepts: conceptKeys(it.concepts),
|
|
126
|
+
...(it.eventAt ? { eventAt: it.eventAt } : {}), ...(it.startAt ? { startAt: it.startAt } : {}), ...(it.endAt ? { endAt: it.endAt } : {}),
|
|
127
|
+
...(it.phase ? { phase: it.phase } : {}), ...(it.about ? { about: it.about } : {}),
|
|
128
|
+
},
|
|
129
|
+
});
|
|
130
|
+
kids.forEach((_, k) => points.push({ id: pointId(it.uid, k + 1), vector: { dense: Array.from(vecs[at + 1 + k]) }, payload: { ...common, part: 'child' } }));
|
|
131
|
+
cues.forEach((_, k) => points.push({ id: pointId(it.uid, cuePoint(k)), vector: { dense: Array.from(vecs[at + 1 + kids.length + k]) }, payload: { ...common, part: 'cue' } }));
|
|
132
|
+
}
|
|
133
|
+
// Remote embedding/validation can fail: keep the old searchable item until every replacement vector exists.
|
|
134
|
+
await d.client.deleteItems(items.map((it) => it.uid));
|
|
135
|
+
for (let i = 0; i < points.length; i += 256)
|
|
136
|
+
await d.client.upsert(points.slice(i, i + 256), true);
|
|
137
|
+
}
|
|
138
|
+
/** The keys of an item's concepts, one per concept (text.ts conceptKey). */
|
|
139
|
+
export function conceptKeys(names) {
|
|
140
|
+
return [...new Set((names ?? []).map(conceptKey).filter(Boolean))];
|
|
141
|
+
}
|
|
142
|
+
/** Only the concepts of these items, written again (their keys on the key point). */
|
|
143
|
+
export async function writeConcepts(d, items) {
|
|
144
|
+
for (const it of items.filter((x) => x.status === 'active'))
|
|
145
|
+
await d.client.setPayload([it.uid], { concepts: conceptKeys(it.concepts) }, true);
|
|
146
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { type MemoryRecord } from '../records.js';
|
|
2
|
+
/** The extensions read by default: text formats only. */
|
|
3
|
+
export declare const TEXT_EXTENSIONS: string[];
|
|
4
|
+
export interface FilesOptions {
|
|
5
|
+
/** Extensions to read, with or without the dot. Default: TEXT_EXTENSIONS. */
|
|
6
|
+
include?: string[];
|
|
7
|
+
/** Folder names never entered (dot folders never are). Default: `node_modules`. */
|
|
8
|
+
skipFolders?: string[];
|
|
9
|
+
/** Leave out a file by its path inside the folder. */
|
|
10
|
+
exclude?: (path: string) => boolean;
|
|
11
|
+
/** Longer files are cut into parts of at most this many characters. Default 20,000. */
|
|
12
|
+
maxChars?: number;
|
|
13
|
+
/** Larger files are not read (reported through `onSkip`). Default 64 MiB. */
|
|
14
|
+
maxBytes?: number;
|
|
15
|
+
collection?: string;
|
|
16
|
+
/** Default: the folder's name. */
|
|
17
|
+
source?: string;
|
|
18
|
+
/** Default `note`. */
|
|
19
|
+
kind?: string;
|
|
20
|
+
/** Called for each file left out, with why. */
|
|
21
|
+
onSkip?: (path: string, reason: string) => void;
|
|
22
|
+
}
|
|
23
|
+
type Scalar = string | number | boolean | null;
|
|
24
|
+
/** YAML (`---`) or TOML (`+++`) front matter, flat fields only: scalars, lists (joined with `, `) and block text. */
|
|
25
|
+
export declare function frontMatter(text: string): {
|
|
26
|
+
fields: Record<string, Scalar>;
|
|
27
|
+
body: string;
|
|
28
|
+
};
|
|
29
|
+
/** Records from the text files in a folder and its subfolders. */
|
|
30
|
+
export declare function fromFiles(dir: string, opts?: FilesOptions): AsyncIterable<MemoryRecord>;
|
|
31
|
+
export {};
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A folder of text files: notes, documents, saved pages. Every file whose extension is in `include` becomes a record,
|
|
3
|
+
* read one at a time; dot folders, dot files and `node_modules` are passed by.
|
|
4
|
+
*
|
|
5
|
+
* id: the path inside the folder (with `/`). title: the Markdown front matter's `title`, else the first `# heading`
|
|
6
|
+
* (an Org `#+TITLE:`, an RST underlined title, an HTML `<title>` or `<h1>`), else the file's name. text: the file, front
|
|
7
|
+
* matter taken off, HTML reduced to text. at: the front matter's `date`, else when the file was last changed. The other
|
|
8
|
+
* front-matter fields go into metadata (`author` also to the record's author). A file longer than `maxChars` is cut
|
|
9
|
+
* into parts with ids `path#1`, `path#2`, … sharing the thread `path`.
|
|
10
|
+
*/
|
|
11
|
+
import { readdir, readFile, stat } from 'node:fs/promises';
|
|
12
|
+
import { basename, extname, join, resolve } from 'node:path';
|
|
13
|
+
import { pathToFileURL } from 'node:url';
|
|
14
|
+
import { timeOf } from '../records.js';
|
|
15
|
+
import { decodeBytes, htmlTitle, htmlToText, record, splitText } from './text.js';
|
|
16
|
+
/** The extensions read by default: text formats only. */
|
|
17
|
+
export const TEXT_EXTENSIONS = ['.md', '.markdown', '.mdx', '.txt', '.text', '.html', '.htm', '.xhtml', '.rst', '.org', '.adoc',
|
|
18
|
+
'.asciidoc', '.textile', '.wiki', '.tex'];
|
|
19
|
+
function scalar(raw) {
|
|
20
|
+
const v = raw.trim();
|
|
21
|
+
if (/^(["']).*\1$/s.test(v))
|
|
22
|
+
return v.slice(1, -1).replace(/\\"/g, '"').replace(/''/g, "'");
|
|
23
|
+
if (/^(true|false)$/i.test(v))
|
|
24
|
+
return v.toLowerCase() === 'true';
|
|
25
|
+
if (/^(null|~)$/i.test(v) || v === '')
|
|
26
|
+
return null;
|
|
27
|
+
if (/^-?(0|[1-9]\d{0,14})(\.\d+)?$/.test(v))
|
|
28
|
+
return Number(v);
|
|
29
|
+
return v;
|
|
30
|
+
}
|
|
31
|
+
function listValue(items) {
|
|
32
|
+
return items.map((i) => scalar(i)).filter((i) => i !== null && i !== '').join(', ');
|
|
33
|
+
}
|
|
34
|
+
/** YAML (`---`) or TOML (`+++`) front matter, flat fields only: scalars, lists (joined with `, `) and block text. */
|
|
35
|
+
export function frontMatter(text) {
|
|
36
|
+
const m = /^(---|\+\+\+)[ \t]*\r?\n([\s\S]*?)\r?\n(?:---|\+\+\+|\.\.\.)[ \t]*(?:\r?\n|$)/.exec(text);
|
|
37
|
+
if (!m)
|
|
38
|
+
return { fields: {}, body: text };
|
|
39
|
+
const toml = m[1] === '+++';
|
|
40
|
+
const fields = {};
|
|
41
|
+
const lines = m[2].split(/\r?\n/);
|
|
42
|
+
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]);
|
|
44
|
+
if (!kv)
|
|
45
|
+
continue;
|
|
46
|
+
const key = kv[1].trim().replace(/^["']|["']$/g, '');
|
|
47
|
+
const value = (kv[2] ?? '').trim();
|
|
48
|
+
if (value.startsWith('[') && value.endsWith(']')) {
|
|
49
|
+
fields[key] = listValue(value.slice(1, -1).split(','));
|
|
50
|
+
}
|
|
51
|
+
else if (!toml && /^[|>][-+]?$/.test(value)) {
|
|
52
|
+
const block = [];
|
|
53
|
+
while (i + 1 < lines.length && (/^\s/.test(lines[i + 1]) || !lines[i + 1].trim()))
|
|
54
|
+
block.push(lines[++i].trim());
|
|
55
|
+
fields[key] = block.join(value.startsWith('|') ? '\n' : ' ').trim();
|
|
56
|
+
}
|
|
57
|
+
else if (!toml && !value) {
|
|
58
|
+
const items = [];
|
|
59
|
+
while (i + 1 < lines.length && /^\s*-\s+/.test(lines[i + 1]))
|
|
60
|
+
items.push(lines[++i].replace(/^\s*-\s+/, ''));
|
|
61
|
+
while (i + 1 < lines.length && /^\s+\S/.test(lines[i + 1]))
|
|
62
|
+
i++; // a nested map: not kept
|
|
63
|
+
if (items.length)
|
|
64
|
+
fields[key] = listValue(items);
|
|
65
|
+
}
|
|
66
|
+
else {
|
|
67
|
+
fields[key] = scalar(value.replace(/\s+#.*$/, ''));
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return { fields, body: text.slice(m[0].length) };
|
|
71
|
+
}
|
|
72
|
+
function titleOf(ext, body, raw) {
|
|
73
|
+
if (ext === '.html' || ext === '.htm' || ext === '.xhtml')
|
|
74
|
+
return htmlTitle(raw);
|
|
75
|
+
if (ext === '.org')
|
|
76
|
+
return /^#\+title:[ \t]*(.+)$/im.exec(body)?.[1].trim() || /^\*+[ \t]+(.+)$/m.exec(body)?.[1].trim();
|
|
77
|
+
if (ext === '.rst')
|
|
78
|
+
return /^(?:([=\-~^"'`#*+])\1+\r?\n)?[ \t]*(\S[^\n]*?)[ \t]*\r?\n([=\-~^"'`#*+])\3{2,}[ \t]*$/m.exec(body)?.[2];
|
|
79
|
+
if (ext === '.adoc' || ext === '.asciidoc')
|
|
80
|
+
return /^=[ \t]+(.+)$/m.exec(body)?.[1].trim();
|
|
81
|
+
const atx = /^#[ \t]+(.+?)(?:[ \t]+#+)?[ \t]*$/m.exec(body)?.[1].trim();
|
|
82
|
+
if (atx)
|
|
83
|
+
return atx;
|
|
84
|
+
return /^(\S[^\n]*?)[ \t]*\r?\n=+[ \t]*$/m.exec(body)?.[1]; // a Setext `Title\n=====`
|
|
85
|
+
}
|
|
86
|
+
async function* walk(root, rel, o) {
|
|
87
|
+
const entries = await readdir(join(root, rel), { withFileTypes: true });
|
|
88
|
+
entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
89
|
+
for (const e of entries) {
|
|
90
|
+
if (e.name.startsWith('.'))
|
|
91
|
+
continue;
|
|
92
|
+
const path = rel ? `${rel}/${e.name}` : e.name;
|
|
93
|
+
if (e.isDirectory()) {
|
|
94
|
+
if (!o.skipFolders.has(e.name))
|
|
95
|
+
yield* walk(root, path, o);
|
|
96
|
+
}
|
|
97
|
+
else if (e.isFile() && o.include.has(extname(e.name).toLowerCase())) {
|
|
98
|
+
yield path;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
function decodeFile(bytes) {
|
|
103
|
+
if (bytes[0] === 0xff && bytes[1] === 0xfe)
|
|
104
|
+
return new TextDecoder('utf-16le').decode(bytes);
|
|
105
|
+
if (bytes[0] === 0xfe && bytes[1] === 0xff)
|
|
106
|
+
return new TextDecoder('utf-16be').decode(bytes);
|
|
107
|
+
if (bytes.subarray(0, 8192).includes(0))
|
|
108
|
+
return null; // binary
|
|
109
|
+
return decodeBytes(bytes);
|
|
110
|
+
}
|
|
111
|
+
/** Records from the text files in a folder and its subfolders. */
|
|
112
|
+
export async function* fromFiles(dir, opts = {}) {
|
|
113
|
+
const root = resolve(dir);
|
|
114
|
+
const include = new Set((opts.include ?? TEXT_EXTENSIONS).map((e) => (e.startsWith('.') ? e : `.${e}`).toLowerCase()));
|
|
115
|
+
const skipFolders = new Set(opts.skipFolders ?? ['node_modules']);
|
|
116
|
+
const maxChars = opts.maxChars ?? 20_000;
|
|
117
|
+
const maxBytes = opts.maxBytes ?? 64 * 1024 * 1024;
|
|
118
|
+
const source = opts.source ?? basename(root);
|
|
119
|
+
for await (const path of walk(root, '', { include, skipFolders })) {
|
|
120
|
+
if (opts.exclude?.(path))
|
|
121
|
+
continue;
|
|
122
|
+
const abs = join(root, path);
|
|
123
|
+
const info = await stat(abs);
|
|
124
|
+
if (info.size > maxBytes) {
|
|
125
|
+
opts.onSkip?.(path, `larger than ${maxBytes} bytes`);
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
const raw = decodeFile(await readFile(abs));
|
|
129
|
+
if (raw === null) {
|
|
130
|
+
opts.onSkip?.(path, 'not text');
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
const ext = extname(path).toLowerCase();
|
|
134
|
+
const isHtml = ext === '.html' || ext === '.htm' || ext === '.xhtml';
|
|
135
|
+
const { fields, body } = isHtml ? { fields: {}, body: raw } : frontMatter(raw);
|
|
136
|
+
const fmTitle = typeof fields.title === 'string' || typeof fields.title === 'number' ? String(fields.title).trim() : '';
|
|
137
|
+
const title = fmTitle || titleOf(ext, body, raw) || basename(path, extname(path));
|
|
138
|
+
const text = isHtml ? htmlToText(raw) : body.replace(/\r\n?/g, '\n').trim();
|
|
139
|
+
const dated = typeof fields.date === 'string' || typeof fields.date === 'number' ? timeOf(String(fields.date)) : undefined;
|
|
140
|
+
const at = dated ?? info.mtimeMs;
|
|
141
|
+
const author = typeof fields.author === 'string' ? fields.author : undefined;
|
|
142
|
+
const metadata = Object.fromEntries(Object.entries(fields).filter(([k]) => k !== 'title'));
|
|
143
|
+
const base = { id: path, text, title, kind: opts.kind ?? 'note', source, collection: opts.collection, author,
|
|
144
|
+
at: Math.round(at), url: pathToFileURL(abs).href };
|
|
145
|
+
if (!text && !title)
|
|
146
|
+
continue;
|
|
147
|
+
const parts = splitText(text, maxChars);
|
|
148
|
+
if (parts.length === 1) {
|
|
149
|
+
yield record({ ...base, metadata });
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
for (let i = 0; i < parts.length; i++) {
|
|
153
|
+
yield record({ ...base, id: `${path}#${i + 1}`, thread: path, text: parts[i], title: `${title} (${i + 1}/${parts.length})`,
|
|
154
|
+
metadata: { ...metadata, part: i + 1, parts: parts.length } });
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { MemoryRecord } from '../records.js';
|
|
2
|
+
export interface GitOptions {
|
|
3
|
+
/** Only commits after this (anything `git log --since` reads, a Date, or milliseconds). */
|
|
4
|
+
since?: string | Date | number;
|
|
5
|
+
/** Only commits before this. */
|
|
6
|
+
until?: string | Date | number;
|
|
7
|
+
/** The branch, tag or range to read (`main`, `v1.0..v2.0`). Default: HEAD. `rev` is the same. */
|
|
8
|
+
branch?: string;
|
|
9
|
+
rev?: string;
|
|
10
|
+
/** Every branch and tag. */
|
|
11
|
+
all?: boolean;
|
|
12
|
+
/** At most this many commits. */
|
|
13
|
+
maxCount?: number;
|
|
14
|
+
/** Only commits that changed these paths. */
|
|
15
|
+
paths?: string[];
|
|
16
|
+
/** Leave out merge commits. */
|
|
17
|
+
noMerges?: boolean;
|
|
18
|
+
/** At most this many changed paths listed in a record's text. Default 50. */
|
|
19
|
+
maxFiles?: number;
|
|
20
|
+
/** Default: the repository's name. */
|
|
21
|
+
thread?: string;
|
|
22
|
+
source?: string;
|
|
23
|
+
collection?: string;
|
|
24
|
+
/** Where to open a commit, e.g. `(sha) => \`https://example.org/repo/commit/${sha}\``. */
|
|
25
|
+
url?: (sha: string) => string;
|
|
26
|
+
/** The git program. Default `git`. */
|
|
27
|
+
git?: string;
|
|
28
|
+
}
|
|
29
|
+
/** Records from a repository's commits, newest first (as `git log` gives them). */
|
|
30
|
+
export declare function fromGit(repoDir: string, opts?: GitOptions): AsyncIterable<MemoryRecord>;
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A Git repository's history: one record a commit, read from `git log` as it runs (never the whole log at once).
|
|
3
|
+
* Each commit is framed by markers carrying a random token made for the run, so no message text can be taken for one.
|
|
4
|
+
*
|
|
5
|
+
* id: the full sha. title: the subject. text: the whole message, then `Files: …` (the paths it changed, at most
|
|
6
|
+
* `maxFiles`). author: the author's name. at: the author date. kind `commit`; thread: `opts.thread` or the repository's
|
|
7
|
+
* name; source: `opts.source` or the repository's name; metadata { sha, email, parents }.
|
|
8
|
+
*/
|
|
9
|
+
import { spawn } from 'node:child_process';
|
|
10
|
+
import { randomBytes } from 'node:crypto';
|
|
11
|
+
import { basename, resolve } from 'node:path';
|
|
12
|
+
import { record } from './text.js';
|
|
13
|
+
function when(t) {
|
|
14
|
+
return t instanceof Date ? t.toISOString() : typeof t === 'number' ? new Date(t).toISOString() : t;
|
|
15
|
+
}
|
|
16
|
+
/** A path as git writes it when it has unusual characters: `"a\tb\303\251"` back to its text. */
|
|
17
|
+
function unquote(path) {
|
|
18
|
+
if (!(path.length > 1 && path.startsWith('"') && path.endsWith('"')))
|
|
19
|
+
return path;
|
|
20
|
+
const bytes = [];
|
|
21
|
+
const s = path.slice(1, -1);
|
|
22
|
+
const esc = { a: 7, b: 8, t: 9, n: 10, v: 11, f: 12, r: 13, '"': 34, '\\': 92 };
|
|
23
|
+
for (let i = 0; i < s.length; i++) {
|
|
24
|
+
if (s[i] === '\\' && /[0-7]{3}/.test(s.slice(i + 1, i + 4))) {
|
|
25
|
+
bytes.push(parseInt(s.slice(i + 1, i + 4), 8));
|
|
26
|
+
i += 3;
|
|
27
|
+
}
|
|
28
|
+
else if (s[i] === '\\' && s[i + 1] in esc) {
|
|
29
|
+
bytes.push(esc[s[++i]]);
|
|
30
|
+
}
|
|
31
|
+
else {
|
|
32
|
+
bytes.push(...Buffer.from(s[i], 'utf8'));
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return Buffer.from(bytes).toString('utf8');
|
|
36
|
+
}
|
|
37
|
+
/** Records from a repository's commits, newest first (as `git log` gives them). */
|
|
38
|
+
export async function* fromGit(repoDir, opts = {}) {
|
|
39
|
+
const name = basename(resolve(repoDir));
|
|
40
|
+
const token = randomBytes(16).toString('hex');
|
|
41
|
+
const start = `\u001e${token}\n`;
|
|
42
|
+
const end = `\u001f${token}`;
|
|
43
|
+
const args = ['-c', 'core.quotePath=false', '-c', 'log.showSignature=false', 'log', '--encoding=UTF-8', '--no-color',
|
|
44
|
+
'--no-ext-diff', '--name-only', `--format=%x1e${token}%n%H%x00%an%x00%ae%x00%aI%x00%P%x00%B%x1f${token}`];
|
|
45
|
+
if (opts.maxCount !== undefined)
|
|
46
|
+
args.push(`--max-count=${Math.max(0, Math.floor(opts.maxCount))}`);
|
|
47
|
+
if (opts.since !== undefined)
|
|
48
|
+
args.push(`--since=${when(opts.since)}`);
|
|
49
|
+
if (opts.until !== undefined)
|
|
50
|
+
args.push(`--until=${when(opts.until)}`);
|
|
51
|
+
if (opts.noMerges)
|
|
52
|
+
args.push('--no-merges');
|
|
53
|
+
if (opts.all)
|
|
54
|
+
args.push('--all');
|
|
55
|
+
const rev = opts.rev ?? opts.branch;
|
|
56
|
+
if (rev) {
|
|
57
|
+
if (rev.startsWith('-'))
|
|
58
|
+
throw new Error(`not a revision: ${rev}`);
|
|
59
|
+
args.push(rev);
|
|
60
|
+
}
|
|
61
|
+
args.push('--', ...(opts.paths ?? []));
|
|
62
|
+
const maxFiles = opts.maxFiles ?? 50;
|
|
63
|
+
const child = spawn(opts.git ?? 'git', args, { cwd: repoDir, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
64
|
+
let stderr = '';
|
|
65
|
+
child.stderr.setEncoding('utf8');
|
|
66
|
+
child.stderr.on('data', (d) => {
|
|
67
|
+
if (stderr.length < 4000)
|
|
68
|
+
stderr += d;
|
|
69
|
+
});
|
|
70
|
+
const exited = new Promise((done, fail) => {
|
|
71
|
+
child.on('error', fail);
|
|
72
|
+
child.on('close', (code) => done(code));
|
|
73
|
+
});
|
|
74
|
+
exited.catch(() => undefined); // read below; this only keeps an early stop from leaving it unhandled
|
|
75
|
+
const toRecord = (chunk) => {
|
|
76
|
+
const close = chunk.lastIndexOf(end);
|
|
77
|
+
const head = close >= 0 ? chunk.slice(0, close) : chunk;
|
|
78
|
+
const [sha, author, email, date, parents, ...message] = head.split('\u0000');
|
|
79
|
+
if (!sha || !/^[0-9a-f]{7,64}$/.test(sha))
|
|
80
|
+
return null;
|
|
81
|
+
const body = message.join('\u0000').replace(/\r\n?/g, '\n').trim();
|
|
82
|
+
const subject = (body.split(/\n\s*\n/)[0] ?? '').replace(/\s*\n\s*/g, ' ').trim();
|
|
83
|
+
const files = close >= 0 ? chunk.slice(close + end.length).split('\n').filter((l) => l.trim()).map(unquote) : [];
|
|
84
|
+
const listed = files.slice(0, maxFiles).join(', ') + (files.length > maxFiles ? `, … and ${files.length - maxFiles} more` : '');
|
|
85
|
+
const text = [body, files.length ? `Files: ${listed}` : ''].filter(Boolean).join('\n\n');
|
|
86
|
+
return record({ id: sha, title: subject || sha.slice(0, 12), text, kind: 'commit', author, at: date || undefined,
|
|
87
|
+
thread: opts.thread ?? name, source: opts.source ?? name, collection: opts.collection, url: opts.url?.(sha),
|
|
88
|
+
metadata: { sha, email, parents } });
|
|
89
|
+
};
|
|
90
|
+
let buffer = '';
|
|
91
|
+
try {
|
|
92
|
+
child.stdout.setEncoding('utf8');
|
|
93
|
+
for await (const piece of child.stdout) {
|
|
94
|
+
buffer += piece;
|
|
95
|
+
let next = buffer.indexOf(start, 1);
|
|
96
|
+
while (next > 0) {
|
|
97
|
+
const r = toRecord(buffer.slice(buffer.startsWith(start) ? start.length : 0, next));
|
|
98
|
+
if (r)
|
|
99
|
+
yield r;
|
|
100
|
+
buffer = buffer.slice(next);
|
|
101
|
+
next = buffer.indexOf(start, 1);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
if (buffer.startsWith(start)) {
|
|
105
|
+
const r = toRecord(buffer.slice(start.length));
|
|
106
|
+
if (r)
|
|
107
|
+
yield r;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
finally {
|
|
111
|
+
if (child.exitCode === null && child.signalCode === null)
|
|
112
|
+
child.kill();
|
|
113
|
+
}
|
|
114
|
+
const code = await exited;
|
|
115
|
+
if (code !== 0 && !/does not have any commits|bad default revision 'HEAD'/.test(stderr)) {
|
|
116
|
+
throw new Error(`git log failed (${code}) in ${repoDir}: ${stderr.trim()}`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Importers: records from what people already have — JSON Lines, a folder of text files, a Git history, a Slack
|
|
3
|
+
* export, an mbox or `.eml` mailbox. Each one streams: records come one at a time as the source is read, never the
|
|
4
|
+
* whole source in memory, so `for await (const batch of batches(fromMbox(path), 200)) await memory.add(batch)` works
|
|
5
|
+
* on a mailbox of any size.
|
|
6
|
+
*/
|
|
7
|
+
export { fromJsonl, type JsonlMap, type JsonlOptions } from './jsonl.js';
|
|
8
|
+
export { fromFiles, frontMatter, TEXT_EXTENSIONS, type FilesOptions } from './files.js';
|
|
9
|
+
export { fromGit, type GitOptions } from './git.js';
|
|
10
|
+
export { fromSlackExport, slackText, SLACK_SKIPPED_SUBTYPES, type SlackOptions } from './slack.js';
|
|
11
|
+
export { fromMbox, fromEml, decodeEncodedWords, type MboxOptions } from './mbox.js';
|
|
12
|
+
export { htmlToText, decodeEntities, decodeBytes, splitText } from './text.js';
|
|
13
|
+
/** Items in arrays of `size` (the last may be shorter), as they come. */
|
|
14
|
+
export declare function batches<T>(it: AsyncIterable<T> | Iterable<T>, size: number): AsyncIterable<T[]>;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Importers: records from what people already have — JSON Lines, a folder of text files, a Git history, a Slack
|
|
3
|
+
* export, an mbox or `.eml` mailbox. Each one streams: records come one at a time as the source is read, never the
|
|
4
|
+
* whole source in memory, so `for await (const batch of batches(fromMbox(path), 200)) await memory.add(batch)` works
|
|
5
|
+
* on a mailbox of any size.
|
|
6
|
+
*/
|
|
7
|
+
export { fromJsonl } from './jsonl.js';
|
|
8
|
+
export { fromFiles, frontMatter, TEXT_EXTENSIONS } from './files.js';
|
|
9
|
+
export { fromGit } from './git.js';
|
|
10
|
+
export { fromSlackExport, slackText, SLACK_SKIPPED_SUBTYPES } from './slack.js';
|
|
11
|
+
export { fromMbox, fromEml, decodeEncodedWords } from './mbox.js';
|
|
12
|
+
export { htmlToText, decodeEntities, decodeBytes, splitText } from './text.js';
|
|
13
|
+
/** Items in arrays of `size` (the last may be shorter), as they come. */
|
|
14
|
+
export async function* batches(it, size) {
|
|
15
|
+
const n = Math.max(1, Math.floor(size));
|
|
16
|
+
let batch = [];
|
|
17
|
+
for await (const item of it) {
|
|
18
|
+
batch.push(item);
|
|
19
|
+
if (batch.length >= n) {
|
|
20
|
+
yield batch;
|
|
21
|
+
batch = [];
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
if (batch.length)
|
|
25
|
+
yield batch;
|
|
26
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { type MemoryRecord } from '../records.js';
|
|
2
|
+
/** Turns one parsed line into a record, or null to leave it out. */
|
|
3
|
+
export type JsonlMap = (obj: unknown, lineNumber: number) => MemoryRecord | null | undefined;
|
|
4
|
+
export interface JsonlOptions {
|
|
5
|
+
map?: JsonlMap;
|
|
6
|
+
/** Called for each line left out, with why. */
|
|
7
|
+
onSkip?: (line: string, reason: string, lineNumber: number) => void;
|
|
8
|
+
/** Filled in as the file is read: lines read, records given, lines skipped. */
|
|
9
|
+
stats?: {
|
|
10
|
+
lines: number;
|
|
11
|
+
records: number;
|
|
12
|
+
skipped: number;
|
|
13
|
+
};
|
|
14
|
+
/** For records that have none. */
|
|
15
|
+
source?: string;
|
|
16
|
+
collection?: string;
|
|
17
|
+
kind?: string;
|
|
18
|
+
}
|
|
19
|
+
/** Records from a JSON Lines file; `map` may be given here or in the options. */
|
|
20
|
+
export declare function fromJsonl(path: string, map?: JsonlMap | JsonlOptions, opts?: JsonlOptions): AsyncIterable<MemoryRecord>;
|
|
@@ -0,0 +1,65 @@
|
|
|
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`.
|
|
5
|
+
*/
|
|
6
|
+
import { createReadStream } from 'node:fs';
|
|
7
|
+
import { createInterface } from 'node:readline';
|
|
8
|
+
import { invalidRecord } from '../records.js';
|
|
9
|
+
import { record } from './text.js';
|
|
10
|
+
/** Records from a JSON Lines file; `map` may be given here or in the options. */
|
|
11
|
+
export async function* fromJsonl(path, map, opts = {}) {
|
|
12
|
+
const o = typeof map === 'function' ? { ...opts, map } : { ...map, ...opts };
|
|
13
|
+
const stats = o.stats ?? { lines: 0, records: 0, skipped: 0 };
|
|
14
|
+
const skip = (line, reason, n) => {
|
|
15
|
+
stats.skipped++;
|
|
16
|
+
o.onSkip?.(line, reason, n);
|
|
17
|
+
};
|
|
18
|
+
const lines = createInterface({ input: createReadStream(path, { encoding: 'utf8' }), crlfDelay: Infinity });
|
|
19
|
+
let n = 0;
|
|
20
|
+
for await (const raw of lines) {
|
|
21
|
+
n++;
|
|
22
|
+
const line = n === 1 ? raw.replace(/^/, '') : raw;
|
|
23
|
+
if (!line.trim())
|
|
24
|
+
continue;
|
|
25
|
+
stats.lines++;
|
|
26
|
+
let obj;
|
|
27
|
+
try {
|
|
28
|
+
obj = JSON.parse(line);
|
|
29
|
+
}
|
|
30
|
+
catch (e) {
|
|
31
|
+
skip(line, `not JSON: ${e.message}`, n);
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
let r;
|
|
35
|
+
if (o.map) {
|
|
36
|
+
try {
|
|
37
|
+
r = o.map(obj, n);
|
|
38
|
+
}
|
|
39
|
+
catch (e) {
|
|
40
|
+
skip(line, `map failed: ${e.message}`, n);
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
if (!r) {
|
|
44
|
+
skip(line, 'left out by map', n);
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
if (!obj || typeof obj !== 'object' || Array.isArray(obj)) {
|
|
50
|
+
skip(line, 'not an object', n);
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
const shaped = obj;
|
|
54
|
+
r = typeof shaped.id === 'number' ? { ...shaped, id: String(shaped.id) } : shaped;
|
|
55
|
+
}
|
|
56
|
+
const filled = record({ ...r, source: r.source ?? o.source, collection: r.collection ?? o.collection, kind: r.kind ?? o.kind });
|
|
57
|
+
const wrong = invalidRecord(filled);
|
|
58
|
+
if (wrong) {
|
|
59
|
+
skip(line, wrong, n);
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
stats.records++;
|
|
63
|
+
yield filled;
|
|
64
|
+
}
|
|
65
|
+
}
|