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 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.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 olderThan = new Date(Date.now() - 60 * 60 * 1000);
125
- log("debug", `[store] optimize candidates: ${candidates.map((c) => `${c.table.name}(${c.count})`).join(", ")}`);
126
- for (const { table, count } of candidates) {
127
- try {
128
- const stats = await table.optimize({ cleanupOlderThan: olderThan, deleteUnverified: true });
129
- log("info", `[store] optimized ${table.name}: ${count} versions before, pruned=${stats.prune.oldVersionsRemoved}, bytesRemoved=${stats.prune.bytesRemoved}`);
130
- }
131
- catch (error) {
132
- const message = error instanceof Error ? error.message : String(error);
133
- // Known-benign LanceDB compaction races: a concurrent write
134
- // (or a second optimize pass) commits a newer version between
135
- // our read and our commit. Lance leaves the rewritten
136
- // fragments for GC and the next optimize retry succeeds (and
137
- // the success line below is logged). Keep these out of the
138
- // TUI; they still land in the plugin log file for debugging.
139
- if (/Retryable commit conflict|Compaction commit failed/.test(message)) {
140
- logFileOnly("warn", `[store] optimize conflict for ${table.name} (self-heals on retry): ${message}`);
141
- continue;
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
- void this.maybeOptimizeAll(false).catch((error) => {
228
- log("warn", `[store] startup optimize failed: ${error instanceof Error ? error.message : String(error)}`);
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",
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",