opencode-memory-pro 1.3.5 → 1.3.7

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.5** 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.7** 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
 
@@ -408,6 +408,52 @@ so your memories and graph carry over untouched.
408
408
 
409
409
  ## Changelog
410
410
 
411
+ ### v1.3.7 (2026-09-06)
412
+
413
+ Scope normalization — fixes lost memories when a `scope` argument is
414
+ explicitly passed to a tool while `scoping` is `"global"`:
415
+
416
+ - **Explicit scopes now collapse to `global` in global mode**: previously
417
+ `memory_remember(scope="project")` (and every other tool accepting a `scope`
418
+ arg) stored the row under the literal string `"project"` — but scope-filtered
419
+ reads derive the scope via `deriveProjectScope()`, which returns `"global"`
420
+ in global mode, so the memory was effectively lost (invisible to search,
421
+ promote, why, `memory_global_list`, ...; reachable only by passing
422
+ `scope="project"` explicitly).
423
+ - **New `resolveScope(scope, worktree)` helper** in `dist/scope.js` — collapses
424
+ any explicit scope to `"global"` in global mode and honors it in project
425
+ mode (falling back to the derived project scope when omitted). Applied to all
426
+ 30 scope-arg sites across `dist/tools/memory.js`, `dist/tools/episodic.js`,
427
+ and `dist/tools/feedback.js`, including `memory_clear`, which previously
428
+ called `clearScope(args.scope)` without any normalization.
429
+ - **Tests**: two new unit tests cover the collapse-to-global and
430
+ honor-in-project-mode behavior.
431
+
432
+ ### v1.3.6 (2026-09-06)
433
+
434
+ Compaction lock hardening — fixes the "Compaction commit failed; leaving N
435
+ rewritten fragment(s) in place for GC" warning reappearing on the TUI at
436
+ startup / first turn when two opencode instances share one store:
437
+
438
+ - **No more lock stealing during the owner's init window**: the owner creates
439
+ `.optimize.lock` with `open("wx")` and *then* writes its pid; a contender
440
+ reading in between saw an empty file, declared it stale, deleted it, and
441
+ created its own — so both processes "owned" the lock and raced `optimize()`
442
+ (the native LanceDB stderr line is uninterceptable by the plugin). The lock
443
+ now treats an empty file as "being initialized" for a short grace instead of
444
+ reclaiming it.
445
+ - **Contenders wait instead of giving up instantly**: when a live process holds
446
+ the lock, the second instance now polls up to 10s for it to finish (serializing
447
+ compaction across processes) before skipping this cycle and retrying next
448
+ interval, instead of racing it.
449
+ - **In-process guard set synchronously**: `maybeOptimizeAll` now sets
450
+ `optimizing = true` before any `await`, so overlapping calls in one process
451
+ (fire-and-forget write trigger + awaited explicit call on the first turn)
452
+ can no longer both run `optimize()` concurrently.
453
+ - **Tests**: two new unit tests cover the open→write TOCTOU (old lock returns
454
+ `true` and steals; new lock returns `false` and preserves ownership) and
455
+ stale-lock reclamation.
456
+
411
457
  ### v1.3.5 (2026-09-06)
412
458
 
413
459
  Code-review hardening pass — bug fixes, no breaking changes:
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.5";
14
+ const PLUGIN_VERSION = "1.3.7";
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/scope.js CHANGED
@@ -26,6 +26,18 @@ export function buildScopeFilter(activeScope, includeGlobal) {
26
26
  const scopes = includeGlobal ? [activeScope, "global"] : [activeScope];
27
27
  return [...new Set(scopes)];
28
28
  }
29
+ // SCOPE_NORMALIZE (1.3.7): resolves a caller-supplied scope argument against
30
+ // the active scoping mode. In "global" mode every explicit scope (e.g. a
31
+ // memory_remember(scope="project")) collapses to "global" — otherwise such
32
+ // rows would be stored under a literal scope that scope-filtered tools never
33
+ // query (the rows are effectively lost). In "project" mode the argument is
34
+ // honored, falling back to the derived project scope.
35
+ export function resolveScope(scope, worktree) {
36
+ if (resolveScoping(worktree) !== "project") {
37
+ return "global";
38
+ }
39
+ return scope ?? deriveProjectScope(worktree);
40
+ }
29
41
  function resolveScoping(worktree) {
30
42
  try {
31
43
  return resolveMemoryConfig({}, worktree).scoping === "project" ? "project" : "global";
package/dist/store.js CHANGED
@@ -78,6 +78,17 @@ export class MemoryStore {
78
78
  // skips this cycle (the 6h interval retries later). Stale locks (owner
79
79
  // process dead or older than the TTL) are reclaimed.
80
80
  static OPTIMIZE_LOCK_TTL_MS = 30 * 60 * 1000;
81
+ // OPTIMIZE_LOCK_WAIT (1.3.6): the 1.3.4 lock gave up instantly when a live
82
+ // process held it, and worse, it treated an EMPTY lock file as stale and
83
+ // deleted it. But the owner creates the file with open("wx") and only THEN
84
+ // writes its pid — a reader landing in that window read 0 bytes, declared
85
+ // the lock stale, deleted it, and both processes "owned" the lock and raced
86
+ // optimize(), which is what puts "Compaction commit failed; leaving N
87
+ // rewritten fragments in place for GC" back on the TUI. Now a contender
88
+ // WAITS a bounded amount of time for a live owner to finish (serializing
89
+ // the compaction), and only reclaims after the pid should have been
90
+ // written or the 30min TTL passes.
91
+ static OPTIMIZE_LOCK_WAIT_MS = 10 * 1000;
81
92
  optimizing = false;
82
93
  lastOptimizeAt = 0;
83
94
  constructor(dbPath, cacheConfig) {
@@ -94,7 +105,9 @@ export class MemoryStore {
94
105
  async acquireOptimizeLock() {
95
106
  await mkdir(this.dbPath, { recursive: true }).catch(() => { });
96
107
  const lockFile = join(this.dbPath, ".optimize.lock");
97
- for (let attempt = 0; attempt < 2; attempt += 1) {
108
+ const deadline = Date.now() + MemoryStore.OPTIMIZE_LOCK_WAIT_MS;
109
+ let waitedMs = 0;
110
+ for (;;) {
98
111
  try {
99
112
  const handle = await open(lockFile, "wx");
100
113
  try {
@@ -102,6 +115,9 @@ export class MemoryStore {
102
115
  }
103
116
  catch { }
104
117
  await handle.close();
118
+ if (waitedMs > 0) {
119
+ log("debug", `[store] acquired compaction lock after ${waitedMs}ms wait`);
120
+ }
105
121
  return true;
106
122
  }
107
123
  catch (error) {
@@ -114,6 +130,16 @@ export class MemoryStore {
114
130
  const ownerPid = Number(pidStr);
115
131
  const ownerTs = Number(tsStr);
116
132
  if (!Number.isInteger(ownerPid) || ownerPid <= 0) {
133
+ // The owner creates the file with open("wx") and only
134
+ // THEN writes the pid; reading in between yields empty
135
+ // content. Treat that as "being initialized", not stale
136
+ // — this was the 1.3.4 bug that let two instances both
137
+ // own the lock and race optimize().
138
+ if (waitedMs < 250) {
139
+ await new Promise((resolve) => setTimeout(resolve, 50));
140
+ waitedMs += 50;
141
+ continue;
142
+ }
117
143
  stale = true;
118
144
  }
119
145
  else if (Number.isFinite(ownerTs) && Date.now() - ownerTs > MemoryStore.OPTIMIZE_LOCK_TTL_MS) {
@@ -127,16 +153,37 @@ export class MemoryStore {
127
153
  stale = true;
128
154
  }
129
155
  }
156
+ else {
157
+ // Same process already owns it (shouldn't happen with
158
+ // the optimizing guard; never deadlock on ourselves).
159
+ return false;
160
+ }
130
161
  }
131
162
  catch {
163
+ // Lock vanished between the EEXIST and the read (owner
164
+ // released); give it a short grace before reclaiming.
165
+ if (waitedMs < 150) {
166
+ await new Promise((resolve) => setTimeout(resolve, 50));
167
+ waitedMs += 50;
168
+ continue;
169
+ }
132
170
  stale = true;
133
171
  }
134
- if (!stale)
135
- return false;
172
+ if (!stale) {
173
+ // Live owner: wait for it to finish instead of racing it,
174
+ // until the bounded deadline (then skip this cycle).
175
+ if (Date.now() >= deadline) {
176
+ logFileOnly("debug", "[store] compaction lock still held after waiting; skipping this cycle");
177
+ return false;
178
+ }
179
+ await new Promise((resolve) => setTimeout(resolve, 100));
180
+ waitedMs += 100;
181
+ continue;
182
+ }
183
+ // Stale: reclaim and loop back to try creating the lock.
136
184
  await rm(lockFile, { force: true }).catch(() => { });
137
185
  }
138
186
  }
139
- return false;
140
187
  }
141
188
  async releaseOptimizeLock() {
142
189
  await rm(join(this.dbPath, ".optimize.lock"), { force: true }).catch(() => { });
@@ -155,42 +202,51 @@ export class MemoryStore {
155
202
  async maybeOptimizeAll(force = false) {
156
203
  if (this.optimizing)
157
204
  return;
158
- const elapsed = Date.now() - this.lastOptimizeAt;
159
- if (!force && elapsed < MemoryStore.OPTIMIZE_INTERVAL_MS)
160
- return;
161
- const tables = [this.table, this.eventTable, this.episodicTaskTable].filter(Boolean);
162
- const candidates = [];
163
- for (const table of tables) {
164
- let count = 0;
165
- // LANCE_COMPACTION_FIX (1.1.6): LanceDB stores each table on disk as
166
- // "<name>.lance", but Table.name only carries the bare name — so the
167
- // old readdir(.../table.name/_versions) always hit ENOENT, the catch
168
- // swallowed it, and optimize() NEVER ran. Result: 13k+ _versions and
169
- // 11k+ fragment files accumulated (disk + native handle/cache growth
170
- // per write, EMFILE/OOM risk). Try the real on-disk dir first.
171
- for (const dirName of [`${table.name}.lance`, table.name]) {
172
- try {
173
- const entries = await readdir(join(this.dbPath, dirName, "_versions"), { withFileTypes: true });
174
- count = entries.filter((e) => e.isFile()).length;
175
- if (count > 0)
176
- break;
205
+ // OPTIMIZE_GUARD (1.3.6): set the in-process guard synchronously,
206
+ // BEFORE any await. The 1.3.4 code set it only after the async
207
+ // candidate enumeration, so two overlapping calls in one process (the
208
+ // fire-and-forget write trigger plus an awaited explicit call on the
209
+ // first turn) could both pass the guard and run optimize()
210
+ // concurrently another way into the "Compaction commit failed" race.
211
+ this.optimizing = true;
212
+ let attempted = false;
213
+ try {
214
+ const elapsed = Date.now() - this.lastOptimizeAt;
215
+ if (!force && elapsed < MemoryStore.OPTIMIZE_INTERVAL_MS)
216
+ return;
217
+ const tables = [this.table, this.eventTable, this.episodicTaskTable].filter(Boolean);
218
+ const candidates = [];
219
+ for (const table of tables) {
220
+ let count = 0;
221
+ // LANCE_COMPACTION_FIX (1.1.6): LanceDB stores each table on disk as
222
+ // "<name>.lance", but Table.name only carries the bare name — so the
223
+ // old readdir(.../table.name/_versions) always hit ENOENT, the catch
224
+ // swallowed it, and optimize() NEVER ran. Result: 13k+ _versions and
225
+ // 11k+ fragment files accumulated (disk + native handle/cache growth
226
+ // per write, EMFILE/OOM risk). Try the real on-disk dir first.
227
+ for (const dirName of [`${table.name}.lance`, table.name]) {
228
+ try {
229
+ const entries = await readdir(join(this.dbPath, dirName, "_versions"), { withFileTypes: true });
230
+ count = entries.filter((e) => e.isFile()).length;
231
+ if (count > 0)
232
+ break;
233
+ }
234
+ catch { }
235
+ }
236
+ if (force || count >= MemoryStore.OPTIMIZE_MIN_VERSIONS) {
237
+ candidates.push({ table, count });
238
+ }
239
+ else {
240
+ log("debug", `[store] optimize skipped for ${table.name}: ${count} versions (min ${MemoryStore.OPTIMIZE_MIN_VERSIONS})`);
177
241
  }
178
- catch { }
179
- }
180
- if (force || count >= MemoryStore.OPTIMIZE_MIN_VERSIONS) {
181
- candidates.push({ table, count });
182
242
  }
183
- else {
184
- log("debug", `[store] optimize skipped for ${table.name}: ${count} versions (min ${MemoryStore.OPTIMIZE_MIN_VERSIONS})`);
243
+ if (force) {
244
+ this.lastOptimizeAt = Date.now();
245
+ attempted = true;
185
246
  }
186
- }
187
- if (force) {
188
- this.lastOptimizeAt = Date.now();
189
- }
190
- if (candidates.length === 0)
191
- return;
192
- this.optimizing = true;
193
- try {
247
+ if (candidates.length === 0)
248
+ return;
249
+ attempted = true;
194
250
  const lockHeld = await this.acquireOptimizeLock();
195
251
  if (!lockHeld) {
196
252
  logFileOnly("warn", "[store] optimize skipped: another process holds the compaction lock (retries next interval)");
@@ -226,7 +282,9 @@ export class MemoryStore {
226
282
  }
227
283
  finally {
228
284
  this.optimizing = false;
229
- this.lastOptimizeAt = Date.now();
285
+ if (attempted) {
286
+ this.lastOptimizeAt = Date.now();
287
+ }
230
288
  }
231
289
  }
232
290
  async init(vectorDim) {
@@ -1,5 +1,5 @@
1
1
  import { tool } from "@opencode-ai/plugin";
2
- import { deriveProjectScope } from "../scope.js";
2
+ import { deriveProjectScope, resolveScope } from "../scope.js";
3
3
  import { generateId, parseJsonObject } from "../utils.js";
4
4
  function unavailableMessage(provider) {
5
5
  return `Memory store unavailable (${provider} embedding may be offline). Will retry automatically.`;
@@ -17,7 +17,7 @@ export function createEpisodicTools(state) {
17
17
  await state.ensureInitialized();
18
18
  if (!state.initialized)
19
19
  return unavailableMessage(state.config.embedding.provider);
20
- const activeScope = args.scope ?? deriveProjectScope(context.directory || context.worktree);
20
+ const activeScope = resolveScope(args.scope, context.directory || context.worktree);
21
21
  const episode = {
22
22
  id: generateId(),
23
23
  sessionId: context.sessionID,
@@ -48,7 +48,7 @@ export function createEpisodicTools(state) {
48
48
  await state.ensureInitialized();
49
49
  if (!state.initialized)
50
50
  return unavailableMessage(state.config.embedding.provider);
51
- const activeScope = args.scope ?? deriveProjectScope(context.directory || context.worktree);
51
+ const activeScope = resolveScope(args.scope, context.directory || context.worktree);
52
52
  const stateFilter = args.state;
53
53
  const episodes = await state.store.queryTaskEpisodes(activeScope, stateFilter);
54
54
  if (episodes.length === 0) {
@@ -73,7 +73,7 @@ export function createEpisodicTools(state) {
73
73
  await state.ensureInitialized();
74
74
  if (!state.initialized)
75
75
  return unavailableMessage(state.config.embedding.provider);
76
- const activeScope = args.scope ?? deriveProjectScope(context.directory || context.worktree);
76
+ const activeScope = resolveScope(args.scope, context.directory || context.worktree);
77
77
  let queryVector = [];
78
78
  try {
79
79
  queryVector = await state.embedder.embed(args.query);
@@ -107,7 +107,7 @@ export function createEpisodicTools(state) {
107
107
  await state.ensureInitialized();
108
108
  if (!state.initialized)
109
109
  return unavailableMessage(state.config.embedding.provider);
110
- const activeScope = args.scope ?? deriveProjectScope(context.directory || context.worktree);
110
+ const activeScope = resolveScope(args.scope, context.directory || context.worktree);
111
111
  const result = await state.store.suggestRetryBudget(activeScope, args.minSamples ?? 3);
112
112
  if (!result) {
113
113
  return `Insufficient data for retry budget suggestion (need at least ${args.minSamples} failed tasks)`;
@@ -131,7 +131,7 @@ export function createEpisodicTools(state) {
131
131
  await state.ensureInitialized();
132
132
  if (!state.initialized)
133
133
  return unavailableMessage(state.config.embedding.provider);
134
- const activeScope = args.scope ?? deriveProjectScope(context.directory || context.worktree);
134
+ const activeScope = resolveScope(args.scope, context.directory || context.worktree);
135
135
  const strategies = await state.store.suggestRecoveryStrategies(activeScope, args.taskId);
136
136
  if (strategies.length === 0) {
137
137
  return `No recovery strategies found for task ${args.taskId}`;
@@ -1,5 +1,5 @@
1
1
  import { tool } from "@opencode-ai/plugin";
2
- import { deriveProjectScope, buildScopeFilter } from "../scope.js";
2
+ import { deriveProjectScope, buildScopeFilter, resolveScope } from "../scope.js";
3
3
  import { generateId } from "../utils.js";
4
4
  function unavailableMessage(provider) {
5
5
  return `Memory store unavailable (${provider} embedding may be offline). Will retry automatically.`;
@@ -17,7 +17,7 @@ export function createFeedbackTools(state) {
17
17
  await state.ensureInitialized();
18
18
  if (!state.initialized)
19
19
  return unavailableMessage(state.config.embedding.provider);
20
- const scope = args.scope ?? deriveProjectScope(context.directory || context.worktree);
20
+ const scope = resolveScope(args.scope, context.directory || context.worktree);
21
21
  await state.store.putEvent({
22
22
  id: generateId(),
23
23
  type: "feedback",
@@ -43,7 +43,7 @@ export function createFeedbackTools(state) {
43
43
  await state.ensureInitialized();
44
44
  if (!state.initialized)
45
45
  return unavailableMessage(state.config.embedding.provider);
46
- const scope = args.scope ?? deriveProjectScope(context.directory || context.worktree);
46
+ const scope = resolveScope(args.scope, context.directory || context.worktree);
47
47
  const scopes = buildScopeFilter(scope, state.config.includeGlobalScope);
48
48
  const exists = await state.store.hasMemory(args.id, scopes);
49
49
  if (!exists) {
@@ -74,7 +74,7 @@ export function createFeedbackTools(state) {
74
74
  await state.ensureInitialized();
75
75
  if (!state.initialized)
76
76
  return unavailableMessage(state.config.embedding.provider);
77
- const scope = args.scope ?? deriveProjectScope(context.directory || context.worktree);
77
+ const scope = resolveScope(args.scope, context.directory || context.worktree);
78
78
  const scopes = buildScopeFilter(scope, state.config.includeGlobalScope);
79
79
  const exists = await state.store.hasMemory(args.id, scopes);
80
80
  if (!exists) {
@@ -103,7 +103,7 @@ export function createFeedbackTools(state) {
103
103
  await state.ensureInitialized();
104
104
  if (!state.initialized)
105
105
  return unavailableMessage(state.config.embedding.provider);
106
- const scope = args.scope ?? deriveProjectScope(context.directory || context.worktree);
106
+ const scope = resolveScope(args.scope, context.directory || context.worktree);
107
107
  const summary = await state.store.summarizeEvents(scope, state.config.includeGlobalScope);
108
108
  return JSON.stringify(summary, null, 2);
109
109
  },
@@ -1,5 +1,5 @@
1
1
  import { tool } from "@opencode-ai/plugin";
2
- import { deriveProjectScope, buildScopeFilter } from "../scope.js";
2
+ import { deriveProjectScope, buildScopeFilter, resolveScope } from "../scope.js";
3
3
  import { generateId } from "../utils.js";
4
4
  import { getEmbedderHealth } from "../embedder.js";
5
5
  import { extractiveDigest, retentionCandidates } from "../store.js";
@@ -43,7 +43,7 @@ export function createMemoryTools(state) {
43
43
  await state.ensureInitialized();
44
44
  if (!state.initialized)
45
45
  return unavailableMessage(state.config.embedding.provider);
46
- const activeScope = args.scope ?? deriveProjectScope(context.directory || context.worktree);
46
+ const activeScope = resolveScope(args.scope, context.directory || context.worktree);
47
47
  const scopes = buildScopeFilter(activeScope, state.config.includeGlobalScope);
48
48
  let queryVector = [];
49
49
  let embedderFailed = false;
@@ -208,7 +208,7 @@ export function createMemoryTools(state) {
208
208
  if (!args.confirm) {
209
209
  return "Rejected: memory_delete requires confirm=true.";
210
210
  }
211
- const activeScope = args.scope ?? deriveProjectScope(context.directory || context.worktree);
211
+ const activeScope = resolveScope(args.scope, context.directory || context.worktree);
212
212
  const scopes = buildScopeFilter(activeScope, state.config.includeGlobalScope);
213
213
  const deleted = await state.store.deleteById(args.id, scopes);
214
214
  return deleted ? `Deleted memory ${args.id}.` : `Memory ${args.id} not found in current scope.`;
@@ -220,15 +220,16 @@ export function createMemoryTools(state) {
220
220
  scope: tool.schema.string(),
221
221
  confirm: tool.schema.boolean().default(false),
222
222
  },
223
- execute: async (args) => {
223
+ execute: async (args, context) => {
224
224
  await state.ensureInitialized();
225
225
  if (!state.initialized)
226
226
  return unavailableMessage(state.config.embedding.provider);
227
227
  if (!args.confirm) {
228
228
  return "Rejected: destructive clear requires confirm=true.";
229
229
  }
230
- const count = await state.store.clearScope(args.scope);
231
- return `Cleared ${count} memories from scope ${args.scope}.`;
230
+ const activeScope = resolveScope(args.scope, context.directory || context.worktree);
231
+ const count = await state.store.clearScope(activeScope);
232
+ return `Cleared ${count} memories from scope ${activeScope}.`;
232
233
  },
233
234
  }),
234
235
  memory_stats: tool({
@@ -240,7 +241,7 @@ export function createMemoryTools(state) {
240
241
  await state.ensureInitialized();
241
242
  if (!state.initialized)
242
243
  return unavailableMessage(state.config.embedding.provider);
243
- const scope = args.scope ?? deriveProjectScope(context.directory || context.worktree);
244
+ const scope = resolveScope(args.scope, context.directory || context.worktree);
244
245
  const entries = await state.store.list(scope, 20);
245
246
  const incompatibleVectors = await state.store.countIncompatibleVectors(buildScopeFilter(scope, state.config.includeGlobalScope), await state.embedder.dim());
246
247
  const health = state.store.getIndexHealth();
@@ -303,7 +304,7 @@ export function createMemoryTools(state) {
303
304
  // passed `args.scope` to cleanupExpiredEvents where `undefined`
304
305
  // meant ALL scopes (and the store's `scope LIKE 'project:%'`
305
306
  // matched every project scope).
306
- const activeScope = args.scope ?? deriveProjectScope(context.directory || context.worktree);
307
+ const activeScope = resolveScope(args.scope, context.directory || context.worktree);
307
308
  const scopes = buildScopeFilter(activeScope, state.config.includeGlobalScope);
308
309
  const cutoffTimestamp = Date.now() - status.retentionDays * 24 * 60 * 60 * 1000;
309
310
  if (args.dryRun) {
@@ -370,7 +371,7 @@ export function createMemoryTools(state) {
370
371
  if (args.text.length < state.config.minCaptureChars) {
371
372
  return `Content too short (minimum ${state.config.minCaptureChars} characters).`;
372
373
  }
373
- const activeScope = args.scope ?? deriveProjectScope(context.directory || context.worktree);
374
+ const activeScope = resolveScope(args.scope, context.directory || context.worktree);
374
375
  let vector = [];
375
376
  try {
376
377
  vector = await state.embedder.embed(args.text);
@@ -443,7 +444,7 @@ export function createMemoryTools(state) {
443
444
  await state.ensureInitialized();
444
445
  if (!state.initialized)
445
446
  return unavailableMessage(state.config.embedding.provider);
446
- const activeScope = args.scope ?? deriveProjectScope(context.directory || context.worktree);
447
+ const activeScope = resolveScope(args.scope, context.directory || context.worktree);
447
448
  const scopes = buildScopeFilter(activeScope, state.config.includeGlobalScope);
448
449
  if (args.force) {
449
450
  const deleted = await state.store.deleteById(args.id, scopes);
@@ -499,7 +500,7 @@ export function createMemoryTools(state) {
499
500
  await state.ensureInitialized();
500
501
  if (!state.initialized)
501
502
  return unavailableMessage(state.config.embedding.provider);
502
- const activeScope = args.scope ?? deriveProjectScope(context.directory || context.worktree);
503
+ const activeScope = resolveScope(args.scope, context.directory || context.worktree);
503
504
  const scopes = buildScopeFilter(activeScope, state.config.includeGlobalScope);
504
505
  const citation = await state.store.getCitation(args.id, scopes);
505
506
  if (!citation) {
@@ -535,7 +536,7 @@ export function createMemoryTools(state) {
535
536
  await state.ensureInitialized();
536
537
  if (!state.initialized)
537
538
  return unavailableMessage(state.config.embedding.provider);
538
- const activeScope = args.scope ?? deriveProjectScope(context.directory || context.worktree);
539
+ const activeScope = resolveScope(args.scope, context.directory || context.worktree);
539
540
  const scopes = buildScopeFilter(activeScope, state.config.includeGlobalScope);
540
541
  const result = await state.store.validateCitation(args.id, scopes);
541
542
  return JSON.stringify({
@@ -556,7 +557,7 @@ export function createMemoryTools(state) {
556
557
  await state.ensureInitialized();
557
558
  if (!state.initialized)
558
559
  return unavailableMessage(state.config.embedding.provider);
559
- const activeScope = args.scope ?? deriveProjectScope(context.directory || context.worktree);
560
+ const activeScope = resolveScope(args.scope, context.directory || context.worktree);
560
561
  const sinceTimestamp = Date.now() - (args.days ?? 7) * 24 * 60 * 60 * 1000;
561
562
  const memories = await state.store.listSince(activeScope, sinceTimestamp, 1000);
562
563
  if (memories.length === 0) {
@@ -598,7 +599,7 @@ ${recentSamples}
598
599
  await state.ensureInitialized();
599
600
  if (!state.initialized)
600
601
  return unavailableMessage(state.config.embedding.provider);
601
- const activeScope = args.scope ?? deriveProjectScope(context.directory || context.worktree);
602
+ const activeScope = resolveScope(args.scope, context.directory || context.worktree);
602
603
  const scopes = buildScopeFilter(activeScope, state.config.includeGlobalScope);
603
604
  const explanation = await state.store.explainMemory(args.id, scopes, activeScope, state.config.retrieval.recencyHalfLifeHours, state.config.globalDiscountFactor);
604
605
  if (!explanation) {
@@ -637,7 +638,7 @@ Explanation:
637
638
  if (!lastRecall) {
638
639
  return "No recent recall to explain. Use memory_search or wait for auto-recall first.";
639
640
  }
640
- const activeScope = args.scope ?? deriveProjectScope(context.directory || context.worktree);
641
+ const activeScope = resolveScope(args.scope, context.directory || context.worktree);
641
642
  const scopes = buildScopeFilter(activeScope, state.config.includeGlobalScope);
642
643
  const explanations = [];
643
644
  for (const result of lastRecall.results) {
@@ -697,7 +698,7 @@ ${explanations.join("\n")}`;
697
698
  if (!args.confirm) {
698
699
  return "Rejected: memory_scope_demote requires confirm=true.";
699
700
  }
700
- const projectScope = args.scope ?? deriveProjectScope(context.directory || context.worktree);
701
+ const projectScope = resolveScope(args.scope, context.directory || context.worktree);
701
702
  const globalExists = await state.store.hasMemory(args.id, ["global"]);
702
703
  if (!globalExists) {
703
704
  return `Memory ${args.id} not found in global scope or is not a global memory.`;
@@ -774,7 +775,7 @@ ${explanations.join("\n")}`;
774
775
  if (!args.confirm) {
775
776
  return "Rejected: memory_consolidate requires confirm=true.";
776
777
  }
777
- const targetScope = args.scope ?? deriveProjectScope(context.directory || context.worktree);
778
+ const targetScope = resolveScope(args.scope, context.directory || context.worktree);
778
779
  if (state.consolidationInProgress.get(targetScope)) {
779
780
  return JSON.stringify({ scope: targetScope, status: "already_in_progress", message: "Consolidation already in progress for this scope" });
780
781
  }
@@ -980,7 +981,7 @@ ${explanations.join("\n")}`;
980
981
  await state.ensureInitialized();
981
982
  if (!state.initialized)
982
983
  return unavailableMessage(state.config.embedding.provider);
983
- const scope = args.scope ?? deriveProjectScope(context.directory || context.worktree);
984
+ const scope = resolveScope(args.scope, context.directory || context.worktree);
984
985
  const dashboard = await state.store.getWeeklyEffectivenessSummary(scope, state.config.includeGlobalScope, args.days ?? 7);
985
986
  return JSON.stringify(dashboard, null, 2);
986
987
  },
@@ -995,7 +996,7 @@ ${explanations.join("\n")}`;
995
996
  await state.ensureInitialized();
996
997
  if (!state.initialized)
997
998
  return unavailableMessage(state.config.embedding.provider);
998
- const scope = args.scope ?? deriveProjectScope(context.directory || context.worktree);
999
+ const scope = resolveScope(args.scope, context.directory || context.worktree);
999
1000
  const kpi = await state.store.getKpiSummary(scope, args.days ?? 30);
1000
1001
  return JSON.stringify(kpi, null, 2);
1001
1002
  },
@@ -1012,7 +1013,7 @@ ${explanations.join("\n")}`;
1012
1013
  await state.ensureInitialized();
1013
1014
  if (!state.initialized)
1014
1015
  return unavailableMessage(state.config.embedding.provider);
1015
- const activeScope = args.scope ?? deriveProjectScope(context.directory || context.worktree);
1016
+ const activeScope = resolveScope(args.scope, context.directory || context.worktree);
1016
1017
  const scopes = buildScopeFilter(activeScope, state.config.includeGlobalScope);
1017
1018
  const records = await state.store.exportAllRecords(scopes);
1018
1019
  if (args.dryRun) {
@@ -1073,7 +1074,7 @@ ${explanations.join("\n")}`;
1073
1074
  if (!Array.isArray(payload?.memories)) {
1074
1075
  return JSON.stringify({ error: "Not a memory_export backup (missing memories array)" }, null, 2);
1075
1076
  }
1076
- const activeScope = args.scope ?? deriveProjectScope(context.directory || context.worktree);
1077
+ const activeScope = resolveScope(args.scope, context.directory || context.worktree);
1077
1078
  const scopes = buildScopeFilter(activeScope, state.config.includeGlobalScope);
1078
1079
  const source = payload?.memories ?? [];
1079
1080
  let imported = 0;
@@ -1203,7 +1204,7 @@ ${explanations.join("\n")}`;
1203
1204
  if (summarizeCfg.enabled === false) {
1204
1205
  return JSON.stringify({ error: "Summarization disabled via config summarize.enabled=false" }, null, 2);
1205
1206
  }
1206
- const activeScope = args.scope ?? deriveProjectScope(context.directory || context.worktree);
1207
+ const activeScope = resolveScope(args.scope, context.directory || context.worktree);
1207
1208
  const scopes = buildScopeFilter(activeScope, state.config.includeGlobalScope);
1208
1209
  const minAgeDays = args.minAgeDays ?? summarizeCfg.minAgeDays;
1209
1210
  const minGroupSize = args.minGroupSize ?? summarizeCfg.minGroupSize;
@@ -1347,7 +1348,7 @@ ${explanations.join("\n")}`;
1347
1348
  return JSON.stringify({ error: "Memory retention disabled via config retention.memory.enabled=false" }, null, 2);
1348
1349
  }
1349
1350
  const result = await sweepExpiredMemories(state, {
1350
- scope: args.scope ?? deriveProjectScope(context.directory || context.worktree),
1351
+ scope: resolveScope(args.scope, context.directory || context.worktree),
1351
1352
  dryRun: args.dryRun === true,
1352
1353
  unusedDays: args.unusedDays,
1353
1354
  minAgeDays: args.minAgeDays,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-memory-pro",
3
- "version": "1.3.5",
3
+ "version": "1.3.7",
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",