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 +4 -0
- package/dist/index.js +75 -9
- 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) {
|
|
@@ -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 {
|
|
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
|
-
|
|
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.
|
|
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",
|