@hraness/wordcell 0.22.5 → 0.23.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/README.md +56 -18
  2. package/dist/authoring.js +2 -2
  3. package/dist/{authority-3ga47syb.js → authority-35cbvp1x.js} +2 -2
  4. package/dist/benchmark.js +3 -3
  5. package/dist/capture.js +6 -6
  6. package/dist/cli.js +26 -24
  7. package/dist/clip/acquire.js +3 -3
  8. package/dist/clip/args.js +1 -1
  9. package/dist/clip/cli.js +6 -6
  10. package/dist/clip/doctor.js +4 -4
  11. package/dist/evaluation-builder.js +10 -10
  12. package/dist/evaluation-kb.js +10 -10
  13. package/dist/graph-authority.js +4 -4
  14. package/dist/graph-percolation.js +6 -6
  15. package/dist/graph.js +3 -1
  16. package/dist/{index-bcknqxrq.js → index-0eacgvpv.js} +9 -1
  17. package/dist/{index-f75zmmdr.js → index-1er88ckw.js} +4 -4
  18. package/dist/{index-8v6k9h4r.js → index-1s8mc4hz.js} +1 -1
  19. package/dist/{index-de2w8crk.js → index-2fmnttca.js} +82 -15
  20. package/dist/{index-aer0jdrq.js → index-ahyhryb8.js} +1 -1
  21. package/dist/{index-f7fnww7a.js → index-b0khj0h5.js} +164 -9
  22. package/dist/{index-qdb8f8va.js → index-bdwcjvr4.js} +1 -1
  23. package/dist/{index-dfag79p7.js → index-byz4kzww.js} +3 -0
  24. package/dist/{index-hgve9rh2.js → index-cd75vky9.js} +96 -1
  25. package/dist/{index-n57tewfr.js → index-fp732bgg.js} +2 -2
  26. package/dist/{index-zy7an84p.js → index-jvb7w0gg.js} +5 -2
  27. package/dist/{index-f984hw45.js → index-k86wepd8.js} +1 -1
  28. package/dist/{index-1r5rnkfr.js → index-mnq2wy34.js} +2 -2
  29. package/dist/{index-p96kb6t2.js → index-nn67kzwa.js} +1 -1
  30. package/dist/{index-fc4dr114.js → index-nwrehke7.js} +1 -1
  31. package/dist/{index-n9kwenpt.js → index-p70t8j13.js} +1 -1
  32. package/dist/{index-pz2b2x0y.js → index-pgtm2nhf.js} +1 -1
  33. package/dist/{index-054mb7d3.js → index-qfqjfxaa.js} +10 -9
  34. package/dist/{index-xzvcw9ga.js → index-r0m5taz2.js} +2 -2
  35. package/dist/{index-h2qn3y3f.js → index-r4qs6vq4.js} +4 -4
  36. package/dist/{index-gtwqye5a.js → index-tcaq1c7f.js} +4 -4
  37. package/dist/{index-42tb8qqp.js → index-tvd2sqrx.js} +7 -7
  38. package/dist/{index-8bgnhjgq.js → index-vtwbapjr.js} +3810 -159
  39. package/dist/{index-g5vsqmdy.js → index-xwxy71ew.js} +190 -4
  40. package/dist/index.js +15 -13
  41. package/dist/pdf.js +3 -3
  42. package/dist/percolate.js +2 -2
  43. package/dist/portfolio.js +10 -10
  44. package/dist/publish.js +3 -3
  45. package/dist/sdk.js +9 -9
  46. package/dist/search.js +2 -2
  47. package/dist/semantic.js +4 -4
  48. package/dist/{support-jf9v10j7.js → support-c8hy7qfa.js} +25 -23
  49. package/dist/url-intelligence.js +1 -1
  50. package/dist/workflows/decision-context.js +10 -10
  51. package/dist/workflows/index.js +10 -10
  52. package/package.json +11 -4
  53. package/skills/wordcell/AGENTS.md +1 -0
  54. package/skills/wordcell/SKILL.md +5 -3
  55. package/skills/wordcell/references/query.md +35 -1
  56. package/skills/wordcell/references/session-memory.md +207 -0
  57. package/src/authoring-import.ts +32 -0
  58. package/src/authoring-model.ts +185 -2
  59. package/src/authoring-program.ts +19 -7
  60. package/src/cli-help.ts +893 -0
  61. package/src/cli-intro.ts +1 -1
  62. package/src/cli-program.ts +571 -80
  63. package/src/cli-style.ts +132 -0
  64. package/src/clip/acquire.ts +20 -0
  65. package/src/clip/args.ts +3 -0
  66. package/src/clip/cli.ts +82 -5
  67. package/src/clip/cookie-permission.ts +241 -0
  68. package/src/clip/url-metadata-cli.ts +6 -5
  69. package/src/clip/url-metadata.ts +14 -0
  70. package/src/graph.ts +9 -4
  71. package/src/import-supermemory.ts +1629 -0
  72. package/src/mcp-server.ts +415 -0
  73. package/src/mcp-tools.ts +1122 -0
  74. package/src/pdf/cli.ts +5 -4
  75. package/src/support-profile.ts +1 -1
  76. package/src/support.ts +3 -0
@@ -0,0 +1,1629 @@
1
+ /**
2
+ * Import supermemory API exports into vault notes.
3
+ *
4
+ * The importer reads export files only and never calls the network. It accepts
5
+ * list-documents pages (`POST /v3/documents/list`, `{ memories, pagination }`),
6
+ * memory-entry pages (`POST /v4/memories/list`, `{ memoryEntries, pagination }`),
7
+ * a `{ documents }` wrapper, an array of such pages, or a bare array of items.
8
+ * Every foreign value is parsed from `unknown` and bounded before it can reach
9
+ * a note.
10
+ */
11
+ import { lstat, mkdir, open, readdir, readFile } from "node:fs/promises";
12
+ import { basename, join } from "node:path";
13
+ import {
14
+ type AuthoringDependencies,
15
+ canonicalNoteId,
16
+ type CreateNoteInput,
17
+ frontmatter,
18
+ type FrontmatterFields,
19
+ type FrontmatterFieldUpdates,
20
+ type FrontmatterFieldValue,
21
+ isErrno,
22
+ MAX_NOTE_BYTES,
23
+ NoteAlreadyExistsError,
24
+ type NoteRevision,
25
+ NoteRevisionConflictError,
26
+ relationsFromParts,
27
+ renderCreatedNote,
28
+ renderUpdatedNoteBodyAndFields,
29
+ revisionFor,
30
+ sha256,
31
+ type Vault,
32
+ } from "./authoring-model.js";
33
+ import { createNoteWithFields, updateNoteBodyWithFields } from "./authoring-import.js";
34
+ import { assertExactDirectoryEntry, assertSafeParent, resolveVault } from "./authoring-platform.js";
35
+ import { addNoteRelation } from "./authoring.js";
36
+ import { slugify } from "./clip/lib.js";
37
+ import { sanitizeArtifactUrl } from "./clip/persist.js";
38
+ import { savedSourceProblem } from "./clip/url-metadata.js";
39
+ import { isMetadataNumber } from "./graph.js";
40
+ import { MAX_SCANNED_NOTES, MAX_VAULT_UTF8_BYTES, scanVault } from "./vault.js";
41
+
42
+ export const MAX_IMPORT_FILE_BYTES = 128 * 1024 * 1024;
43
+ export const MAX_IMPORT_FILES = 1_000;
44
+ /** One vault holds at most this many notes, so one import never plans more. */
45
+ export const MAX_IMPORT_ITEMS = MAX_SCANNED_NOTES;
46
+ export const MAX_METADATA_KEYS = 64;
47
+ export const MAX_METADATA_VALUE_CHARS = 1_024;
48
+ export const MAX_TITLE_CHARS = 200;
49
+ export const MAX_JSON_DEPTH = 32;
50
+ export const MAX_CONTAINER_TAGS = 64;
51
+ export const MAX_SOURCE_DOCUMENT_IDS = 256;
52
+ const MAX_CUSTOM_ID_CHARS = 255;
53
+ const MAX_FIELD_CHARS = 2_048;
54
+ // Titles are collapsed and capped, so a long or multi-line source title is fine.
55
+ const MAX_TITLE_SOURCE_CHARS = 65_536;
56
+ const READ_CHUNK_BYTES = 1024 * 1024;
57
+ /** More than the vault note cap plus the item cap, so a valid run never reaches it. */
58
+ const MAX_SLUG_ATTEMPTS = 25_000;
59
+
60
+ const EXTERNAL_ID = /^[A-Za-z0-9_-]{1,128}$/u;
61
+ const CONTAINER_TAG = /^[a-zA-Z0-9_:-]{1,100}$/u;
62
+ const ENUM_VALUE = /^[a-z][a-z0-9_]{0,63}$/u;
63
+ const METADATA_KEY = /^[A-Za-z0-9_.:-]{1,64}$/u;
64
+ const LONE_SURROGATE = /\p{Cs}/u;
65
+ const CONTROL_CHARACTER = /[\u0000-\u001f\u007f]/u;
66
+ const STRICT_TIMESTAMP =
67
+ /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,9}))?(Z|[+-]\d{2}:\d{2})$/u;
68
+
69
+ /** Document types documented for `POST /v3/documents/list` (checked 2026-09-26). */
70
+ export const SUPERMEMORY_DOCUMENT_TYPES: readonly string[] = [
71
+ "text", "pdf", "tweet", "google_doc", "google_slide", "google_sheet", "image",
72
+ "video", "audio", "notion_doc", "webpage", "onedrive", "github_markdown", "granola",
73
+ ];
74
+
75
+ /** Processing statuses documented for `POST /v3/documents/list` (checked 2026-09-26). */
76
+ export const SUPERMEMORY_STATUSES: readonly string[] = [
77
+ "unknown", "queued", "extracting", "chunking", "embedding", "indexing", "done", "failed",
78
+ ];
79
+
80
+ /** A failure that makes a whole export file unusable. Nothing is written. */
81
+ export class ImportFileError extends Error {
82
+ readonly file: string;
83
+ readonly pointer: string;
84
+
85
+ constructor(file: string, pointer: string, reason: string, options?: ErrorOptions) {
86
+ super(pointer === "" ? `${file}: ${reason}` : `${file}#${pointer}: ${reason}`, options);
87
+ this.name = "ImportFileError";
88
+ this.file = file;
89
+ this.pointer = pointer;
90
+ }
91
+ }
92
+
93
+ export type ImportItemKind = "document" | "memory";
94
+
95
+ /** One unvalidated export row and where it came from. */
96
+ export interface RawItem {
97
+ readonly file: string;
98
+ /** RFC 6901 JSON Pointer into the export file. */
99
+ readonly pointer: string;
100
+ readonly kind: ImportItemKind;
101
+ readonly value: unknown;
102
+ }
103
+
104
+ export type FlatMetadata = Readonly<Record<string, string | number | boolean>>;
105
+
106
+ export interface ImportDocument {
107
+ readonly kind: "document";
108
+ readonly file: string;
109
+ readonly pointer: string;
110
+ readonly externalId: string;
111
+ readonly customId?: string;
112
+ readonly title: string;
113
+ readonly slug: string;
114
+ readonly supermemoryType?: string;
115
+ readonly status?: string;
116
+ readonly containerTags: readonly string[];
117
+ readonly url?: string;
118
+ readonly connectionId?: string;
119
+ readonly filepath?: string;
120
+ readonly created: string;
121
+ readonly updated: string;
122
+ readonly metadata?: FlatMetadata;
123
+ /** Imported text with LF line endings and no trailing whitespace. */
124
+ readonly text: string;
125
+ readonly diagnostics: readonly string[];
126
+ }
127
+
128
+ export interface ImportMemory {
129
+ readonly kind: "memory";
130
+ readonly file: string;
131
+ readonly pointer: string;
132
+ readonly externalId: string;
133
+ readonly title: string;
134
+ readonly slug: string;
135
+ readonly version: number;
136
+ readonly isLatest: boolean;
137
+ readonly forgotten: boolean;
138
+ readonly parentId?: string;
139
+ readonly rootId?: string;
140
+ readonly isStatic: boolean;
141
+ readonly isInference: boolean;
142
+ readonly forgetAfter?: string;
143
+ readonly sourceDocumentIds: readonly string[];
144
+ readonly created: string;
145
+ readonly updated: string;
146
+ readonly metadata?: FlatMetadata;
147
+ readonly hasMemoryRelations: boolean;
148
+ readonly text: string;
149
+ readonly diagnostics: readonly string[];
150
+ }
151
+
152
+ export type ImportItem = ImportDocument | ImportMemory;
153
+
154
+ export interface ImportRejection {
155
+ readonly file: string;
156
+ readonly pointer: string;
157
+ readonly externalId?: string;
158
+ readonly reasons: readonly string[];
159
+ }
160
+
161
+ export type Validated<T> =
162
+ | { readonly ok: true; readonly item: T }
163
+ | { readonly ok: false; readonly rejection: ImportRejection };
164
+
165
+ type JsonRecord = Readonly<Record<string, unknown>>;
166
+
167
+ function isRecord(value: unknown): value is JsonRecord {
168
+ return typeof value === "object" && value !== null && !Array.isArray(value);
169
+ }
170
+
171
+ function pointerSegment(segment: string | number): string {
172
+ return `/${String(segment).replaceAll("~", "~0").replaceAll("/", "~1")}`;
173
+ }
174
+
175
+ function errorMessage(error: unknown): string {
176
+ return error instanceof Error ? error.message : String(error);
177
+ }
178
+
179
+ /** Read one export file as fatal UTF-8 without holding more than the bound. */
180
+ export async function readExportFile(
181
+ path: string,
182
+ file: string,
183
+ maximumBytes: number = MAX_IMPORT_FILE_BYTES,
184
+ ): Promise<string> {
185
+ let handle;
186
+ try {
187
+ handle = await open(path, "r");
188
+ } catch (error) {
189
+ throw new ImportFileError(file, "", `cannot read export file (${errorMessage(error)})`, { cause: error });
190
+ }
191
+ try {
192
+ const chunks: Uint8Array[] = [];
193
+ let total = 0;
194
+ for (;;) {
195
+ const chunk = new Uint8Array(Math.min(READ_CHUNK_BYTES, maximumBytes + 1 - total));
196
+ let bytesRead: number;
197
+ try {
198
+ ({ bytesRead } = await handle.read(chunk, 0, chunk.byteLength, null));
199
+ } catch (error) {
200
+ throw new ImportFileError(file, "", `cannot read export file (${errorMessage(error)})`, { cause: error });
201
+ }
202
+ if (bytesRead === 0) break;
203
+ total += bytesRead;
204
+ if (total > maximumBytes) {
205
+ throw new ImportFileError(file, "", `export file exceeds the ${maximumBytes}-byte limit`);
206
+ }
207
+ chunks.push(chunk.subarray(0, bytesRead));
208
+ }
209
+ try {
210
+ return new TextDecoder("utf-8", { fatal: true }).decode(Buffer.concat(chunks, total));
211
+ } catch (error) {
212
+ throw new ImportFileError(file, "", "export file is not valid UTF-8", { cause: error });
213
+ }
214
+ } finally {
215
+ await handle.close();
216
+ }
217
+ }
218
+
219
+ /** Reject nesting deeper than the bound before JSON.parse can recurse into it. */
220
+ function assertJsonDepth(text: string, file: string): void {
221
+ let depth = 0;
222
+ let inString = false;
223
+ let escaped = false;
224
+ for (let index = 0; index < text.length; index += 1) {
225
+ const code = text.charCodeAt(index);
226
+ if (inString) {
227
+ if (escaped) escaped = false;
228
+ else if (code === 0x5c) escaped = true;
229
+ else if (code === 0x22) inString = false;
230
+ continue;
231
+ }
232
+ if (code === 0x22) inString = true;
233
+ else if (code === 0x5b || code === 0x7b) {
234
+ depth += 1;
235
+ if (depth > MAX_JSON_DEPTH) {
236
+ throw new ImportFileError(file, "", `JSON nesting exceeds ${MAX_JSON_DEPTH} levels`);
237
+ }
238
+ } else if (code === 0x5d || code === 0x7d) depth -= 1;
239
+ }
240
+ }
241
+
242
+ const PAGE_KEYS = ["memories", "memoryEntries", "documents"] as const;
243
+
244
+ function isPage(value: unknown): value is JsonRecord {
245
+ return isRecord(value) && PAGE_KEYS.some((key) => Array.isArray(value[key]));
246
+ }
247
+
248
+ function bareItemKind(value: unknown): ImportItemKind {
249
+ return isRecord(value) && typeof value.memory === "string" && typeof value.version === "number"
250
+ ? "memory"
251
+ : "document";
252
+ }
253
+
254
+ /**
255
+ * Parse one export file into raw rows with JSON Pointers. File-level problems
256
+ * throw `ImportFileError`; row problems are left to validation.
257
+ */
258
+ export function parseExportText(text: string, file: string): readonly RawItem[] {
259
+ const source = text.startsWith("\uFEFF") ? text.slice(1) : text;
260
+ assertJsonDepth(source, file);
261
+ let root: unknown;
262
+ try {
263
+ root = JSON.parse(source) as unknown;
264
+ } catch (error) {
265
+ throw new ImportFileError(file, "", `not valid JSON (${errorMessage(error)})`, { cause: error });
266
+ }
267
+ const items: RawItem[] = [];
268
+ const push = (pointer: string, kind: ImportItemKind, value: unknown): void => {
269
+ if (items.length >= MAX_IMPORT_ITEMS) {
270
+ throw new ImportFileError(file, pointer, `export holds more than ${MAX_IMPORT_ITEMS} items`);
271
+ }
272
+ items.push({ file, pointer, kind, value });
273
+ };
274
+ const collectPage = (page: JsonRecord, base: string): void => {
275
+ for (const key of PAGE_KEYS) {
276
+ const rows = page[key];
277
+ if (rows === undefined) continue;
278
+ if (!Array.isArray(rows)) {
279
+ throw new ImportFileError(file, `${base}${pointerSegment(key)}`, `${key} must be an array`);
280
+ }
281
+ rows.forEach((value: unknown, index) => {
282
+ const pointer = `${base}${pointerSegment(key)}${pointerSegment(index)}`;
283
+ const kind = key === "memories" ? "document" : key === "memoryEntries" ? "memory" : bareItemKind(value);
284
+ push(pointer, kind, value);
285
+ });
286
+ }
287
+ const pagination = page.pagination;
288
+ if (pagination !== undefined && !isRecord(pagination)) {
289
+ throw new ImportFileError(file, `${base}/pagination`, "pagination must be an object");
290
+ }
291
+ };
292
+ if (Array.isArray(root)) {
293
+ if (root.length > 0 && root.every(isPage)) {
294
+ root.forEach((page: JsonRecord, index) => collectPage(page, pointerSegment(index)));
295
+ } else {
296
+ root.forEach((value: unknown, index) => push(pointerSegment(index), bareItemKind(value), value));
297
+ }
298
+ } else if (isPage(root)) {
299
+ collectPage(root, "");
300
+ } else {
301
+ throw new ImportFileError(
302
+ file,
303
+ "",
304
+ "unknown export shape: expected {memories}, {memoryEntries}, {documents}, or an array",
305
+ );
306
+ }
307
+ return items;
308
+ }
309
+
310
+ function daysInMonth(year: number, month: number): number {
311
+ if (month === 2) return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0) ? 29 : 28;
312
+ return [4, 6, 9, 11].includes(month) ? 30 : 31;
313
+ }
314
+
315
+ /**
316
+ * Parse an RFC 3339 timestamp with an explicit offset and return the UTC
317
+ * instant as `Date#toISOString()` text, or `undefined` when it is not valid.
318
+ * Fractions past milliseconds are truncated.
319
+ */
320
+ export function parseStrictTimestamp(value: unknown): string | undefined {
321
+ if (typeof value !== "string") return undefined;
322
+ const match = STRICT_TIMESTAMP.exec(value);
323
+ if (match === null) return undefined;
324
+ const [year, month, day, hour, minute, second] = match.slice(1, 7).map(Number) as [
325
+ number, number, number, number, number, number,
326
+ ];
327
+ const fraction = match[7] ?? "";
328
+ const zone = match[8] ?? "Z";
329
+ if (month < 1 || month > 12 || day < 1 || day > daysInMonth(year, month)) return undefined;
330
+ if (hour > 23 || minute > 59 || second > 59) return undefined;
331
+ let offsetMinutes = 0;
332
+ if (zone !== "Z") {
333
+ const offsetHour = Number(zone.slice(1, 3));
334
+ const offsetMinute = Number(zone.slice(4, 6));
335
+ if (offsetHour > 23 || offsetMinute > 59) return undefined;
336
+ offsetMinutes = (zone.startsWith("-") ? -1 : 1) * (offsetHour * 60 + offsetMinute);
337
+ }
338
+ const milliseconds = Number(fraction.padEnd(3, "0").slice(0, 3));
339
+ const instant = new Date(0);
340
+ instant.setUTCFullYear(year, month - 1, day);
341
+ instant.setUTCHours(hour, minute, second, milliseconds);
342
+ const time = instant.getTime() - offsetMinutes * 60_000;
343
+ if (!Number.isFinite(time)) return undefined;
344
+ const result = new Date(time);
345
+ const resultYear = result.getUTCFullYear();
346
+ if (resultYear < 0 || resultYear > 9999) return undefined;
347
+ return result.toISOString();
348
+ }
349
+
350
+ /** Why a string cannot be copied into a note, or `undefined` when it can. */
351
+ function stringProblem(value: string, maximum: number, singleLine: boolean): string | undefined {
352
+ if (LONE_SURROGATE.test(value)) return "is not well-formed Unicode";
353
+ if (value.includes("\u0000")) return "contains a NUL character";
354
+ if (singleLine && CONTROL_CHARACTER.test(value)) return "contains control characters";
355
+ if (value.length > maximum) return `is longer than ${maximum} characters`;
356
+ return undefined;
357
+ }
358
+
359
+ export interface FlatMetadataResult {
360
+ readonly value?: FlatMetadata;
361
+ readonly diagnostics: readonly string[];
362
+ }
363
+
364
+ /**
365
+ * Keep the flat, printable part of a metadata object: strings, numbers the
366
+ * vault can read back (finite, and a safe integer when integral), and booleans
367
+ * under safe keys, sorted by key. Everything else is dropped with
368
+ * a diagnostic, except `null`, which means "no value".
369
+ */
370
+ export function flatMetadata(value: unknown, label = "metadata"): FlatMetadataResult {
371
+ if (value === undefined || value === null) return { diagnostics: [] };
372
+ if (!isRecord(value)) return { diagnostics: [`${label}: expected an object; dropped`] };
373
+ const diagnostics: string[] = [];
374
+ const kept: [string, string | number | boolean][] = [];
375
+ for (const key of Object.keys(value).sort()) {
376
+ const entry = value[key];
377
+ if (entry === null || entry === undefined) continue;
378
+ const name = JSON.stringify(key.length > 64 ? `${key.slice(0, 64)}…` : key);
379
+ if (!METADATA_KEY.test(key) || key === "__proto__") {
380
+ diagnostics.push(`${label} key ${name} is not a safe key; dropped`);
381
+ continue;
382
+ }
383
+ if (typeof entry === "string") {
384
+ const problem = stringProblem(entry, MAX_METADATA_VALUE_CHARS, true);
385
+ if (problem !== undefined) {
386
+ diagnostics.push(`${label}.${key} ${problem}; dropped`);
387
+ continue;
388
+ }
389
+ } else if (typeof entry === "number") {
390
+ if (!Number.isFinite(entry)) {
391
+ diagnostics.push(`${label}.${key} is not a finite number; dropped`);
392
+ continue;
393
+ }
394
+ if (!isMetadataNumber(entry)) {
395
+ diagnostics.push(`${label}.${key} is an integer outside the safe range; dropped`);
396
+ continue;
397
+ }
398
+ } else if (typeof entry !== "boolean") {
399
+ diagnostics.push(`${label}.${key} is a nested value; dropped`);
400
+ continue;
401
+ }
402
+ if (kept.length >= MAX_METADATA_KEYS) {
403
+ diagnostics.push(`${label}.${key} is past the ${MAX_METADATA_KEYS}-key limit; dropped`);
404
+ continue;
405
+ }
406
+ kept.push([key, entry]);
407
+ }
408
+ return kept.length === 0 ? { diagnostics } : { value: Object.fromEntries(kept), diagnostics };
409
+ }
410
+
411
+ /** LF line endings, no leading blank lines, and no trailing whitespace. */
412
+ export function normalizeText(value: string): string {
413
+ return value.replace(/\r\n?/gu, "\n").replace(/^(?:[ \t]*\n)+/u, "").trimEnd();
414
+ }
415
+
416
+ /** Collapse whitespace and control characters into one line and cap its length. */
417
+ function cleanTitle(value: string | undefined): string {
418
+ if (value === undefined) return "";
419
+ const single = value.replace(/[\s\p{Cc}]+/gu, " ").trim();
420
+ const characters = Array.from(single);
421
+ if (characters.length <= MAX_TITLE_CHARS) return single;
422
+ return `${characters.slice(0, MAX_TITLE_CHARS - 1).join("").trimEnd()}…`;
423
+ }
424
+
425
+ /** The first non-empty cleaned title among the candidates. */
426
+ export function titleFor(candidates: readonly (string | undefined)[]): string {
427
+ for (const candidate of candidates) {
428
+ const title = cleanTitle(candidate);
429
+ if (title !== "") return title;
430
+ }
431
+ return "Untitled";
432
+ }
433
+
434
+ /** The first non-empty clip slug among the candidates. */
435
+ export function slugFor(candidates: readonly (string | undefined)[]): string {
436
+ for (const candidate of candidates) {
437
+ if (candidate === undefined) continue;
438
+ const slug = slugify(candidate);
439
+ if (slug !== "") return slug;
440
+ }
441
+ return "untitled";
442
+ }
443
+
444
+ /** Collects per-field reasons (rejections) and diagnostics (dropped values). */
445
+ class FieldReader {
446
+ readonly reasons: string[] = [];
447
+ readonly diagnostics: string[] = [];
448
+ private readonly record: JsonRecord;
449
+
450
+ constructor(record: JsonRecord) {
451
+ this.record = record;
452
+ }
453
+
454
+ id(key: string, required: boolean): string | undefined {
455
+ const value = this.record[key];
456
+ if (value === undefined || value === null) {
457
+ if (required) this.reasons.push(`${key}: missing`);
458
+ return undefined;
459
+ }
460
+ if (typeof value !== "string" || !EXTERNAL_ID.test(value)) {
461
+ this.reasons.push(`${key}: expected an ID of 1 to 128 letters, digits, "_" or "-"`);
462
+ return undefined;
463
+ }
464
+ return value;
465
+ }
466
+
467
+ timestamp(key: string): string | undefined {
468
+ const value = this.record[key];
469
+ if (value === undefined || value === null) {
470
+ this.reasons.push(`${key}: missing`);
471
+ return undefined;
472
+ }
473
+ const parsed = parseStrictTimestamp(value);
474
+ if (parsed === undefined) this.reasons.push(`${key}: expected an RFC 3339 timestamp with an offset`);
475
+ return parsed;
476
+ }
477
+
478
+ optionalString(key: string, maximum: number = MAX_FIELD_CHARS, singleLine = true): string | undefined {
479
+ const value = this.record[key];
480
+ if (value === undefined || value === null || value === "") return undefined;
481
+ if (typeof value !== "string") {
482
+ this.reasons.push(`${key}: expected a string or null`);
483
+ return undefined;
484
+ }
485
+ const problem = stringProblem(value, maximum, singleLine);
486
+ if (problem !== undefined) {
487
+ this.reasons.push(`${key}: ${problem}`);
488
+ return undefined;
489
+ }
490
+ return value;
491
+ }
492
+
493
+ optionalEnum(key: string, known: readonly string[]): string | undefined {
494
+ const value = this.optionalString(key, 64);
495
+ if (value === undefined) return undefined;
496
+ if (!ENUM_VALUE.test(value)) {
497
+ this.reasons.push(`${key}: expected a lower-case identifier`);
498
+ return undefined;
499
+ }
500
+ if (!known.includes(value)) this.diagnostics.push(`${key} "${value}" is not a documented value`);
501
+ return value;
502
+ }
503
+
504
+ boolean(key: string, fallback: boolean): boolean {
505
+ const value = this.record[key];
506
+ if (value === undefined || value === null) return fallback;
507
+ if (typeof value !== "boolean") {
508
+ this.reasons.push(`${key}: expected a boolean`);
509
+ return fallback;
510
+ }
511
+ return value;
512
+ }
513
+
514
+ /** The first non-blank text among the keys, with LF line endings and no trailing whitespace. */
515
+ body(keys: readonly string[]): string | undefined {
516
+ for (const key of keys) {
517
+ const value = this.record[key];
518
+ if (value === undefined || value === null) continue;
519
+ if (typeof value !== "string") {
520
+ this.reasons.push(`${key}: expected a string or null`);
521
+ return undefined;
522
+ }
523
+ const text = normalizeText(value);
524
+ if (text.trim() === "") continue;
525
+ const problem = stringProblem(text, Number.POSITIVE_INFINITY, false);
526
+ if (problem !== undefined) {
527
+ this.reasons.push(`${key}: ${problem}`);
528
+ return undefined;
529
+ }
530
+ if (Buffer.byteLength(text, "utf8") > MAX_NOTE_BYTES) {
531
+ this.reasons.push(`${key}: body exceeds the ${MAX_NOTE_BYTES}-byte note limit`);
532
+ return undefined;
533
+ }
534
+ return text;
535
+ }
536
+ this.reasons.push(`empty body: no non-blank ${keys.join(" or ")}`);
537
+ return undefined;
538
+ }
539
+
540
+ idList(key: string, maximum: number): readonly string[] {
541
+ const value = this.record[key];
542
+ if (value === undefined || value === null) return [];
543
+ if (!Array.isArray(value)) {
544
+ this.diagnostics.push(`${key}: expected an array; dropped`);
545
+ return [];
546
+ }
547
+ const kept: string[] = [];
548
+ value.forEach((entry: unknown, index) => {
549
+ if (typeof entry !== "string" || !EXTERNAL_ID.test(entry)) {
550
+ this.diagnostics.push(`${key}/${index}: not a valid ID; dropped`);
551
+ } else if (kept.length >= maximum) {
552
+ this.diagnostics.push(`${key}/${index}: past the ${maximum}-entry limit; dropped`);
553
+ } else if (!kept.includes(entry)) kept.push(entry);
554
+ });
555
+ return kept;
556
+ }
557
+ }
558
+
559
+ function rejection(raw: RawItem, reasons: readonly string[], externalId?: string): Validated<never> {
560
+ return {
561
+ ok: false,
562
+ rejection: {
563
+ file: raw.file,
564
+ pointer: raw.pointer,
565
+ ...(externalId === undefined ? {} : { externalId }),
566
+ reasons,
567
+ },
568
+ };
569
+ }
570
+
571
+ function externalIdOf(value: unknown): string | undefined {
572
+ return isRecord(value) && typeof value.id === "string" && EXTERNAL_ID.test(value.id)
573
+ ? value.id
574
+ : undefined;
575
+ }
576
+
577
+ /** Validate one list-documents row. */
578
+ export function validateDocument(raw: RawItem): Validated<ImportDocument> {
579
+ if (!isRecord(raw.value)) return rejection(raw, ["expected an object"]);
580
+ const fields = new FieldReader(raw.value);
581
+ const externalId = fields.id("id", true);
582
+ const customId = fields.optionalString("customId", MAX_CUSTOM_ID_CHARS);
583
+ const rawTitle = fields.optionalString("title", MAX_TITLE_SOURCE_CHARS, false);
584
+ const supermemoryType = fields.optionalEnum("type", SUPERMEMORY_DOCUMENT_TYPES);
585
+ const status = fields.optionalEnum("status", SUPERMEMORY_STATUSES);
586
+ const url = fields.optionalString("url");
587
+ const connectionId = fields.optionalString("connectionId", 128);
588
+ const filepath = fields.optionalString("filepath");
589
+ const created = fields.timestamp("createdAt");
590
+ const updated = fields.timestamp("updatedAt");
591
+ const text = fields.body(["content", "summary"]);
592
+ const containerTags: string[] = [];
593
+ const tags = raw.value.containerTags;
594
+ if (Array.isArray(tags)) {
595
+ tags.forEach((tag: unknown, index) => {
596
+ if (typeof tag !== "string" || !CONTAINER_TAG.test(tag)) {
597
+ fields.diagnostics.push(`containerTags/${index}: not a valid container tag; dropped`);
598
+ } else if (containerTags.length >= MAX_CONTAINER_TAGS) {
599
+ fields.diagnostics.push(`containerTags/${index}: past the ${MAX_CONTAINER_TAGS}-tag limit; dropped`);
600
+ } else if (!containerTags.includes(tag)) containerTags.push(tag);
601
+ });
602
+ } else if (tags !== undefined && tags !== null) {
603
+ fields.diagnostics.push("containerTags: expected an array; dropped");
604
+ }
605
+ const metadata = flatMetadata(raw.value.metadata);
606
+ if (
607
+ fields.reasons.length > 0
608
+ || externalId === undefined
609
+ || created === undefined
610
+ || updated === undefined
611
+ || text === undefined
612
+ ) {
613
+ return rejection(raw, fields.reasons, externalIdOf(raw.value));
614
+ }
615
+ return {
616
+ ok: true,
617
+ item: {
618
+ kind: "document",
619
+ file: raw.file,
620
+ pointer: raw.pointer,
621
+ externalId,
622
+ ...(customId === undefined ? {} : { customId }),
623
+ title: titleFor([rawTitle, customId, externalId]),
624
+ slug: slugFor([customId, rawTitle, externalId]),
625
+ ...(supermemoryType === undefined ? {} : { supermemoryType }),
626
+ ...(status === undefined ? {} : { status }),
627
+ containerTags,
628
+ ...(url === undefined ? {} : { url }),
629
+ ...(connectionId === undefined ? {} : { connectionId }),
630
+ ...(filepath === undefined ? {} : { filepath }),
631
+ created,
632
+ updated,
633
+ ...(metadata.value === undefined ? {} : { metadata: metadata.value }),
634
+ text,
635
+ diagnostics: [...fields.diagnostics, ...metadata.diagnostics],
636
+ },
637
+ };
638
+ }
639
+
640
+ function validateMemoryVersion(
641
+ raw: RawItem,
642
+ value: unknown,
643
+ pointer: string,
644
+ history: boolean,
645
+ ): Validated<ImportMemory> {
646
+ const at: RawItem = { ...raw, pointer, value };
647
+ if (!isRecord(value)) return rejection(at, ["expected an object"]);
648
+ const fields = new FieldReader(value);
649
+ const externalId = fields.id("id", true);
650
+ const parentId = fields.id("parentMemoryId", false);
651
+ const rootId = fields.id("rootMemoryId", false);
652
+ const version = value.version;
653
+ if (typeof version !== "number" || !Number.isFinite(version)) {
654
+ fields.reasons.push("version: expected a finite number");
655
+ } else if (!isMetadataNumber(version)) {
656
+ fields.reasons.push("version: integer outside the safe range");
657
+ }
658
+ const created = fields.timestamp("createdAt");
659
+ const updated = fields.timestamp("updatedAt");
660
+ const isLatest = fields.boolean("isLatest", !history);
661
+ const forgotten = fields.boolean("isForgotten", false);
662
+ const isStatic = history ? false : fields.boolean("isStatic", false);
663
+ const isInference = history ? false : fields.boolean("isInference", false);
664
+ const text = fields.body(["memory"]);
665
+ let forgetAfter: string | undefined;
666
+ if (!history && value.forgetAfter !== undefined && value.forgetAfter !== null) {
667
+ forgetAfter = parseStrictTimestamp(value.forgetAfter);
668
+ if (forgetAfter === undefined) fields.diagnostics.push("forgetAfter: not a valid timestamp; dropped");
669
+ }
670
+ const sourceDocumentIds = history ? [] : fields.idList("documentIds", MAX_SOURCE_DOCUMENT_IDS);
671
+ const metadata = history ? { diagnostics: [] } : flatMetadata(value.metadata);
672
+ const relations = value.memoryRelations;
673
+ const hasMemoryRelations = !history && isRecord(relations) && Object.keys(relations).length > 0;
674
+ if (
675
+ fields.reasons.length > 0
676
+ || externalId === undefined
677
+ || typeof version !== "number"
678
+ || created === undefined
679
+ || updated === undefined
680
+ || text === undefined
681
+ ) {
682
+ return rejection(at, fields.reasons, externalIdOf(value));
683
+ }
684
+ const title = titleFor([text.split("\n").find((line) => line.trim() !== ""), externalId]);
685
+ return {
686
+ ok: true,
687
+ item: {
688
+ kind: "memory",
689
+ file: raw.file,
690
+ pointer,
691
+ externalId,
692
+ title,
693
+ slug: slugFor([title, externalId]),
694
+ version,
695
+ isLatest,
696
+ forgotten,
697
+ ...(parentId === undefined ? {} : { parentId }),
698
+ ...(rootId === undefined ? {} : { rootId }),
699
+ isStatic,
700
+ isInference,
701
+ ...(forgetAfter === undefined ? {} : { forgetAfter }),
702
+ sourceDocumentIds,
703
+ created,
704
+ updated,
705
+ ...(metadata.value === undefined ? {} : { metadata: metadata.value }),
706
+ hasMemoryRelations,
707
+ text,
708
+ diagnostics: [...fields.diagnostics, ...metadata.diagnostics],
709
+ },
710
+ };
711
+ }
712
+
713
+ /**
714
+ * Validate one memory entry and flatten its `history` into one result per
715
+ * version. History pointers end in `/history/<index>`.
716
+ */
717
+ export function validateMemoryEntry(raw: RawItem): readonly Validated<ImportMemory>[] {
718
+ const results = [validateMemoryVersion(raw, raw.value, raw.pointer, false)];
719
+ if (!isRecord(raw.value)) return results;
720
+ const history = raw.value.history;
721
+ if (history === undefined || history === null) return results;
722
+ if (!Array.isArray(history)) {
723
+ const latest = results[0];
724
+ if (latest?.ok === true) {
725
+ results[0] = {
726
+ ok: true,
727
+ item: { ...latest.item, diagnostics: [...latest.item.diagnostics, "history: expected an array; dropped"] },
728
+ };
729
+ }
730
+ return results;
731
+ }
732
+ history.forEach((entry: unknown, index) => {
733
+ results.push(validateMemoryVersion(raw, entry, `${raw.pointer}/history/${index}`, true));
734
+ });
735
+ return results;
736
+ }
737
+
738
+ /** Validate a raw row of either kind. */
739
+ export function validateRawItem(raw: RawItem): readonly Validated<ImportItem>[] {
740
+ return raw.kind === "memory" ? validateMemoryEntry(raw) : [validateDocument(raw)];
741
+ }
742
+
743
+ // ---------------------------------------------------------------------------
744
+ // Planning: pure, deterministic, and free of file-system access.
745
+
746
+ export type ImportOutcome = "created" | "updated" | "skipped" | "conflict" | "rejected";
747
+
748
+ export interface PlannedCreate {
749
+ readonly kind: "create";
750
+ readonly input: CreateNoteInput & { readonly body: string };
751
+ readonly fields: FrontmatterFields;
752
+ }
753
+
754
+ export interface PlannedUpdate {
755
+ readonly kind: "update";
756
+ readonly body: string;
757
+ readonly expectedRevision: NoteRevision;
758
+ readonly fields: FrontmatterFieldUpdates;
759
+ }
760
+
761
+ export interface PlannedItem {
762
+ readonly outcome: ImportOutcome;
763
+ readonly file: string;
764
+ readonly pointer: string;
765
+ readonly externalId?: string;
766
+ /** Extensionless vault-relative note ID. */
767
+ readonly note?: string;
768
+ readonly reason?: string;
769
+ readonly diagnostics: readonly string[];
770
+ readonly write?: PlannedCreate | PlannedUpdate;
771
+ }
772
+
773
+ export interface PlannedRelation {
774
+ readonly source: string;
775
+ readonly predicate: "supersedes";
776
+ readonly target: string;
777
+ }
778
+
779
+ export interface ImportPlan {
780
+ readonly items: readonly PlannedItem[];
781
+ readonly relations: readonly PlannedRelation[];
782
+ readonly diagnostics: readonly string[];
783
+ /** Notes the plan creates and the UTF-8 bytes its writes add to the vault. */
784
+ readonly growth: { readonly notes: number; readonly bytes: number };
785
+ }
786
+
787
+ /** The part of a scanned note that planning reads. */
788
+ export interface ExistingNote {
789
+ readonly id: string;
790
+ /** Vault-relative path, used for frontmatter errors. */
791
+ readonly path: string;
792
+ readonly content: string;
793
+ readonly metadata: Readonly<Record<string, unknown>>;
794
+ /** Targets of the note's `supersedes` relations; read from `content` when absent. */
795
+ readonly supersedes?: readonly string[];
796
+ /** Revision of the bytes on disk; computed from `content` when absent. */
797
+ readonly revision?: NoteRevision;
798
+ }
799
+
800
+ export interface PlanImportInput {
801
+ readonly items: readonly Validated<ImportItem>[];
802
+ readonly existing: readonly ExistingNote[];
803
+ /** `occupancyKey` of every taken note ID and placement-directory entry. */
804
+ readonly occupied: ReadonlySet<string>;
805
+ readonly prefix?: string;
806
+ readonly now: Date;
807
+ }
808
+
809
+ export const ARTICLES_DIRECTORY = "articles";
810
+ export const IMPORTED_NOTES_DIRECTORY = "notes/imported";
811
+ export const IMPORTED_MEMORIES_DIRECTORY = "notes/imported/memories";
812
+
813
+ const DOCUMENT_KEYS: readonly string[] = [
814
+ "imported_from", "external_id", "custom_id", "supermemory_type", "status", "container_tag",
815
+ "container_tags", "source", "url", "connection_id", "filepath", "clipped", "created", "updated",
816
+ "metadata", "import_digest",
817
+ ];
818
+ const MEMORY_KEYS: readonly string[] = [
819
+ "imported_from", "external_id", "version", "parent_id", "root_id", "is_static", "is_inference",
820
+ "forget_after", "source_document_ids", "created", "updated", "metadata", "import_digest",
821
+ ];
822
+ const PROVENANCE_LINE = /\n\nImported from supermemory export `[^`\n]*` on \d{4}-\d{2}-\d{2}\.$/u;
823
+
824
+ /** Case-folded key for occupancy checks on case-insensitive volumes. */
825
+ export function occupancyKey(value: string): string {
826
+ return value.normalize("NFC").toLowerCase();
827
+ }
828
+
829
+ /** Validate `--prefix` as a vault-relative directory note-ID prefix. */
830
+ export function normalizePrefix(prefix: string): string {
831
+ const trimmed = prefix.replace(/\/+$/u, "");
832
+ let canonical: string | undefined;
833
+ try {
834
+ canonical = canonicalNoteId(trimmed);
835
+ } catch {
836
+ canonical = undefined;
837
+ }
838
+ if (canonical !== trimmed || trimmed === "") {
839
+ throw new TypeError("--prefix must be a vault-relative directory such as notes/imported");
840
+ }
841
+ const key = occupancyKey(trimmed);
842
+ if (key === ARTICLES_DIRECTORY || key.startsWith(`${ARTICLES_DIRECTORY}/`)) {
843
+ throw new TypeError(
844
+ "--prefix must be outside articles/, where each directory holds one captured source; omit --prefix to place web documents there",
845
+ );
846
+ }
847
+ return trimmed;
848
+ }
849
+
850
+ type UrlPlacement =
851
+ | { readonly kind: "source" | "url"; readonly value: string; readonly diagnostic?: string }
852
+ | { readonly kind: "dropped"; readonly diagnostic: string };
853
+
854
+ /** Userinfo, or path, query, or fragment data that looks like a credential. */
855
+ function credentialShaped(url: string): boolean {
856
+ let parsed: URL;
857
+ try {
858
+ parsed = new URL(url);
859
+ } catch {
860
+ return false;
861
+ }
862
+ if (parsed.username !== "" || parsed.password !== "") return true;
863
+ const probe = parsed.protocol === "http:" || parsed.protocol === "https:"
864
+ ? parsed.href
865
+ : `https://supermemory.invalid/${parsed.pathname.replace(/^\/+/u, "")}${parsed.search}${parsed.hash}`;
866
+ try {
867
+ return sanitizeArtifactUrl(probe) !== new URL(probe).href;
868
+ } catch {
869
+ return true;
870
+ }
871
+ }
872
+
873
+ /**
874
+ * Where a document URL goes: `source` only when `url-metadata` accepts it as a
875
+ * saved article source, `url` otherwise, and nowhere when it carries a
876
+ * credential.
877
+ */
878
+ function urlPlacement(url: string): UrlPlacement {
879
+ if (credentialShaped(url)) {
880
+ return { kind: "dropped", diagnostic: "url carries credential-shaped data; dropped" };
881
+ }
882
+ if (!/^https?:/iu.test(url)) return { kind: "url", value: url };
883
+ const problem = savedSourceProblem(url);
884
+ return problem === undefined
885
+ ? { kind: "source", value: url }
886
+ : { kind: "url", value: url, diagnostic: `url is kept as url, not source: ${problem}` };
887
+ }
888
+
889
+ function noteType(item: ImportItem): "article" | "note" | "memory" {
890
+ if (item.kind === "memory") return "memory";
891
+ const url = item.url;
892
+ return (url !== undefined && urlPlacement(url).kind === "source")
893
+ || (item.supermemoryType !== undefined && item.supermemoryType !== "text")
894
+ ? "article"
895
+ : "note";
896
+ }
897
+
898
+ interface Placement {
899
+ readonly id: string;
900
+ /** Occupancy keys that must all be free before the ID can be allocated. */
901
+ readonly keys: readonly string[];
902
+ }
903
+
904
+ /**
905
+ * The extensionless note ID for a slug under the item's placement. Only the
906
+ * default clip layout `articles/<slug>/<slug>` also claims its directory,
907
+ * because each article directory holds exactly one captured source.
908
+ */
909
+ function placement(item: ImportItem, slug: string, prefix: string | undefined): Placement {
910
+ if (prefix !== undefined) {
911
+ const id = `${prefix}/${slug}`;
912
+ return { id, keys: [occupancyKey(id)] };
913
+ }
914
+ if (item.kind === "memory") {
915
+ const id = `${IMPORTED_MEMORIES_DIRECTORY}/${slug}`;
916
+ return { id, keys: [occupancyKey(id)] };
917
+ }
918
+ if (noteType(item) !== "article") {
919
+ const id = `${IMPORTED_NOTES_DIRECTORY}/${slug}`;
920
+ return { id, keys: [occupancyKey(id)] };
921
+ }
922
+ const directory = `${ARTICLES_DIRECTORY}/${slug}`;
923
+ const id = `${directory}/${slug}`;
924
+ return { id, keys: [occupancyKey(id), occupancyKey(directory)] };
925
+ }
926
+
927
+ function sortedJson(value: unknown): unknown {
928
+ if (Array.isArray(value)) return value.map(sortedJson);
929
+ if (isRecord(value)) {
930
+ return Object.fromEntries(Object.keys(value).sort().map((key) => [key, sortedJson(value[key])]));
931
+ }
932
+ return value;
933
+ }
934
+
935
+ /** `sha256:<hex>` over the title, importer-owned fields, and imported text. */
936
+ export function importDigest(title: string, fields: Readonly<Record<string, unknown>>, text: string): string {
937
+ const owned = Object.fromEntries(Object.entries(fields).filter(([key]) => key !== "import_digest"));
938
+ return `sha256:${sha256(JSON.stringify(sortedJson({ title, fields: owned, text })))}`;
939
+ }
940
+
941
+ /** Provenance paragraph appended to every imported body, dated with the local calendar day. */
942
+ export function provenanceLine(file: string, now: Date): string {
943
+ const name = basename(file).replace(/[`\u0000-\u001f\u007f]/gu, "_");
944
+ return `Imported from supermemory export \`${name}\` on ${localCalendarDate(now)}.`;
945
+ }
946
+
947
+ /** YYYY-MM-DD for the local calendar day of `now`, so an evening import is not stamped with the next UTC day. */
948
+ export function localCalendarDate(now: Date): string {
949
+ const pad = (value: number) => String(value).padStart(2, "0");
950
+ return `${String(now.getFullYear()).padStart(4, "0")}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
951
+ }
952
+
953
+ /** The imported text of a note body: drops the provenance paragraph and outer blank lines. */
954
+ export function localImportedText(body: string): string {
955
+ const text = normalizeText(body);
956
+ const match = PROVENANCE_LINE.exec(text);
957
+ return (match === null ? text : text.slice(0, match.index)).trimEnd();
958
+ }
959
+
960
+ type OwnedFields = Record<string, FrontmatterFieldValue>;
961
+
962
+ /** Importer-owned frontmatter fields for an item, without `import_digest`. */
963
+ function ownedFields(item: ImportItem, inArticles: boolean): OwnedFields {
964
+ const fields: OwnedFields = { imported_from: "supermemory", external_id: item.externalId };
965
+ if (item.kind === "document") {
966
+ if (item.customId !== undefined) fields.custom_id = item.customId;
967
+ if (item.supermemoryType !== undefined) fields.supermemory_type = item.supermemoryType;
968
+ if (item.status !== undefined && item.status !== "done") fields.status = item.status;
969
+ const [firstTag] = item.containerTags;
970
+ if (firstTag !== undefined) fields.container_tag = firstTag;
971
+ if (item.containerTags.length > 1) fields.container_tags = item.containerTags;
972
+ if (item.url !== undefined) {
973
+ const url = urlPlacement(item.url);
974
+ if (url.kind === "source") fields.source = url.value;
975
+ else if (url.kind === "url") fields.url = url.value;
976
+ }
977
+ if (item.connectionId !== undefined) fields.connection_id = item.connectionId;
978
+ if (item.filepath !== undefined) fields.filepath = item.filepath;
979
+ if (inArticles) fields.clipped = item.created.slice(0, 10);
980
+ } else {
981
+ fields.version = item.version;
982
+ if (item.parentId !== undefined) fields.parent_id = item.parentId;
983
+ if (item.rootId !== undefined) fields.root_id = item.rootId;
984
+ if (item.isStatic) fields.is_static = true;
985
+ if (item.isInference) fields.is_inference = true;
986
+ if (item.forgetAfter !== undefined) fields.forget_after = item.forgetAfter;
987
+ if (item.sourceDocumentIds.length > 0) fields.source_document_ids = item.sourceDocumentIds;
988
+ }
989
+ fields.created = item.created;
990
+ fields.updated = item.updated;
991
+ if (item.metadata !== undefined) fields.metadata = item.metadata;
992
+ return fields;
993
+ }
994
+
995
+ function isInArticles(id: string): boolean {
996
+ return id.startsWith(`${ARTICLES_DIRECTORY}/`);
997
+ }
998
+
999
+ function renderedBody(item: ImportItem, now: Date): string {
1000
+ return `${item.text}\n\n${provenanceLine(item.file, now)}\n`;
1001
+ }
1002
+
1003
+ function itemDiagnostics(item: ImportItem): readonly string[] {
1004
+ if (item.kind !== "document") return item.diagnostics;
1005
+ const diagnostics = [...item.diagnostics];
1006
+ const url = item.url === undefined ? undefined : urlPlacement(item.url).diagnostic;
1007
+ if (url !== undefined) diagnostics.push(url);
1008
+ if (item.status !== undefined && item.status !== "done") {
1009
+ diagnostics.push(`status is "${item.status}"; the imported text may be incomplete`);
1010
+ }
1011
+ return diagnostics;
1012
+ }
1013
+
1014
+ interface LocalState {
1015
+ readonly digest?: string;
1016
+ readonly recorded?: string;
1017
+ readonly revision: NoteRevision;
1018
+ readonly error?: string;
1019
+ }
1020
+
1021
+ function localState(note: ExistingNote, keys: readonly string[]): LocalState {
1022
+ const revision = note.revision ?? revisionFor(new TextEncoder().encode(note.content));
1023
+ let parts;
1024
+ try {
1025
+ parts = frontmatter(note.content, note.path);
1026
+ } catch (error) {
1027
+ return { revision, error: `cannot read frontmatter (${errorMessage(error)})` };
1028
+ }
1029
+ const data: unknown = parts.document.toJS();
1030
+ if (!isRecord(data)) return { revision };
1031
+ const recorded = typeof data.import_digest === "string" ? data.import_digest : undefined;
1032
+ const fields = Object.fromEntries(keys.filter((key) => key in data).map((key) => [key, data[key]]));
1033
+ const title = typeof data.title === "string" ? data.title : "";
1034
+ return {
1035
+ digest: importDigest(title, fields, localImportedText(parts.bodySuffix)),
1036
+ ...(recorded === undefined ? {} : { recorded }),
1037
+ revision,
1038
+ };
1039
+ }
1040
+
1041
+ function laterItem(left: ImportItem, right: ImportItem): boolean {
1042
+ return Date.parse(right.updated) > Date.parse(left.updated);
1043
+ }
1044
+
1045
+ /**
1046
+ * Decide what happens to every validated item. The plan does not touch the
1047
+ * file system; `applyImportPlan` performs it.
1048
+ */
1049
+ export function planImport(input: PlanImportInput): ImportPlan {
1050
+ const prefix = input.prefix === undefined ? undefined : normalizePrefix(input.prefix);
1051
+ const items: (PlannedItem | undefined)[] = new Array(input.items.length);
1052
+
1053
+ // (1) Rejections, then one winner per external ID: the greatest `updatedAt`, then the first.
1054
+ const winners = new Map<string, number>();
1055
+ input.items.forEach((result, index) => {
1056
+ if (!result.ok) {
1057
+ const { rejection } = result;
1058
+ items[index] = {
1059
+ outcome: "rejected",
1060
+ file: rejection.file,
1061
+ pointer: rejection.pointer,
1062
+ ...(rejection.externalId === undefined ? {} : { externalId: rejection.externalId }),
1063
+ reason: rejection.reasons.join("; "),
1064
+ diagnostics: [],
1065
+ };
1066
+ return;
1067
+ }
1068
+ const previous = winners.get(result.item.externalId);
1069
+ const previousResult = previous === undefined ? undefined : input.items[previous];
1070
+ if (previousResult?.ok !== true || laterItem(previousResult.item, result.item)) {
1071
+ winners.set(result.item.externalId, index);
1072
+ }
1073
+ });
1074
+ const accepted: { index: number; item: ImportItem }[] = [];
1075
+ input.items.forEach((result, index) => {
1076
+ if (!result.ok) return;
1077
+ const { item } = result;
1078
+ const base = { file: item.file, pointer: item.pointer, externalId: item.externalId, diagnostics: itemDiagnostics(item) };
1079
+ if (winners.get(item.externalId) !== index) {
1080
+ items[index] = { ...base, outcome: "skipped", reason: "duplicate in export" };
1081
+ } else if (item.kind === "memory" && item.forgotten) {
1082
+ items[index] = { ...base, outcome: "skipped", reason: "forgotten" };
1083
+ } else {
1084
+ accepted.push({ index, item });
1085
+ }
1086
+ });
1087
+
1088
+ // (2) Existing imported notes by external ID.
1089
+ const existing = new Map<string, ExistingNote[]>();
1090
+ for (const note of input.existing) {
1091
+ if (note.metadata.imported_from !== "supermemory") continue;
1092
+ const externalId = note.metadata.external_id;
1093
+ if (typeof externalId !== "string") continue;
1094
+ existing.set(externalId, [...(existing.get(externalId) ?? []), note]);
1095
+ }
1096
+
1097
+ // (3) Allocate IDs for new items in (base ID, external ID) order.
1098
+ const fresh = accepted
1099
+ .filter(({ item }) => !existing.has(item.externalId))
1100
+ .map((entry) => ({ ...entry, baseId: placement(entry.item, entry.item.slug, prefix).id }))
1101
+ .sort((left, right) =>
1102
+ left.baseId < right.baseId ? -1 : left.baseId > right.baseId ? 1
1103
+ : left.item.externalId < right.item.externalId ? -1 : left.item.externalId > right.item.externalId ? 1 : 0,
1104
+ );
1105
+ // Each base ID keeps its next suffix, so allocation stays linear in the
1106
+ // number of items plus occupied names; an item gives up after a fixed
1107
+ // number of attempts instead of looping.
1108
+ const taken = new Set(input.occupied);
1109
+ const allocated = new Map<number, string>();
1110
+ const nextSuffix = new Map<string, number>();
1111
+ for (const { index, item, baseId } of fresh) {
1112
+ const counter = occupancyKey(baseId);
1113
+ const first = nextSuffix.get(counter) ?? 1;
1114
+ let suffix = first;
1115
+ for (; suffix < first + MAX_SLUG_ATTEMPTS; suffix += 1) {
1116
+ const candidate = placement(item, suffix === 1 ? item.slug : `${item.slug}-${suffix}`, prefix);
1117
+ if (candidate.keys.some((key) => taken.has(key))) continue;
1118
+ for (const key of candidate.keys) taken.add(key);
1119
+ allocated.set(index, candidate.id);
1120
+ break;
1121
+ }
1122
+ nextSuffix.set(counter, suffix + 1);
1123
+ }
1124
+
1125
+ // (4) Outcomes. Every write is rendered here, so the size bound and field
1126
+ // checks reject an item in a dry run exactly as they would in a real one.
1127
+ let growthNotes = 0;
1128
+ let growthBytes = 0;
1129
+ for (const { index, item } of accepted) {
1130
+ const base = { file: item.file, pointer: item.pointer, externalId: item.externalId, diagnostics: itemDiagnostics(item) };
1131
+ const keys = item.kind === "document" ? DOCUMENT_KEYS : MEMORY_KEYS;
1132
+ const body = renderedBody(item, input.now);
1133
+ const matches = existing.get(item.externalId);
1134
+ if (matches === undefined) {
1135
+ const id = allocated.get(index);
1136
+ if (id === undefined) {
1137
+ items[index] = {
1138
+ ...base,
1139
+ outcome: "rejected",
1140
+ reason: `no free note ID for slug "${item.slug}" after ${MAX_SLUG_ATTEMPTS} attempts`,
1141
+ };
1142
+ continue;
1143
+ }
1144
+ const fields = ownedFields(item, isInArticles(id));
1145
+ const write: PlannedCreate = {
1146
+ kind: "create",
1147
+ input: { id, title: item.title, type: noteType(item), body },
1148
+ fields: { ...fields, import_digest: importDigest(item.title, fields, item.text) },
1149
+ };
1150
+ const rendered = renderedSize(() => renderCreatedNote(write.input, MEASURE_DOCUMENT_ID, write.fields));
1151
+ if (typeof rendered === "string") {
1152
+ items[index] = { ...base, outcome: "rejected", reason: rendered };
1153
+ continue;
1154
+ }
1155
+ growthNotes += 1;
1156
+ growthBytes += rendered;
1157
+ items[index] = { ...base, outcome: "created", note: id, write };
1158
+ continue;
1159
+ }
1160
+ if (matches.length > 1) {
1161
+ const paths = matches.map((note) => note.path).sort().join(", ");
1162
+ items[index] = { ...base, outcome: "conflict", reason: `more than one note imports this ID: ${paths}` };
1163
+ continue;
1164
+ }
1165
+ const note = matches[0] as ExistingNote;
1166
+ const local = localState(note, keys);
1167
+ const fields = ownedFields(item, isInArticles(note.id));
1168
+ const digest = importDigest(item.title, fields, item.text);
1169
+ const matched = { ...base, note: note.id };
1170
+ if (local.error !== undefined) {
1171
+ items[index] = { ...matched, outcome: "conflict", reason: local.error };
1172
+ } else if (local.recorded === undefined) {
1173
+ items[index] = { ...matched, outcome: "conflict", reason: "missing import_digest" };
1174
+ } else if (local.digest !== local.recorded) {
1175
+ items[index] = { ...matched, outcome: "conflict", reason: "the note was edited after the last import" };
1176
+ } else if (digest === local.recorded) {
1177
+ items[index] = { ...matched, outcome: "skipped", reason: "unchanged" };
1178
+ } else {
1179
+ const updates: Record<string, FrontmatterFieldValue | null> = { title: item.title };
1180
+ for (const key of keys) updates[key] = key === "import_digest" ? digest : fields[key] ?? null;
1181
+ const rendered = renderedSize(() => renderUpdatedNoteBodyAndFields(
1182
+ { content: note.content, relativePath: note.path }, frontmatter(note.content, note.path), body, updates,
1183
+ ));
1184
+ if (typeof rendered === "string") {
1185
+ items[index] = { ...matched, outcome: "rejected", reason: rendered };
1186
+ continue;
1187
+ }
1188
+ growthBytes += rendered - Buffer.byteLength(note.content, "utf8");
1189
+ items[index] = {
1190
+ ...matched,
1191
+ outcome: "updated",
1192
+ write: { kind: "update", body, expectedRevision: local.revision, fields: updates },
1193
+ };
1194
+ }
1195
+ }
1196
+
1197
+ // (5) Supersession: each memory version points at its parent version only.
1198
+ // A relation is planned when this run creates or updates its source, or
1199
+ // creates its target, so a relation the user removed stays removed. An edge
1200
+ // that would close a supersedes cycle is omitted: the vault never holds
1201
+ // reciprocal supersession.
1202
+ const noteByExternalId = new Map<string, string>();
1203
+ for (const [externalId, notes] of existing) {
1204
+ const [only] = notes;
1205
+ if (notes.length === 1 && only !== undefined) noteByExternalId.set(externalId, only.id);
1206
+ }
1207
+ const outcomeByNote = new Map<string, ImportOutcome>();
1208
+ for (const planned of items) {
1209
+ if (planned?.note !== undefined && planned.externalId !== undefined) {
1210
+ noteByExternalId.set(planned.externalId, planned.note);
1211
+ outcomeByNote.set(planned.note, planned.outcome);
1212
+ }
1213
+ }
1214
+ const existingById = new Map(input.existing.map((note) => [note.id, note]));
1215
+ const candidates: { index: number; relation: PlannedRelation }[] = [];
1216
+ let sawMemoryRelations = false;
1217
+ for (const { index, item } of accepted) {
1218
+ if (item.kind !== "memory") continue;
1219
+ sawMemoryRelations ||= item.hasMemoryRelations;
1220
+ const planned = items[index];
1221
+ if (item.parentId === undefined || planned?.note === undefined) continue;
1222
+ if (planned.outcome === "conflict" || planned.outcome === "rejected") continue;
1223
+ const target = noteByExternalId.get(item.parentId);
1224
+ if (target === undefined) {
1225
+ items[index] = {
1226
+ ...planned,
1227
+ diagnostics: [
1228
+ ...planned.diagnostics,
1229
+ `parent memory ${item.parentId} was not imported; supersedes relation omitted`,
1230
+ ],
1231
+ };
1232
+ continue;
1233
+ }
1234
+ if (target === planned.note) continue;
1235
+ const targetOutcome = outcomeByNote.get(target);
1236
+ if (targetOutcome === "conflict" || targetOutcome === "rejected") continue;
1237
+ const written = planned.outcome === "created" || planned.outcome === "updated" || targetOutcome === "created";
1238
+ if (!written) continue;
1239
+ const source = existingById.get(planned.note);
1240
+ if (source !== undefined && supersedesTargets(source).includes(target)) continue;
1241
+ candidates.push({ index, relation: { source: planned.note, predicate: "supersedes", target } });
1242
+ }
1243
+ candidates.sort((left, right) => compareRelations(left.relation, right.relation));
1244
+ const graph = new Map<string, string[]>();
1245
+ const addEdge = (source: string, target: string) => {
1246
+ const targets = graph.get(source);
1247
+ if (targets === undefined) graph.set(source, [target]);
1248
+ else targets.push(target);
1249
+ };
1250
+ for (const note of input.existing) {
1251
+ for (const target of supersedesTargets(note)) addEdge(note.id, target);
1252
+ }
1253
+ const relations: PlannedRelation[] = [];
1254
+ for (const { index, relation } of candidates) {
1255
+ if (reaches(graph, relation.target, relation.source)) {
1256
+ const planned = items[index] as PlannedItem;
1257
+ items[index] = {
1258
+ ...planned,
1259
+ diagnostics: [
1260
+ ...planned.diagnostics,
1261
+ `supersedes relation to ${relation.target} omitted because it would close a cycle`,
1262
+ ],
1263
+ };
1264
+ continue;
1265
+ }
1266
+ addEdge(relation.source, relation.target);
1267
+ relations.push(relation);
1268
+ }
1269
+
1270
+ return {
1271
+ items: items.filter((item): item is PlannedItem => item !== undefined),
1272
+ relations,
1273
+ diagnostics: sawMemoryRelations
1274
+ ? ["memoryRelations are the service's inferred links and were not imported"]
1275
+ : [],
1276
+ growth: { notes: growthNotes, bytes: growthBytes },
1277
+ };
1278
+ }
1279
+
1280
+ /**
1281
+ * A stand-in with the length of the random UUID that authoring assigns as the
1282
+ * document ID, used only to measure a created note before it is written.
1283
+ */
1284
+ const MEASURE_DOCUMENT_ID = "00000000-0000-4000-8000-000000000000";
1285
+
1286
+ /** The UTF-8 size of a rendered note, or the reason it cannot be written. */
1287
+ function renderedSize(render: () => string): number | string {
1288
+ let content: string;
1289
+ try {
1290
+ content = render();
1291
+ } catch (error) {
1292
+ return error instanceof RangeError
1293
+ ? `the rendered note is larger than the ${MAX_NOTE_BYTES / (1024 * 1024)} MiB note limit`
1294
+ : errorMessage(error);
1295
+ }
1296
+ const bytes = Buffer.byteLength(content, "utf8");
1297
+ return bytes > MAX_NOTE_BYTES
1298
+ ? `the rendered note is larger than the ${MAX_NOTE_BYTES / (1024 * 1024)} MiB note limit`
1299
+ : bytes;
1300
+ }
1301
+
1302
+ function compareRelations(left: PlannedRelation, right: PlannedRelation): number {
1303
+ return left.source < right.source ? -1 : left.source > right.source ? 1
1304
+ : left.target < right.target ? -1 : left.target > right.target ? 1 : 0;
1305
+ }
1306
+
1307
+ /** Whether `to` is reachable from `from` along supersedes edges (iterative, cycle-safe). */
1308
+ function reaches(graph: ReadonlyMap<string, readonly string[]>, from: string, to: string): boolean {
1309
+ const seen = new Set<string>([from]);
1310
+ const stack = [from];
1311
+ for (let current = stack.pop(); current !== undefined; current = stack.pop()) {
1312
+ if (current === to) return true;
1313
+ for (const next of graph.get(current) ?? []) {
1314
+ if (seen.has(next)) continue;
1315
+ seen.add(next);
1316
+ stack.push(next);
1317
+ }
1318
+ }
1319
+ return false;
1320
+ }
1321
+
1322
+ function supersedesTargets(note: ExistingNote): readonly string[] {
1323
+ if (note.supersedes !== undefined) return note.supersedes;
1324
+ try {
1325
+ return relationsFromParts(frontmatter(note.content, note.path), note.path)
1326
+ .filter((relation) => relation.predicate === "supersedes")
1327
+ .map((relation) => relation.target);
1328
+ } catch {
1329
+ return [];
1330
+ }
1331
+ }
1332
+
1333
+ export interface ImportCounts {
1334
+ readonly created: number;
1335
+ readonly updated: number;
1336
+ readonly skipped: number;
1337
+ readonly conflicts: number;
1338
+ readonly rejected: number;
1339
+ }
1340
+
1341
+ /** One item's outcome as reported to the caller. */
1342
+ export interface ImportReportItem {
1343
+ readonly outcome: ImportOutcome;
1344
+ /** The export file argument as given. */
1345
+ readonly file: string;
1346
+ /** RFC 6901 JSON Pointer to the item inside `file`. */
1347
+ readonly pointer: string;
1348
+ readonly externalId?: string;
1349
+ readonly note?: string;
1350
+ /** Vault-relative Markdown path of `note`. */
1351
+ readonly path?: string;
1352
+ readonly reason?: string;
1353
+ readonly diagnostics?: readonly string[];
1354
+ }
1355
+
1356
+ export interface ImportRelationReport {
1357
+ readonly outcome: "added" | "unchanged" | "omitted" | "failed";
1358
+ readonly source: string;
1359
+ readonly predicate: "supersedes";
1360
+ readonly target: string;
1361
+ readonly reason?: string;
1362
+ }
1363
+
1364
+ export interface ImportReport {
1365
+ /** False only when there were items and every one was rejected. */
1366
+ readonly ok: boolean;
1367
+ readonly dryRun: boolean;
1368
+ /** Canonical vault root. */
1369
+ readonly root: string;
1370
+ readonly files: number;
1371
+ readonly counts: ImportCounts;
1372
+ readonly items: readonly ImportReportItem[];
1373
+ readonly relations: readonly ImportRelationReport[];
1374
+ readonly diagnostics: readonly string[];
1375
+ }
1376
+
1377
+ export interface ApplyImportOptions {
1378
+ /** Authoring seams, such as the document ID source in tests. */
1379
+ readonly dependencies?: Partial<AuthoringDependencies>;
1380
+ }
1381
+
1382
+ export interface ImportSupermemoryOptions extends ApplyImportOptions {
1383
+ readonly root: string;
1384
+ /** Export file paths, read relative to the working directory. */
1385
+ readonly files: readonly string[];
1386
+ readonly prefix?: string;
1387
+ readonly dryRun?: boolean;
1388
+ /** Clock for the provenance line; defaults to the current time. */
1389
+ readonly now?: Date;
1390
+ /** Vault scan caps the result must stay within; defaults to the scanner's own caps. */
1391
+ readonly limits?: { readonly notes?: number; readonly bytes?: number };
1392
+ }
1393
+
1394
+ function reportItem(item: PlannedItem): ImportReportItem {
1395
+ return {
1396
+ outcome: item.outcome,
1397
+ file: item.file,
1398
+ pointer: item.pointer,
1399
+ ...(item.externalId === undefined ? {} : { externalId: item.externalId }),
1400
+ ...(item.note === undefined ? {} : { note: item.note, path: `${item.note}.md` }),
1401
+ ...(item.reason === undefined ? {} : { reason: item.reason }),
1402
+ ...(item.diagnostics.length === 0 ? {} : { diagnostics: item.diagnostics }),
1403
+ };
1404
+ }
1405
+
1406
+ async function lstatOrNull(path: string) {
1407
+ try {
1408
+ return await lstat(path);
1409
+ } catch (error) {
1410
+ if (isErrno(error, "ENOENT")) return null;
1411
+ throw error;
1412
+ }
1413
+ }
1414
+
1415
+ /**
1416
+ * Create the missing parent directories of a note, one exact segment at a
1417
+ * time, refusing symbolic links and non-directories on the way.
1418
+ */
1419
+ export async function ensureNoteParent(vault: Vault, noteId: string): Promise<void> {
1420
+ const id = canonicalNoteId(noteId);
1421
+ let current = vault.root;
1422
+ for (const segment of id.split("/").slice(0, -1)) {
1423
+ const next = join(current, segment);
1424
+ let metadata = await lstatOrNull(next);
1425
+ if (metadata === null) {
1426
+ try {
1427
+ await mkdir(next);
1428
+ } catch (error) {
1429
+ if (!isErrno(error, "EEXIST")) throw error;
1430
+ }
1431
+ metadata = await lstat(next);
1432
+ }
1433
+ await assertExactDirectoryEntry(current, segment);
1434
+ if (metadata.isSymbolicLink()) throw new Error("the note path must not traverse a symbolic link");
1435
+ if (!metadata.isDirectory()) throw new Error("every note parent must be a directory");
1436
+ current = next;
1437
+ }
1438
+ await assertSafeParent(vault, join(vault.root, `${id}.md`));
1439
+ }
1440
+
1441
+ async function applyItem(vault: Vault, item: PlannedItem, options: ApplyImportOptions): Promise<ImportReportItem> {
1442
+ const reported = reportItem(item);
1443
+ const { write, note } = item;
1444
+ if (write === undefined || note === undefined) return reported;
1445
+ const authoring = options.dependencies === undefined ? {} : { dependencies: options.dependencies };
1446
+ try {
1447
+ if (write.kind === "create") {
1448
+ await ensureNoteParent(vault, note);
1449
+ const result = await createNoteWithFields(vault.root, write.input, authoring, write.fields);
1450
+ return result.changed ? reported : { ...reported, outcome: "skipped", reason: "unchanged" };
1451
+ }
1452
+ await updateNoteBodyWithFields(
1453
+ vault.root, note, write.body, { ...authoring, expectedRevision: write.expectedRevision }, write.fields,
1454
+ );
1455
+ return reported;
1456
+ } catch (error) {
1457
+ if (error instanceof NoteRevisionConflictError) {
1458
+ return { ...reported, outcome: "conflict", reason: "the note changed during import" };
1459
+ }
1460
+ if (error instanceof NoteAlreadyExistsError) {
1461
+ return { ...reported, outcome: "conflict", reason: error.message };
1462
+ }
1463
+ return { ...reported, outcome: "rejected", reason: errorMessage(error) };
1464
+ }
1465
+ }
1466
+
1467
+ /**
1468
+ * Perform a plan: creates and updates in plan order, then relations. A failed
1469
+ * write becomes that item's `rejected` or `conflict` outcome; the run continues.
1470
+ * A relation whose source or target ended that way is omitted, never written.
1471
+ */
1472
+ export async function applyImportPlan(
1473
+ root: string,
1474
+ plan: ImportPlan,
1475
+ options: ApplyImportOptions = {},
1476
+ ): Promise<{ readonly items: readonly ImportReportItem[]; readonly relations: readonly ImportRelationReport[] }> {
1477
+ const vault = await resolveVault(root);
1478
+ const items: ImportReportItem[] = [];
1479
+ for (const item of plan.items) items.push(await applyItem(vault, item, options));
1480
+ const failed = new Set(items.flatMap((item) =>
1481
+ item.note !== undefined && (item.outcome === "conflict" || item.outcome === "rejected") ? [item.note] : []));
1482
+ const authoring = options.dependencies === undefined ? {} : { dependencies: options.dependencies };
1483
+ const relations: ImportRelationReport[] = [];
1484
+ for (const relation of plan.relations) {
1485
+ const unwritten = [relation.source, relation.target].find((note) => failed.has(note));
1486
+ if (unwritten !== undefined) {
1487
+ relations.push({ ...relation, outcome: "omitted", reason: `${unwritten} was not written` });
1488
+ continue;
1489
+ }
1490
+ try {
1491
+ const result = await addNoteRelation(vault.root, relation.source, relation.predicate, relation.target, authoring);
1492
+ relations.push({ ...relation, outcome: result.changed ? "added" : "unchanged" });
1493
+ } catch (error) {
1494
+ relations.push({ ...relation, outcome: "failed", reason: errorMessage(error) });
1495
+ }
1496
+ }
1497
+ return { items, relations };
1498
+ }
1499
+
1500
+ /** Taken note IDs plus every entry name under the placement directories, case-folded. */
1501
+ async function occupiedIds(vault: Vault, ids: readonly string[], prefix: string | undefined): Promise<Set<string>> {
1502
+ const occupied = new Set(ids.map(occupancyKey));
1503
+ const parents = prefix === undefined
1504
+ ? [ARTICLES_DIRECTORY, IMPORTED_NOTES_DIRECTORY, IMPORTED_MEMORIES_DIRECTORY]
1505
+ : [prefix];
1506
+ for (const parent of parents) {
1507
+ const directory = join(vault.root, ...parent.split("/"));
1508
+ const metadata = await lstatOrNull(directory).catch((error: unknown) => {
1509
+ if (isErrno(error, "ENOTDIR")) return null;
1510
+ throw error;
1511
+ });
1512
+ if (metadata === null || !metadata.isDirectory()) continue;
1513
+ for (const name of await readdir(directory)) {
1514
+ occupied.add(occupancyKey(`${parent}/${name}`));
1515
+ if (/\.md$/iu.test(name)) occupied.add(occupancyKey(`${parent}/${name.slice(0, -3)}`));
1516
+ }
1517
+ }
1518
+ return occupied;
1519
+ }
1520
+
1521
+ /**
1522
+ * The revision of a scanned note's bytes on disk. The scanner drops a UTF-8
1523
+ * byte order mark, so a revision computed from its text would never match.
1524
+ * Returns `undefined` when the file no longer holds the scanned text.
1525
+ */
1526
+ async function diskRevision(root: string, path: string, content: string): Promise<NoteRevision | undefined> {
1527
+ let bytes: Uint8Array;
1528
+ try {
1529
+ bytes = await readFile(join(root, ...path.split("/")));
1530
+ } catch {
1531
+ return undefined;
1532
+ }
1533
+ return new TextDecoder().decode(bytes) === content ? revisionFor(bytes) : undefined;
1534
+ }
1535
+
1536
+ /**
1537
+ * Refuse, before any write, an import that would leave more notes or bytes
1538
+ * than the vault scanner reads; every later command would fail on the result.
1539
+ */
1540
+ function assertWithinVaultCaps(
1541
+ notes: readonly { readonly content: string }[],
1542
+ plan: ImportPlan,
1543
+ limits: ImportSupermemoryOptions["limits"],
1544
+ ): void {
1545
+ const maximumNotes = limits?.notes ?? MAX_SCANNED_NOTES;
1546
+ const maximumBytes = limits?.bytes ?? MAX_VAULT_UTF8_BYTES;
1547
+ const noteCount = notes.length + plan.growth.notes;
1548
+ if (noteCount > maximumNotes) {
1549
+ throw new Error(
1550
+ `the import would leave ${noteCount} notes, more than the vault's ${maximumNotes}-note limit; nothing was written`,
1551
+ );
1552
+ }
1553
+ let bytes = plan.growth.bytes;
1554
+ for (const note of notes) bytes += Buffer.byteLength(note.content, "utf8");
1555
+ if (bytes > maximumBytes) {
1556
+ throw new Error(
1557
+ `the import would leave ${bytes} bytes of notes, more than the vault's ${maximumBytes}-byte limit; nothing was written`,
1558
+ );
1559
+ }
1560
+ }
1561
+
1562
+ function countOutcomes(items: readonly ImportReportItem[]): ImportCounts {
1563
+ const count = (outcome: ImportOutcome) => items.filter((item) => item.outcome === outcome).length;
1564
+ return {
1565
+ created: count("created"),
1566
+ updated: count("updated"),
1567
+ skipped: count("skipped"),
1568
+ conflicts: count("conflict"),
1569
+ rejected: count("rejected"),
1570
+ };
1571
+ }
1572
+
1573
+ /**
1574
+ * Import supermemory export files into a vault. Every file is read, parsed,
1575
+ * and validated before anything is written; a file-level problem throws
1576
+ * `ImportFileError` and writes nothing. The catalog is not refreshed.
1577
+ */
1578
+ export async function importSupermemory(options: ImportSupermemoryOptions): Promise<ImportReport> {
1579
+ if (options.files.length === 0) throw new TypeError("import supermemory requires at least one export file");
1580
+ if (options.files.length > MAX_IMPORT_FILES) {
1581
+ throw new TypeError(`import supermemory accepts at most ${MAX_IMPORT_FILES} export files`);
1582
+ }
1583
+ const prefix = options.prefix === undefined ? undefined : normalizePrefix(options.prefix);
1584
+ const vault = await resolveVault(options.root);
1585
+ const now = options.now ?? new Date();
1586
+ const raw: RawItem[] = [];
1587
+ for (const file of options.files) {
1588
+ for (const item of parseExportText(await readExportFile(file, file), file)) raw.push(item);
1589
+ if (raw.length > MAX_IMPORT_ITEMS) throw new Error(`the exports hold more than ${MAX_IMPORT_ITEMS} items`);
1590
+ }
1591
+ const validated = raw.flatMap((item) => validateRawItem(item));
1592
+ if (validated.length > MAX_IMPORT_ITEMS) {
1593
+ throw new Error(`the exports hold more than ${MAX_IMPORT_ITEMS} items after memory history versions`);
1594
+ }
1595
+ const snapshot = await scanVault(vault.root, { mentionScope: false });
1596
+ const wanted = new Set(validated.flatMap((result) => (result.ok ? [result.item.externalId] : [])));
1597
+ const existing: ExistingNote[] = [];
1598
+ for (const note of snapshot.notes) {
1599
+ const supersedes = (note.relationDeclarations ?? [])
1600
+ .filter((relation) => relation.predicate === "supersedes")
1601
+ .map((relation) => relation.target.replace(/\.md$/iu, ""));
1602
+ const base = { id: note.id, path: note.path, content: note.content, metadata: note.metadata, supersedes };
1603
+ const externalId = note.metadata.external_id;
1604
+ const matched = note.metadata.imported_from === "supermemory" && typeof externalId === "string" && wanted.has(externalId);
1605
+ const revision = matched ? await diskRevision(vault.root, note.path, note.content) : undefined;
1606
+ existing.push(revision === undefined ? base : { ...base, revision });
1607
+ }
1608
+ const occupied = await occupiedIds(vault, snapshot.notes.map((note) => note.id), prefix);
1609
+ const plan = planImport({ items: validated, existing, occupied, ...(prefix === undefined ? {} : { prefix }), now });
1610
+ assertWithinVaultCaps(snapshot.notes, plan, options.limits);
1611
+ const dryRun = options.dryRun === true;
1612
+ const applied = dryRun
1613
+ ? {
1614
+ items: plan.items.map(reportItem),
1615
+ relations: plan.relations.map((relation) => ({ ...relation, outcome: "added" as const })),
1616
+ }
1617
+ : await applyImportPlan(vault.root, plan, options);
1618
+ const counts = countOutcomes(applied.items);
1619
+ return {
1620
+ ok: applied.items.length === 0 || counts.rejected < applied.items.length,
1621
+ dryRun,
1622
+ root: vault.root,
1623
+ files: options.files.length,
1624
+ counts,
1625
+ items: applied.items,
1626
+ relations: applied.relations,
1627
+ diagnostics: plan.diagnostics,
1628
+ };
1629
+ }