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.
- package/README.md +4 -0
- package/dist/index.js +101 -13
- 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
|
|
394
|
-
return `${short} · asked: ${asked} → ${answered}`.slice(0,
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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:
|
|
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.
|
|
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",
|