@pi-unipi/memory 2.4.1 → 2.5.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/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Persistent memory that survives across sessions. Stores facts, preferences, and decisions with semantic vector search, so the agent remembers what you told it last week.
4
4
 
5
- **Primary backend: [MemPalace](https://github.com/mempalace/mempalace)** — auto-installed via `uv` on first load, with one-way auto-migration of any existing legacy memories. If MemPalace or `uv` is unavailable, the package transparently falls back to the bundled SQLite + sqlite-vec store, so memory never hard-fails.
5
+ **Primary backend: [MemPalace](https://github.com/mempalace/mempalace)** — auto-installed via `uv` on first load, with verified, resumable migration of existing legacy memories. If MemPalace or `uv` is unavailable, the package transparently falls back to the bundled SQLite + sqlite-vec store, so memory never hard-fails.
6
6
 
7
7
  Two storage tiers: MemPalace (or SQLite) for vector similarity search, markdown files for a durable human-readable copy you can edit by hand. Project-scoped memories stay separate per codebase, global memories are accessible everywhere.
8
8
 
@@ -76,7 +76,7 @@ Memory has no configuration file. Storage paths are fixed:
76
76
  ```
77
77
  ~/.unipi/memory/ # UniPi memory root (legacy + markdown tier)
78
78
  ├── .mempalace-install # Cached MemPalace venv detection
79
- ├── .mempalace-migrated # One-way migration completion flag
79
+ ├── .mempalace-migrated # Versioned migration verification state
80
80
  ├── global/
81
81
  │ ├── memory.db # Global vector DB (SQLite fallback)
82
82
  │ └── *.md # Global memory files
@@ -94,14 +94,18 @@ On first load, the memory package:
94
94
  `uv tool install mempalace` once (caches the venv python path in
95
95
  `~/.unipi/memory/.mempalace-install`).
96
96
  2. Pings the bridge to confirm the palace is usable.
97
- 3. If `~/.unipi/memory/.mempalace-migrated` is absent, performs a one-way
98
- read-only migration of every legacy memory (SQLite rows + markdown files
99
- across all projects) into MemPalace drawers, then writes the flag.
100
- Migration is idempotent (deterministic drawer IDs) and never deletes or
101
- mutates legacy files.
102
-
103
- Each memory operation invokes a bundled Python bridge
104
- (`bridge/mempalace_bridge.py`) once via `spawnSync` (~0.5s per call). The
97
+ 3. Fingerprints the durable SQLite and markdown sources. If they differ from
98
+ the verified state in `~/.unipi/memory/.mempalace-migrated`, performs an
99
+ idempotent read-only migration into MemPalace drawers. Unchanged drawers are
100
+ skipped, new or changed memories are upserted, and the versioned state is
101
+ written only after every discovered record is verified in MemPalace.
102
+ Failed or partial migrations remain unmarked and retry on a later session.
103
+ Legacy files are never deleted or mutated.
104
+
105
+ Each memory operation invokes the packaged Python bridge
106
+ (`bridge/mempalace_bridge.py`) once via `spawnSync` (~0.5s per call). Both the
107
+ standalone memory package and the all-in-one umbrella tarball ship and resolve
108
+ this bridge. The
105
109
  first MemPalace use on a machine also downloads the default ONNX embedding
106
110
  model (~80MB, cached at `~/.cache/chroma/onnx_models/`).
107
111
 
@@ -109,7 +113,7 @@ model (~80MB, cached at `~/.cache/chroma/onnx_models/`).
109
113
 
110
114
  ```bash
111
115
  rm ~/.unipi/memory/.mempalace-install # re-detect MemPalace next session
112
- rm ~/.unipi/memory/.mempalace-migrated # re-run one-way migration next session
116
+ rm ~/.unipi/memory/.mempalace-migrated # force a full verified migration pass next session
113
117
  ```
114
118
 
115
119
  ### Backend override
@@ -245,7 +245,13 @@ def parse_markdown_memory(project: str, path: Path) -> dict[str, Any] | None:
245
245
  if parsed is None:
246
246
  return None
247
247
  fm, body = parsed
248
- mid = safe_id_part(str(fm.get("id") or path.stem))
248
+ # Explicit IDs are authoritative and must not be normalized: UniPi's TS
249
+ # store permits leading/trailing underscores. Legacy files lack `id`, so
250
+ # retain their established filename normalization.
251
+ explicit_id = fm.get("id")
252
+ mid = str(explicit_id) if explicit_id is not None else safe_id_part(path.stem)
253
+ if not mid:
254
+ mid = "unknown"
249
255
  return {
250
256
  "project": str(fm.get("project") or project),
251
257
  "id": mid,
@@ -487,17 +493,91 @@ class Bridge:
487
493
  return synced
488
494
 
489
495
  def migrate(self, source_dir: str, project_filter: list[str] | None = None) -> dict[str, Any]:
496
+ """Idempotently import and then verify every discovered UniPi record.
497
+
498
+ Migration callers must not infer success merely from a bridge process
499
+ exiting cleanly. Return explicit discovery/failure/verification counts
500
+ so UniPi only writes its completion marker after full verification.
501
+ """
490
502
  records = discover_legacy_memories(Path(source_dir), project_filter)
491
503
  imported = 0
504
+ skipped = 0
505
+ failed = 0
506
+ errors: list[str] = []
492
507
  by_project: dict[str, int] = {}
508
+ expected = {(rec["project"], rec["id"]) for rec in records}
509
+
510
+ # Read once and skip unchanged records. Without this, adding one new
511
+ # markdown memory would re-embed every historical drawer on catch-up.
512
+ got = self.collection.get()
513
+ docs = got.documents if hasattr(got, "documents") else got.get("documents", [])
514
+ metas = got.metadatas if hasattr(got, "metadatas") else got.get("metadatas", [])
515
+ existing_docs: dict[tuple[str, str], str] = {}
516
+ for doc, meta in zip(docs, metas):
517
+ existing = record_from_doc(doc, meta)
518
+ if not existing:
519
+ continue
520
+ existing_docs[(existing["project"], existing["id"])] = doc
521
+
493
522
  for rec in records:
523
+ key = (rec["project"], rec["id"])
524
+ expected_doc = build_document(
525
+ rec["title"], rec["content"], rec["tags"], rec["project"],
526
+ rec.get("created", ""), rec.get("updated", ""), rec["type"], rec["id"],
527
+ )
528
+ if existing_docs.get(key) == expected_doc:
529
+ skipped += 1
530
+ continue
494
531
  try:
495
532
  self._upsert_one(rec)
496
533
  imported += 1
534
+ existing_docs[key] = expected_doc
497
535
  by_project[rec["project"]] = by_project.get(rec["project"], 0) + 1
498
- except Exception:
499
- pass
500
- return {"imported": imported, "projects": by_project}
536
+ except Exception as exc:
537
+ failed += 1
538
+ if len(errors) < 20:
539
+ errors.append(
540
+ f"{rec['project']}/{rec['id']}: {type(exc).__name__}: {exc}"
541
+ )
542
+
543
+ # Re-read after writes and verify exact durable documents, not just
544
+ # optimistic in-memory bookkeeping or collection counts. A palace may
545
+ # also contain drawers created by other harnesses/import recipes.
546
+ verified_get = self.collection.get()
547
+ verified_docs = (
548
+ verified_get.documents
549
+ if hasattr(verified_get, "documents")
550
+ else verified_get.get("documents", [])
551
+ )
552
+ verified_metas = (
553
+ verified_get.metadatas
554
+ if hasattr(verified_get, "metadatas")
555
+ else verified_get.get("metadatas", [])
556
+ )
557
+ persisted: dict[tuple[str, str], str] = {}
558
+ for doc, meta in zip(verified_docs, verified_metas):
559
+ persisted_rec = record_from_doc(doc, meta)
560
+ if persisted_rec:
561
+ persisted[(persisted_rec["project"], persisted_rec["id"])] = doc
562
+ verified = 0
563
+ for rec in records:
564
+ expected_doc = build_document(
565
+ rec["title"], rec["content"], rec["tags"], rec["project"],
566
+ rec.get("created", ""), rec.get("updated", ""), rec["type"], rec["id"],
567
+ )
568
+ if persisted.get((rec["project"], rec["id"])) == expected_doc:
569
+ verified += 1
570
+
571
+ return {
572
+ "discovered": len(records),
573
+ "imported": imported,
574
+ "updated": imported,
575
+ "skipped": skipped,
576
+ "failed": failed,
577
+ "verified": verified,
578
+ "projects": by_project,
579
+ "errors": errors,
580
+ }
501
581
 
502
582
 
503
583
  # ---------------------------------------------------------------------------
package/mempalace.ts CHANGED
@@ -12,6 +12,8 @@
12
12
  */
13
13
 
14
14
  import { spawnSync, spawn } from "node:child_process";
15
+ import { createHash } from "node:crypto";
16
+ import { createRequire } from "node:module";
15
17
  import * as fs from "node:fs";
16
18
  import * as path from "node:path";
17
19
  import * as os from "node:os";
@@ -27,11 +29,74 @@ const MIGRATED_FLAG = path.join(os.homedir(), ".unipi", "memory", ".mempalace-mi
27
29
  const PING_VERIFIED_FLAG = path.join(os.homedir(), ".unipi", "memory", ".mempalace-ping-verified");
28
30
  const PING_VERIFIED_TTL_MS = 24 * 60 * 60 * 1000; // 24h
29
31
 
30
- /** Path to the bundled bridge script. */
31
- const BRIDGE_PATH = path.join(dirname(fileURLToPath(import.meta.url)), "bridge", "mempalace_bridge.py");
32
+ /** Migration marker schema. Increment when migration semantics change. */
33
+ export const MIGRATION_STATE_VERSION = 2;
34
+
35
+ export interface MigrationResult {
36
+ discovered: number;
37
+ imported: number;
38
+ updated: number;
39
+ skipped: number;
40
+ failed: number;
41
+ verified: number;
42
+ errors?: string[];
43
+ }
44
+
45
+ export interface MigrationState {
46
+ version: number;
47
+ completedAt: string;
48
+ sourceFingerprint: string;
49
+ result: MigrationResult;
50
+ }
51
+
52
+ let cachedBridgePath: string | null | undefined;
53
+
54
+ function isReadableFile(candidate: string): boolean {
55
+ try {
56
+ return fs.statSync(candidate).isFile() && fs.accessSync(candidate, fs.constants.R_OK) === undefined;
57
+ } catch {
58
+ return false;
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Resolve the Python bridge in both supported layouts:
64
+ *
65
+ * - standalone @pi-unipi/memory: <package>/bridge/mempalace_bridge.py
66
+ * - bundled @pi-unipi/unipi: <umbrella>/packages/memory/bridge/...
67
+ *
68
+ * The explicit environment override is useful for custom packagers. The
69
+ * package-resolution fallback handles npm layouts where dependencies are not
70
+ * hoisted beside the umbrella package.
71
+ */
72
+ export function resolveMempalaceBridgePath(moduleUrl = import.meta.url): string | null {
73
+ const moduleDir = path.dirname(fileURLToPath(moduleUrl));
74
+ const candidates: string[] = [];
75
+ if (process.env.UNIPI_MEMPALACE_BRIDGE) {
76
+ candidates.push(path.resolve(process.env.UNIPI_MEMPALACE_BRIDGE));
77
+ }
78
+ candidates.push(
79
+ path.join(moduleDir, "bridge", "mempalace_bridge.py"),
80
+ path.join(moduleDir, "..", "memory", "bridge", "mempalace_bridge.py"),
81
+ );
82
+
83
+ try {
84
+ const require = createRequire(moduleUrl);
85
+ const memoryPackage = require.resolve("@pi-unipi/memory/package.json");
86
+ candidates.push(path.join(path.dirname(memoryPackage), "bridge", "mempalace_bridge.py"));
87
+ } catch { /* standalone/source layout may not expose package resolution */ }
88
+
89
+ return candidates.find(isReadableFile) ?? null;
90
+ }
91
+
92
+ function getBridgePath(): string | null {
93
+ if (cachedBridgePath === undefined) cachedBridgePath = resolveMempalaceBridgePath();
94
+ return cachedBridgePath;
95
+ }
32
96
 
33
- function dirname(p: string): string {
34
- return path.dirname(p);
97
+ /** Clear bridge discovery cache (primarily for recovery/tests). */
98
+ export function invalidateBridgePathCache(): void {
99
+ cachedBridgePath = undefined;
35
100
  }
36
101
 
37
102
  export interface BridgeResponse<T> {
@@ -196,22 +261,99 @@ export function invalidatePingVerified(): void {
196
261
  try { if (fs.existsSync(PING_VERIFIED_FLAG)) fs.unlinkSync(PING_VERIFIED_FLAG); } catch { /* ignore */ }
197
262
  }
198
263
 
199
- /** Has the one-way legacy migration been completed? */
200
- export function isMigrated(): boolean {
201
- return fs.existsSync(MIGRATED_FLAG);
264
+ /**
265
+ * Fingerprint all durable legacy sources. This makes migration catch-up
266
+ * automatic for existing installations instead of treating a years-old
267
+ * timestamp flag as permanently complete.
268
+ */
269
+ export function getMemorySourceFingerprint(
270
+ sourceDir = path.join(os.homedir(), ".unipi", "memory"),
271
+ ): string {
272
+ const hash = createHash("sha256");
273
+ if (!fs.existsSync(sourceDir)) return hash.update("missing").digest("hex");
274
+
275
+ const visit = (dir: string): void => {
276
+ let entries: fs.Dirent[];
277
+ try {
278
+ entries = fs.readdirSync(dir, { withFileTypes: true })
279
+ .sort((a, b) => a.name.localeCompare(b.name));
280
+ } catch {
281
+ hash.update(`unreadable:${dir}`);
282
+ return;
283
+ }
284
+ for (const entry of entries) {
285
+ if (entry.name.startsWith(".")) continue;
286
+ const full = path.join(dir, entry.name);
287
+ if (entry.isDirectory()) {
288
+ visit(full);
289
+ continue;
290
+ }
291
+ if (!entry.isFile() || (entry.name !== "memory.db" && !entry.name.endsWith(".md"))) continue;
292
+ try {
293
+ const stat = fs.statSync(full);
294
+ hash.update(`${path.relative(sourceDir, full)}\0${stat.size}\0${stat.mtimeMs}\n`);
295
+ } catch {
296
+ hash.update(`unreadable:${path.relative(sourceDir, full)}\n`);
297
+ }
298
+ }
299
+ };
300
+ visit(sourceDir);
301
+ return hash.digest("hex");
202
302
  }
203
303
 
204
- /** Mark the one-way legacy migration complete. */
205
- export function markMigrated(): void {
304
+ /** Read a verified v2 migration state. Legacy timestamp markers return null. */
305
+ export function readMigrationState(flagPath = MIGRATED_FLAG): MigrationState | null {
206
306
  try {
207
- fs.mkdirSync(path.dirname(MIGRATED_FLAG), { recursive: true });
208
- fs.writeFileSync(MIGRATED_FLAG, new Date().toISOString(), "utf-8");
209
- } catch { /* ignore */ }
307
+ const parsed = JSON.parse(fs.readFileSync(flagPath, "utf-8")) as MigrationState;
308
+ if (
309
+ parsed?.version !== MIGRATION_STATE_VERSION ||
310
+ typeof parsed.completedAt !== "string" ||
311
+ typeof parsed.sourceFingerprint !== "string" ||
312
+ !parsed.result ||
313
+ parsed.result.failed !== 0 ||
314
+ parsed.result.verified !== parsed.result.discovered
315
+ ) return null;
316
+ return parsed;
317
+ } catch {
318
+ return null;
319
+ }
320
+ }
321
+
322
+ /** Is the palace verified against the current durable source set? */
323
+ export function isMigrated(
324
+ sourceFingerprint = getMemorySourceFingerprint(),
325
+ flagPath = MIGRATED_FLAG,
326
+ ): boolean {
327
+ return readMigrationState(flagPath)?.sourceFingerprint === sourceFingerprint;
328
+ }
329
+
330
+ /** Mark migration complete only after the caller has verified every record. */
331
+ export function markMigrated(
332
+ sourceFingerprint: string,
333
+ result: MigrationResult,
334
+ flagPath = MIGRATED_FLAG,
335
+ ): boolean {
336
+ if (result.failed !== 0 || result.verified !== result.discovered) return false;
337
+ try {
338
+ fs.mkdirSync(path.dirname(flagPath), { recursive: true });
339
+ const state: MigrationState = {
340
+ version: MIGRATION_STATE_VERSION,
341
+ completedAt: new Date().toISOString(),
342
+ sourceFingerprint,
343
+ result,
344
+ };
345
+ const temp = `${flagPath}.${process.pid}.tmp`;
346
+ fs.writeFileSync(temp, JSON.stringify(state, null, 2), "utf-8");
347
+ fs.renameSync(temp, flagPath);
348
+ return true;
349
+ } catch {
350
+ return false;
351
+ }
210
352
  }
211
353
 
212
354
  /** Force re-migration by clearing the flag. */
213
- export function clearMigratedFlag(): void {
214
- try { if (fs.existsSync(MIGRATED_FLAG)) fs.unlinkSync(MIGRATED_FLAG); } catch { /* ignore */ }
355
+ export function clearMigratedFlag(flagPath = MIGRATED_FLAG): void {
356
+ try { if (fs.existsSync(flagPath)) fs.unlinkSync(flagPath); } catch { /* ignore */ }
215
357
  }
216
358
 
217
359
  /**
@@ -223,7 +365,10 @@ export function runBridge<T = unknown>(
223
365
  palace: string,
224
366
  cmd: string,
225
367
  args: Record<string, unknown> = {},
368
+ timeoutMs = 60_000,
226
369
  ): T | null {
370
+ const bridgePath = getBridgePath();
371
+ if (!bridgePath) return null;
227
372
  let argsJson: string;
228
373
  try {
229
374
  argsJson = JSON.stringify(args);
@@ -232,9 +377,9 @@ export function runBridge<T = unknown>(
232
377
  }
233
378
  let res;
234
379
  try {
235
- res = spawnSync(install.python, [BRIDGE_PATH, palace, cmd, argsJson], {
380
+ res = spawnSync(install.python, [bridgePath, palace, cmd, argsJson], {
236
381
  encoding: "utf-8",
237
- timeout: 60_000,
382
+ timeout: timeoutMs,
238
383
  maxBuffer: 64 * 1024 * 1024,
239
384
  });
240
385
  } catch {
@@ -266,8 +411,14 @@ export function runBridgeAsync<T = unknown>(
266
411
  palace: string,
267
412
  cmd: string,
268
413
  args: Record<string, unknown> = {},
414
+ timeoutMs = 60_000,
269
415
  ): Promise<T | null> {
270
416
  return new Promise((resolve) => {
417
+ const bridgePath = getBridgePath();
418
+ if (!bridgePath) {
419
+ resolve(null);
420
+ return;
421
+ }
271
422
  let argsJson: string;
272
423
  try {
273
424
  argsJson = JSON.stringify(args);
@@ -278,7 +429,7 @@ export function runBridgeAsync<T = unknown>(
278
429
 
279
430
  let child;
280
431
  try {
281
- child = spawn(install.python, [BRIDGE_PATH, palace, cmd, argsJson], {
432
+ child = spawn(install.python, [bridgePath, palace, cmd, argsJson], {
282
433
  stdio: ["ignore", "pipe", "ignore"],
283
434
  });
284
435
  } catch {
@@ -298,7 +449,7 @@ export function runBridgeAsync<T = unknown>(
298
449
  const timer = setTimeout(() => {
299
450
  try { child.kill(); } catch { /* already gone */ }
300
451
  finish(null);
301
- }, 60_000);
452
+ }, timeoutMs);
302
453
  // Do not hold the process open purely for a background bridge call.
303
454
  timer.unref?.();
304
455
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/memory",
3
- "version": "2.4.1",
3
+ "version": "2.5.0",
4
4
  "description": "Persistent cross-session memory with MemPalace backend (auto-installed) and SQLite fallback for Pi coding agent",
5
5
  "type": "module",
6
6
  "main": "index.ts",
@@ -43,8 +43,8 @@
43
43
  "better-sqlite3": "^12.9.0",
44
44
  "sqlite-vec": "^0.1.9",
45
45
  "js-yaml": "^4.1.0",
46
- "@pi-unipi/core": "2.4.1",
47
- "@pi-unipi/info-screen": "2.4.1"
46
+ "@pi-unipi/core": "2.5.0",
47
+ "@pi-unipi/info-screen": "2.5.0"
48
48
  },
49
49
  "peerDependencies": {
50
50
  "@earendil-works/pi-coding-agent": "^0.80.0",
package/storage.ts CHANGED
@@ -20,6 +20,7 @@ import {
20
20
  runBridgeAsync,
21
21
  isMigrated,
22
22
  markMigrated,
23
+ getMemorySourceFingerprint,
23
24
  isPingVerified,
24
25
  markPingVerified,
25
26
  invalidatePingVerified,
@@ -29,6 +30,7 @@ import {
29
30
  type MempalaceSearchResult,
30
31
  type MempalaceListItem,
31
32
  type MempalaceListItemAll,
33
+ type MigrationResult,
32
34
  } from "./mempalace.js";
33
35
 
34
36
  export type MemoryBackend = "mempalace" | "sqlite";
@@ -102,6 +104,7 @@ export interface SearchResult {
102
104
 
103
105
  /** Memory file frontmatter */
104
106
  interface MemoryFrontmatter {
107
+ id?: string;
105
108
  title: string;
106
109
  tags: string[];
107
110
  project: string;
@@ -201,7 +204,7 @@ export function parseMemoryContent(content: string): MemoryRecord | null {
201
204
  const frontmatter = yaml.load(frontmatterStr) as MemoryFrontmatter;
202
205
 
203
206
  return {
204
- id: "",
207
+ id: frontmatter.id || "",
205
208
  title: frontmatter.title,
206
209
  content: body.trim(),
207
210
  tags: frontmatter.tags || [],
@@ -217,6 +220,7 @@ export function parseMemoryContent(content: string): MemoryRecord | null {
217
220
  */
218
221
  export function writeMemoryFile(filePath: string, record: MemoryRecord): void {
219
222
  const frontmatter: MemoryFrontmatter = {
223
+ id: record.id,
220
224
  title: record.title,
221
225
  tags: record.tags,
222
226
  project: record.project,
@@ -383,16 +387,28 @@ export class MemoryStorage {
383
387
  this.mempalaceInstall = install;
384
388
  this.backend = "mempalace";
385
389
 
386
- // One-way auto-migration of legacy memories (idempotent).
387
- if (!isMigrated()) {
390
+ // Idempotent migration + automatic catch-up. The source fingerprint turns
391
+ // the old one-shot timestamp into a resumable state: new/changed markdown
392
+ // or SQLite sources trigger another verified upsert pass. Never mark a
393
+ // failed/partial run complete; it will retry on a later session.
394
+ const sourceFingerprint = getMemorySourceFingerprint(getMemoryBaseDir());
395
+ if (!isMigrated(sourceFingerprint)) {
388
396
  try {
389
- runBridge(install, this.palacePath, "migrate", {
397
+ // First migrations can embed thousands of records. Give the bridge a
398
+ // practical bounded window rather than the normal per-operation 60s.
399
+ const result = runBridge<MigrationResult>(install, this.palacePath, "migrate", {
390
400
  source_dir: getMemoryBaseDir(),
391
- });
392
- markMigrated();
401
+ }, 15 * 60_000);
402
+ if (
403
+ result &&
404
+ result.failed === 0 &&
405
+ result.verified === result.discovered
406
+ ) {
407
+ markMigrated(sourceFingerprint, result);
408
+ }
393
409
  } catch {
394
- // Migration failed palace still usable for new stores; legacy
395
- // memories can be re-migrated later. Do not block.
410
+ // Palace remains available for current writes; durable markdown/SQLite
411
+ // sources are untouched and migration retries because no state is set.
396
412
  }
397
413
  }
398
414
 
@@ -629,8 +645,9 @@ export class MemoryStorage {
629
645
  const record = parseMemoryFile(filePath);
630
646
  if (!record) continue;
631
647
 
632
- // Generate ID from title (same logic as store())
633
- const id = record.title.toLowerCase().replace(/[^a-z0-9]+/g, "_");
648
+ // New files preserve the authoritative store ID. Legacy files have no
649
+ // `id` frontmatter, so retain the historical title-derived fallback.
650
+ const id = record.id || record.title.toLowerCase().replace(/[^a-z0-9]+/g, "_");
634
651
 
635
652
  if (existingIds.has(id)) continue; // Already in DB
636
653