opencode-mempalace-persistence 2.7.2 → 2.8.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
@@ -209,6 +209,10 @@ Next time you ask
209
209
 
210
210
  Every turn (question + answer) is saved as a drawer in MemPalace. Mining runs with `--mode convos` (default `exchange` extraction: one drawer per exchange pair, verbatim, no paraphrasing). Exports are grouped one wing per project (official multi-project pattern: `bot-oc` sessions land in wing `bot-oc`, never leaking across projects). Only completed turns are exported (in-flight replies are revisited by the next sync). The model additionally records KG facts (decisions, milestones, preferences) during conversation and at each checkpoint via MCP tools.
211
211
 
212
+ ### Message-level dedup
213
+
214
+ Each opencode message is exported **exactly once ever**: exported message IDs are tracked in `sync_state.json` (retained 90 days / 200k entries) and skipped on later runs. This kills the main duplicate source mempalace's file-level dedup cannot catch — repeated boilerplate (e.g. system prompts re-sent every turn) landing in different export files. (`mempalace dedup` only compares drawers from the *same* source file, so it can't fix that either.)
215
+
212
216
  ### Backfill existing sessions
213
217
 
214
218
  To mine the full opencode history once (e.g. on first install):
package/dist/index.js CHANGED
@@ -26,6 +26,9 @@ const LOG_FILE = "/tmp/opencode-mempalace.log";
26
26
  const MAX_INJECT_CHARS = 900;
27
27
  const MAX_SEARCH_RESULTS = 3;
28
28
  const MAX_WAKEUP_CHARS = 1500;
29
+ // Message-ID retention for export dedup: age + size caps (see commitExportedIds).
30
+ const MINED_IDS_MAX_AGE_MS = 90 * 24 * 3600 * 1000;
31
+ const MINED_IDS_MAX_ENTRIES = 200000;
29
32
  // Official MemPalace hook cadence: AI checkpoint every N human messages.
30
33
  const DEFAULT_SAVE_INTERVAL = 15;
31
34
  function log(msg) {
@@ -419,11 +422,53 @@ function readSyncState() {
419
422
  try {
420
423
  const raw = JSON.parse(readFileSync(STATE_FILE, "utf-8"));
421
424
  if (typeof raw?.last_sync_ms === "number") {
422
- return { last_sync_ms: raw.last_sync_ms, wings: raw.wings && typeof raw.wings === "object" ? raw.wings : {} };
425
+ return {
426
+ last_sync_ms: raw.last_sync_ms,
427
+ wings: raw.wings && typeof raw.wings === "object" ? raw.wings : {},
428
+ mined_ids: raw.mined_ids && typeof raw.mined_ids === "object" ? raw.mined_ids : {},
429
+ };
423
430
  }
424
431
  }
425
432
  catch { }
426
- return { last_sync_ms: 0, wings: {} };
433
+ return { last_sync_ms: 0, wings: {}, mined_ids: {} };
434
+ }
435
+ // Message IDs already exported in a previous run. An ID older than every
436
+ // cursor can never be selected again (queries use time_created > cursor),
437
+ // so the set is pruned to stay small.
438
+ function loadMinedIds() {
439
+ try {
440
+ const raw = (readSyncState().mined_ids || {});
441
+ return new Map(Object.entries(raw).filter(([, ts]) => typeof ts === "number"));
442
+ }
443
+ catch {
444
+ return new Map();
445
+ }
446
+ }
447
+ function commitExportedIds(byWing) {
448
+ try {
449
+ const st = readSyncState();
450
+ const merged = { ...(st.mined_ids || {}) };
451
+ for (const ids of byWing.values()) {
452
+ for (const [mid, ts] of ids) {
453
+ if (typeof ts === "number")
454
+ merged[mid] = ts;
455
+ }
456
+ }
457
+ // Retention by AGE (90d) and SIZE (200k newest) — NOT by cursor.
458
+ // Cursors move backward on incomplete clamps and stall on failures;
459
+ // cursor-based pruning dropped IDs that future exports reselect,
460
+ // silently disabling the filter (seen live: set always empty).
461
+ const cutoff = Date.now() - MINED_IDS_MAX_AGE_MS;
462
+ let entries = Object.entries(merged).filter(([, ts]) => typeof ts === "number" && ts >= cutoff);
463
+ if (entries.length > MINED_IDS_MAX_ENTRIES) {
464
+ entries = entries.sort((a, b) => b[1] - a[1]).slice(0, MINED_IDS_MAX_ENTRIES);
465
+ }
466
+ st.mined_ids = Object.fromEntries(entries);
467
+ writeFileSync(STATE_FILE, JSON.stringify(st));
468
+ }
469
+ catch (e) {
470
+ log("mined-ids write err: " + String(e));
471
+ }
427
472
  }
428
473
  // Per-wing cursors (see PR #1524 follow-up): a global cursor stalls
429
474
  // forever when one wing keeps failing while others succeed. Each wing
@@ -474,15 +519,21 @@ print(json.dumps(rows))
474
519
  sessionsArr = JSON.parse(sessions);
475
520
  }
476
521
  catch {
477
- return { wings: new Map(), now: Date.now() };
522
+ return { wings: new Map(), now: Date.now(), exportedIds: new Map() };
478
523
  }
479
524
  if (!sessionsArr || sessionsArr.length === 0)
480
- return { wings: new Map(), now: Date.now() };
525
+ return { wings: new Map(), now: Date.now(), exportedIds: new Map() };
481
526
  const now = Date.now();
482
527
  // Never advance the cursor past an in-flight reply: anything skipped
483
528
  // as incomplete is revisited by the next sync (idle/exit/startup).
484
529
  let cursor = now;
485
530
  const wings = new Map();
531
+ // Already-mined IDs loaded ONCE per export (state file can be MBs).
532
+ const seen = loadMinedIds();
533
+ // Message IDs written to export files, PER WING. Recorded only when
534
+ // that wing mines successfully — recording another wing's IDs early
535
+ // would skip its content forever on failure.
536
+ const exportedByWing = new Map();
486
537
  mkdirSync(OUT_DIR, { recursive: true, mode: 0o700 });
487
538
  for (const sess of sessionsArr) {
488
539
  const [sessId, title, , directory] = sess;
@@ -531,7 +582,7 @@ for (mid, mts, mdata_raw) in rows:
531
582
  try:
532
583
  pdata = json.loads(pdata_raw)
533
584
  if pdata.get("type") == "text" and pdata.get("text","").strip():
534
- texts.append({"role": role, "text": pdata.get("text").strip(), "ts": mts})
585
+ texts.append({"mid": mid, "role": role, "text": pdata.get("text").strip(), "ts": mts})
535
586
  except: pass
536
587
  db.close()
537
588
  print(json.dumps({"texts": texts, "incomplete": incomplete}))
@@ -540,7 +591,11 @@ print(json.dumps({"texts": texts, "incomplete": incomplete}))
540
591
  let incompleteTs = [];
541
592
  try {
542
593
  const parsed = JSON.parse(msgs);
543
- msgList = parsed.texts;
594
+ // Message-level dedup: each message is exported exactly once ever.
595
+ // Repeated boilerplate (system prompts re-sent every turn) across
596
+ // overlapping windows was the main duplicate source mempalace's
597
+ // file-level dedup cannot catch (different files, same paragraph).
598
+ msgList = (parsed.texts || []).filter((m) => m && m.mid && !seen.has(m.mid));
544
599
  incompleteTs = parsed.incomplete || [];
545
600
  }
546
601
  catch {
@@ -572,11 +627,18 @@ print(json.dumps({"texts": texts, "incomplete": incomplete}))
572
627
  mkdirSync(wingDir, { recursive: true, mode: 0o700 });
573
628
  const fname = `sync_${prefix}_${contentHash}.txt`;
574
629
  writeFileSync(join(wingDir, fname), content + "\n", { mode: 0o600 });
630
+ if (!exportedByWing.has(wing))
631
+ exportedByWing.set(wing, new Map());
632
+ const wingIds = exportedByWing.get(wing);
633
+ for (const m of msgList) {
634
+ if (m && m.mid && typeof m.ts === "number")
635
+ wingIds.set(m.mid, m.ts);
636
+ }
575
637
  if (!wings.has(wing))
576
638
  wings.set(wing, []);
577
639
  wings.get(wing).push(join(wingDir, fname));
578
640
  }
579
- return { wings, now: cursor };
641
+ return { wings, now: cursor, exportedIds: exportedByWing };
580
642
  }
581
643
  function markSynced(now, wing) {
582
644
  try {
@@ -645,7 +707,7 @@ function doDbSync() {
645
707
  const cursorFor = backfillRequested()
646
708
  ? (_wing) => 0
647
709
  : (wing) => (wing ? getLastSync(wing) : getLastSync());
648
- const { wings, now } = exportNewSessions(cursorFor);
710
+ const { wings, now, exportedIds } = exportNewSessions(cursorFor);
649
711
  if (wings.size === 0)
650
712
  return;
651
713
  miningLock = true;
@@ -725,7 +787,10 @@ function doDbSync() {
725
787
  wingDrawers.set(wing, parseDrawers(stdout));
726
788
  // Per-wing cursor: this wing's progress is banked even if a later
727
789
  // wing fails — the counter never stalls on one slow wing again.
790
+ // Only THIS wing's message IDs are recorded: other wings' content
791
+ // is not filed yet, recording it would skip it forever on failure.
728
792
  markSynced(now, wing);
793
+ commitExportedIds(new Map([[wing, exportedIds.get(wing) || new Map()]]));
729
794
  for (const f of files) {
730
795
  try {
731
796
  unlinkSync(f);
@@ -757,7 +822,7 @@ function exitSync() {
757
822
  const bin = resolveBin();
758
823
  if (!bin)
759
824
  return;
760
- const { wings, now } = exportNewSessions((wing) => (wing ? getLastSync(wing) : getLastSync()));
825
+ const { wings, now, exportedIds } = exportNewSessions((wing) => (wing ? getLastSync(wing) : getLastSync()));
761
826
  if (wings.size === 0)
762
827
  return;
763
828
  const deadline = Date.now() + EXIT_BUDGET_MS;
@@ -779,6 +844,7 @@ function exitSync() {
779
844
  return;
780
845
  }
781
846
  markSynced(now, wing);
847
+ commitExportedIds(new Map([[wing, exportedIds.get(wing) || new Map()]]));
782
848
  done.push(wing);
783
849
  }
784
850
  for (const wing of done) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-mempalace-persistence",
3
- "version": "2.7.2",
3
+ "version": "2.8.0",
4
4
  "description": "OpenCode plugin — auto-sync conversations to MemPalace memory in real-time. No forced wings, KG extraction via MCP tools.",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",