opencode-memory-pro 1.3.2 → 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/README.md CHANGED
@@ -40,7 +40,7 @@ Published on npm — install directly (requires OpenCode ≥ 1.x and Node.js ≥
40
40
  opencode plugin opencode-memory-pro
41
41
  ```
42
42
 
43
- The latest release is **v1.3.2** on [npm](https://www.npmjs.com/package/opencode-memory-pro); source and releases are on [GitHub](https://github.com/tman204-50/opencode-memory-pro).
43
+ The latest release is **v1.3.3** on [npm](https://www.npmjs.com/package/opencode-memory-pro); source and releases are on [GitHub](https://github.com/tman204-50/opencode-memory-pro).
44
44
 
45
45
  Remove the old plugin pin at the same time:
46
46
 
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.2";
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/logger.d.ts CHANGED
@@ -6,4 +6,5 @@ export declare function configureLogger(opts: {
6
6
  logFile?: string;
7
7
  }): void;
8
8
  export declare function log(level: LogLevel, message: string, extra?: Record<string, unknown>): void;
9
+ export declare function logFileOnly(level: LogLevel, message: string, extra?: Record<string, unknown>): void;
9
10
  export {};
package/dist/logger.js CHANGED
@@ -123,4 +123,15 @@ function consoleFallback(level, message) {
123
123
  console.log(formatted);
124
124
  break;
125
125
  }
126
+ }
127
+
128
+ // File-sink-only logging: writes to the configured log file but never to the
129
+ // opencode /log bus or the console. Used for known-benign noise (e.g. LanceDB
130
+ // optimize commit-conflict warnings) that should stay out of the TUI while
131
+ // remaining debuggable in the plugin log.
132
+ export function logFileOnly(level, message, extra) {
133
+ const lvl = LOG_LEVELS[level] ?? LOG_LEVELS.info;
134
+ if (lvl < _minLevel)
135
+ return;
136
+ writeFileLog(level, message, extra);
126
137
  }
package/dist/store.js CHANGED
@@ -1,8 +1,8 @@
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";
5
- import { log } from "./logger.js";
5
+ import { log, logFileOnly } from "./logger.js";
6
6
  const TABLE_NAME = "memories";
7
7
  const EVENTS_TABLE_NAME = "effectiveness_events";
8
8
  const EVENTS_SOURCE_COLUMN = "source";
@@ -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,17 +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
- log("warn", `[store] optimize failed for ${table.name}: ${error instanceof Error ? error.message : String(error)}`);
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}`);
220
+ }
133
221
  }
134
222
  }
223
+ finally {
224
+ await this.releaseOptimizeLock();
225
+ }
135
226
  }
136
227
  finally {
137
228
  this.optimizing = false;
@@ -213,9 +304,13 @@ export class MemoryStore {
213
304
  // LANCE_COMPACTION_FIX (1.1.6): fire-and-forget so a first-run
214
305
  // compaction of a backlogged store (13k+ versions) doesn't block init —
215
306
  // it compacts in the background once the version gate passes.
216
- void this.maybeOptimizeAll(false).catch((error) => {
217
- log("warn", `[store] startup optimize failed: ${error instanceof Error ? error.message : String(error)}`);
218
- });
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));
219
314
  }
220
315
  // GRACEFUL_SHUTDOWN: lance's commit path spawns a background
221
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.2",
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",