opencode-mempalace-persistence 2.7.1 → 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.
Files changed (3) hide show
  1. package/README.md +4 -0
  2. package/dist/index.js +101 -13
  3. package/package.json +1 -1
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) {
@@ -380,6 +383,28 @@ function mempalaceSearch(query) {
380
383
  function isMemPalaceTool(name) {
381
384
  return typeof name === "string" && name.toLowerCase().includes("mempalace");
382
385
  }
386
+ // MCP tool results arrive as content blocks ({content: [{type, text}]),
387
+ // NOT as a flat `output` string (diagnosed via shape logging 2026-09-19:
388
+ // keys=["content"], no `output` key at all).
389
+ function extractResultText(out) {
390
+ try {
391
+ const blocks = out?.content;
392
+ if (Array.isArray(blocks)) {
393
+ const text = blocks
394
+ .filter((b) => b && (b.type === "text" || typeof b.text === "string") && typeof b.text === "string")
395
+ .map((b) => String(b.text))
396
+ .join("\n");
397
+ if (text.trim())
398
+ return text;
399
+ }
400
+ if (typeof out?.output === "string" && out.output.trim())
401
+ return out.output;
402
+ if (typeof out === "string" && out.trim())
403
+ return out;
404
+ }
405
+ catch { }
406
+ return "";
407
+ }
383
408
  function summarizeToolCall(tool, args, out) {
384
409
  const short = tool.replace(/^mcp_+/, "").replace(/^mempalace_mempalace_/, "").replace(/^mempalace_/, "");
385
410
  let asked = "";
@@ -390,18 +415,60 @@ function summarizeToolCall(tool, args, out) {
390
415
  catch {
391
416
  asked = "";
392
417
  }
393
- const answered = (out || "").replace(/\s+/g, " ").slice(0, 80) || "(empty)";
394
- return `${short} · asked: ${asked} → ${answered}`.slice(0, 220);
418
+ const answered = extractResultText(out).replace(/\s+/g, " ").slice(0, 120) || "(empty)";
419
+ return `${short} · asked: ${asked} → ${answered}`.slice(0, 260);
395
420
  }
396
421
  function readSyncState() {
397
422
  try {
398
423
  const raw = JSON.parse(readFileSync(STATE_FILE, "utf-8"));
399
424
  if (typeof raw?.last_sync_ms === "number") {
400
- 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
+ };
401
430
  }
402
431
  }
403
432
  catch { }
404
- 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
+ }
405
472
  }
406
473
  // Per-wing cursors (see PR #1524 follow-up): a global cursor stalls
407
474
  // forever when one wing keeps failing while others succeed. Each wing
@@ -452,15 +519,21 @@ print(json.dumps(rows))
452
519
  sessionsArr = JSON.parse(sessions);
453
520
  }
454
521
  catch {
455
- return { wings: new Map(), now: Date.now() };
522
+ return { wings: new Map(), now: Date.now(), exportedIds: new Map() };
456
523
  }
457
524
  if (!sessionsArr || sessionsArr.length === 0)
458
- return { wings: new Map(), now: Date.now() };
525
+ return { wings: new Map(), now: Date.now(), exportedIds: new Map() };
459
526
  const now = Date.now();
460
527
  // Never advance the cursor past an in-flight reply: anything skipped
461
528
  // as incomplete is revisited by the next sync (idle/exit/startup).
462
529
  let cursor = now;
463
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();
464
537
  mkdirSync(OUT_DIR, { recursive: true, mode: 0o700 });
465
538
  for (const sess of sessionsArr) {
466
539
  const [sessId, title, , directory] = sess;
@@ -509,7 +582,7 @@ for (mid, mts, mdata_raw) in rows:
509
582
  try:
510
583
  pdata = json.loads(pdata_raw)
511
584
  if pdata.get("type") == "text" and pdata.get("text","").strip():
512
- 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})
513
586
  except: pass
514
587
  db.close()
515
588
  print(json.dumps({"texts": texts, "incomplete": incomplete}))
@@ -518,7 +591,11 @@ print(json.dumps({"texts": texts, "incomplete": incomplete}))
518
591
  let incompleteTs = [];
519
592
  try {
520
593
  const parsed = JSON.parse(msgs);
521
- 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));
522
599
  incompleteTs = parsed.incomplete || [];
523
600
  }
524
601
  catch {
@@ -550,11 +627,18 @@ print(json.dumps({"texts": texts, "incomplete": incomplete}))
550
627
  mkdirSync(wingDir, { recursive: true, mode: 0o700 });
551
628
  const fname = `sync_${prefix}_${contentHash}.txt`;
552
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
+ }
553
637
  if (!wings.has(wing))
554
638
  wings.set(wing, []);
555
639
  wings.get(wing).push(join(wingDir, fname));
556
640
  }
557
- return { wings, now: cursor };
641
+ return { wings, now: cursor, exportedIds: exportedByWing };
558
642
  }
559
643
  function markSynced(now, wing) {
560
644
  try {
@@ -623,7 +707,7 @@ function doDbSync() {
623
707
  const cursorFor = backfillRequested()
624
708
  ? (_wing) => 0
625
709
  : (wing) => (wing ? getLastSync(wing) : getLastSync());
626
- const { wings, now } = exportNewSessions(cursorFor);
710
+ const { wings, now, exportedIds } = exportNewSessions(cursorFor);
627
711
  if (wings.size === 0)
628
712
  return;
629
713
  miningLock = true;
@@ -703,7 +787,10 @@ function doDbSync() {
703
787
  wingDrawers.set(wing, parseDrawers(stdout));
704
788
  // Per-wing cursor: this wing's progress is banked even if a later
705
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.
706
792
  markSynced(now, wing);
793
+ commitExportedIds(new Map([[wing, exportedIds.get(wing) || new Map()]]));
707
794
  for (const f of files) {
708
795
  try {
709
796
  unlinkSync(f);
@@ -735,7 +822,7 @@ function exitSync() {
735
822
  const bin = resolveBin();
736
823
  if (!bin)
737
824
  return;
738
- const { wings, now } = exportNewSessions((wing) => (wing ? getLastSync(wing) : getLastSync()));
825
+ const { wings, now, exportedIds } = exportNewSessions((wing) => (wing ? getLastSync(wing) : getLastSync()));
739
826
  if (wings.size === 0)
740
827
  return;
741
828
  const deadline = Date.now() + EXIT_BUDGET_MS;
@@ -757,6 +844,7 @@ function exitSync() {
757
844
  return;
758
845
  }
759
846
  markSynced(now, wing);
847
+ commitExportedIds(new Map([[wing, exportedIds.get(wing) || new Map()]]));
760
848
  done.push(wing);
761
849
  }
762
850
  for (const wing of done) {
@@ -922,7 +1010,7 @@ export default (async ({ client }) => {
922
1010
  const name = input?.tool || "";
923
1011
  if (!isMemPalaceTool(name))
924
1012
  return;
925
- const summary = summarizeToolCall(name, input?.args, output?.output || "");
1013
+ const summary = summarizeToolCall(name, input?.args, output);
926
1014
  log(`tool: ${summary}`);
927
1015
  toast("info", "MemPalace", summary);
928
1016
  // Diagnostic: MCP results may live outside `output` — record the
@@ -936,7 +1024,7 @@ export default (async ({ client }) => {
936
1024
  catch {
937
1025
  return "";
938
1026
  } })(),
939
- answered: String(outAny.output || "").replace(/\s+/g, " ").slice(0, 300),
1027
+ answered: extractResultText(output).replace(/\s+/g, " ").slice(0, 300),
940
1028
  shape: {
941
1029
  keys: Object.keys(outAny),
942
1030
  title: outAny.title,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-mempalace-persistence",
3
- "version": "2.7.1",
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",