opencode-memory-pro 1.3.3 → 1.3.4
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/dist/index.js +1 -1
- package/dist/store.js +108 -24
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -11,7 +11,7 @@ import { requestLLMCapture, isOwnSession } from "./llm.js";
|
|
|
11
11
|
import { createMemoryTools, createFeedbackTools, createEpisodicTools } from "./tools/index.js";
|
|
12
12
|
import { sweepExpiredMemories } from "./tools/memory.js";
|
|
13
13
|
import { createGraphStore } from "./graph.js";
|
|
14
|
-
const PLUGIN_VERSION = "1.3.
|
|
14
|
+
const PLUGIN_VERSION = "1.3.4";
|
|
15
15
|
const SCHEMA_VERSION = 1;
|
|
16
16
|
// Event-driven dedup: run consolidateDuplicates on session.idle (throttled to
|
|
17
17
|
// this interval so chatty sessions aren't re-scanning the store every turn)
|
package/dist/store.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { mkdir, readdir } from "node:fs/promises";
|
|
1
|
+
import { mkdir, open, readFile, readdir, rm } from "node:fs/promises";
|
|
2
2
|
import { dirname, join } from "node:path";
|
|
3
3
|
import { validateEpisodicRecord, validateEpisodicRecordArray } from "./types.js";
|
|
4
4
|
import { tokenize } from "./utils.js";
|
|
@@ -68,19 +68,89 @@ export class MemoryStore {
|
|
|
68
68
|
// logged but never fatal.
|
|
69
69
|
static OPTIMIZE_INTERVAL_MS = 6 * 60 * 60 * 1000;
|
|
70
70
|
static OPTIMIZE_MIN_VERSIONS = 500;
|
|
71
|
+
// OPTIMIZE_LOCK (1.3.4): two opencode processes sharing one store both run
|
|
72
|
+
// maybeOptimizeAll on first writes after a restart (lastOptimizeAt=0), so
|
|
73
|
+
// their optimize() calls race. The loser's native Rust env_logger prints
|
|
74
|
+
// "Compaction commit failed; leaving N rewritten fragment(s) in place for
|
|
75
|
+
// GC" DIRECTLY to stderr — the plugin has no JS hook to intercept it (no
|
|
76
|
+
// RUST_LOG in the binary), so it lands on the TUI no matter what log()
|
|
77
|
+
// does. A lock file serializes compaction across processes; the loser just
|
|
78
|
+
// skips this cycle (the 6h interval retries later). Stale locks (owner
|
|
79
|
+
// process dead or older than the TTL) are reclaimed.
|
|
80
|
+
static OPTIMIZE_LOCK_TTL_MS = 30 * 60 * 1000;
|
|
71
81
|
optimizing = false;
|
|
72
82
|
lastOptimizeAt = 0;
|
|
73
83
|
constructor(dbPath, cacheConfig) {
|
|
74
84
|
this.dbPath = dbPath;
|
|
75
85
|
this.cacheConfig = { ...DEFAULT_CACHE_CONFIG, ...cacheConfig };
|
|
76
86
|
}
|
|
87
|
+
/**
|
|
88
|
+
* Cross-process compaction lock. Returns true when this process owns the
|
|
89
|
+
* lock; false when another live process holds it (or the lock could not be
|
|
90
|
+
* taken). Stale locks are reclaimed: owner pid no longer alive, or the lock
|
|
91
|
+
* file is older than OPTIMIZE_LOCK_TTL_MS (crash fallback; the pid check
|
|
92
|
+
* covers the normal case).
|
|
93
|
+
*/
|
|
94
|
+
async acquireOptimizeLock() {
|
|
95
|
+
await mkdir(this.dbPath, { recursive: true }).catch(() => { });
|
|
96
|
+
const lockFile = join(this.dbPath, ".optimize.lock");
|
|
97
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
98
|
+
try {
|
|
99
|
+
const handle = await open(lockFile, "wx");
|
|
100
|
+
try {
|
|
101
|
+
await handle.writeFile(`${process.pid}\n${Date.now()}\n`, "utf8");
|
|
102
|
+
}
|
|
103
|
+
catch { }
|
|
104
|
+
await handle.close();
|
|
105
|
+
return true;
|
|
106
|
+
}
|
|
107
|
+
catch (error) {
|
|
108
|
+
if (error?.code !== "EEXIST")
|
|
109
|
+
return false;
|
|
110
|
+
let stale = false;
|
|
111
|
+
try {
|
|
112
|
+
const content = await readFile(lockFile, "utf8");
|
|
113
|
+
const [pidStr, tsStr] = content.split("\n");
|
|
114
|
+
const ownerPid = Number(pidStr);
|
|
115
|
+
const ownerTs = Number(tsStr);
|
|
116
|
+
if (!Number.isInteger(ownerPid) || ownerPid <= 0) {
|
|
117
|
+
stale = true;
|
|
118
|
+
}
|
|
119
|
+
else if (Number.isFinite(ownerTs) && Date.now() - ownerTs > MemoryStore.OPTIMIZE_LOCK_TTL_MS) {
|
|
120
|
+
stale = true;
|
|
121
|
+
}
|
|
122
|
+
else if (ownerPid !== process.pid) {
|
|
123
|
+
try {
|
|
124
|
+
process.kill(ownerPid, 0);
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
stale = true;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
stale = true;
|
|
133
|
+
}
|
|
134
|
+
if (!stale)
|
|
135
|
+
return false;
|
|
136
|
+
await rm(lockFile, { force: true }).catch(() => { });
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
async releaseOptimizeLock() {
|
|
142
|
+
await rm(join(this.dbPath, ".optimize.lock"), { force: true }).catch(() => { });
|
|
143
|
+
}
|
|
77
144
|
/**
|
|
78
145
|
* Version-count-gated Lance compaction. Non-blocking: reads the _versions
|
|
79
146
|
* directory for each open table and optimizes the ones that crossed the
|
|
80
147
|
* threshold (or all when force=true), throttled by an interval so chatty
|
|
81
148
|
* sessions can't trigger it every turn. cleanupOlderThan=1h keeps
|
|
82
149
|
* in-flight recent versions; deleteUnverified removes orphaned fragment
|
|
83
|
-
* files (safe under Lance's exclusive table write lock).
|
|
150
|
+
* files (safe under Lance's exclusive table write lock). The cross-process
|
|
151
|
+
* lock keeps two opencode instances from racing optimize() on a shared
|
|
152
|
+
* store — the race is what makes lance print "Compaction commit failed" to
|
|
153
|
+
* stderr (uninterceptable), so the lock is what keeps it out of the TUI.
|
|
84
154
|
*/
|
|
85
155
|
async maybeOptimizeAll(force = false) {
|
|
86
156
|
if (this.optimizing)
|
|
@@ -121,28 +191,38 @@ export class MemoryStore {
|
|
|
121
191
|
return;
|
|
122
192
|
this.optimizing = true;
|
|
123
193
|
try {
|
|
124
|
-
const
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
194
|
+
const lockHeld = await this.acquireOptimizeLock();
|
|
195
|
+
if (!lockHeld) {
|
|
196
|
+
logFileOnly("warn", "[store] optimize skipped: another process holds the compaction lock (retries next interval)");
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
try {
|
|
200
|
+
const olderThan = new Date(Date.now() - 60 * 60 * 1000);
|
|
201
|
+
log("debug", `[store] optimize candidates: ${candidates.map((c) => `${c.table.name}(${c.count})`).join(", ")}`);
|
|
202
|
+
for (const { table, count } of candidates) {
|
|
203
|
+
try {
|
|
204
|
+
const stats = await table.optimize({ cleanupOlderThan: olderThan, deleteUnverified: true });
|
|
205
|
+
log("info", `[store] optimized ${table.name}: ${count} versions before, pruned=${stats.prune.oldVersionsRemoved}, bytesRemoved=${stats.prune.bytesRemoved}`);
|
|
206
|
+
}
|
|
207
|
+
catch (error) {
|
|
208
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
209
|
+
// Known-benign LanceDB compaction races: a concurrent write
|
|
210
|
+
// (or a second optimize pass) commits a newer version between
|
|
211
|
+
// our read and our commit. Lance leaves the rewritten
|
|
212
|
+
// fragments for GC and the next optimize retry succeeds (and
|
|
213
|
+
// the success line below is logged). Keep these out of the
|
|
214
|
+
// TUI; they still land in the plugin log file for debugging.
|
|
215
|
+
if (/Retryable commit conflict|Compaction commit failed/.test(message)) {
|
|
216
|
+
logFileOnly("warn", `[store] optimize conflict for ${table.name} (self-heals on retry): ${message}`);
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
log("warn", `[store] optimize failed for ${table.name}: ${message}`);
|
|
142
220
|
}
|
|
143
|
-
log("warn", `[store] optimize failed for ${table.name}: ${message}`);
|
|
144
221
|
}
|
|
145
222
|
}
|
|
223
|
+
finally {
|
|
224
|
+
await this.releaseOptimizeLock();
|
|
225
|
+
}
|
|
146
226
|
}
|
|
147
227
|
finally {
|
|
148
228
|
this.optimizing = false;
|
|
@@ -224,9 +304,13 @@ export class MemoryStore {
|
|
|
224
304
|
// LANCE_COMPACTION_FIX (1.1.6): fire-and-forget so a first-run
|
|
225
305
|
// compaction of a backlogged store (13k+ versions) doesn't block init —
|
|
226
306
|
// it compacts in the background once the version gate passes.
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
307
|
+
// OPTIMIZE_JITTER (1.3.4): staggered 5–30s so two instances that boot
|
|
308
|
+
// together don't race for the compaction lock in the same instant.
|
|
309
|
+
setTimeout(() => {
|
|
310
|
+
void this.maybeOptimizeAll(false).catch((error) => {
|
|
311
|
+
log("warn", `[store] startup optimize failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
312
|
+
});
|
|
313
|
+
}, 5_000 + Math.floor(Math.random() * 25_000));
|
|
230
314
|
}
|
|
231
315
|
// GRACEFUL_SHUTDOWN: lance's commit path spawns a background
|
|
232
316
|
// auto_cleanup_hook task; if the process exits without closing the
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-memory-pro",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.4",
|
|
4
4
|
"description": "LanceDB-backed long-term memory provider for OpenCode — standalone fork of lancedb-opencode-pro with entity graph, lifecycle, and retention",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|