opencode-memory-pro 1.3.4 → 1.3.6

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.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).
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).
44
44
 
45
45
  Remove the old plugin pin at the same time:
46
46
 
@@ -406,6 +406,47 @@ Clean-break rename: sidecar is `opencode-memory-pro.json`, env prefix is
406
406
  are unchanged (`~/.opencode/memory/lancedb` + `~/.opencode/memory/graph.db`),
407
407
  so your memories and graph carry over untouched.
408
408
 
409
+ ## Changelog
410
+
411
+ ### v1.3.5 (2026-09-06)
412
+
413
+ Code-review hardening pass — bug fixes, no breaking changes:
414
+
415
+ - **Metadata is no longer destroyed on recall** (`updateMemoryUsage`): the
416
+ first recall of a global memory used to *replace* `metadataJson` with
417
+ `{ recalledProjects: [...] }`, silently dropping `pinned`, duplicate flags,
418
+ source, and graph entities — breaking `memory_export` provenance, duplicate-
419
+ aware pruning, and the pin protection in retention. It now merges into the
420
+ existing metadata blob.
421
+ - **LLM capture respects an explicit "nothing to store" verdict**: when the LLM
422
+ extraction succeeds but returns `[]`, the transcript no longer falls through
423
+ to the keyword heuristics and gets stored against the model's judgment — the
424
+ heuristic fallback now only runs when extraction actually fails.
425
+ - **Ephemeral LLM sessions no longer trigger consolidate/sweep**: the
426
+ `session.deleted` cleanup ran unconditionally with `force=true`, so in
427
+ `capture.mode="llm"` every ephemeral extraction/digest session paid a full
428
+ dedup + retention scan on teardown (and could spawn further LLM digests).
429
+ Own sessions are skipped entirely; pending transcript fragments are flushed
430
+ before user sessions close.
431
+ - **Consolidation only merges active memories**: digested (retention-hidden)
432
+ and disabled (soft-deleted) rows can no longer be picked as merge endpoints,
433
+ which previously flipped their status to `merged` and could resurrect
434
+ disabled memories / corrupt digest provenance.
435
+ - **`memory_import` replace-mode can't duplicate ids**: existence is now
436
+ checked against raw rows (digested/merged/disabled included) and replace
437
+ deletes the exact id before re-adding, so a hidden row is truly replaced
438
+ instead of leaving two physical rows per id. Citation chains are also
439
+ stringified consistently on write.
440
+ - **Episodic data is actually recorded**: failed validations now write numbered
441
+ retry attempts (so `retry_budget_suggest` has real data), and sessions that
442
+ receive injected memories are stamped `recallUsed` (so `memory_kpi`'s memory
443
+ lift is meaningful).
444
+ - **Smaller fixes**: `session.error` session-id fallback (`info.id`), bounded
445
+ `getEventTtlStatus` read, graph backfill covers all scopes, embedder
446
+ `fallbackActive` resets on recovery, scope-cache truncation is logged,
447
+ `memory_forget` records `wrong` feedback instead of polluting unhelpful
448
+ stats.
449
+
409
450
  ## License
410
451
 
411
452
  MIT — fork of `lancedb-opencode-pro` (MIT, tryweb).
package/dist/embedder.js CHANGED
@@ -42,6 +42,10 @@ async function embedWithRetry(embedder, config, text) {
42
42
  const result = await embedder.embed(text);
43
43
  globalEmbedderHealth.lastSuccess = Date.now();
44
44
  globalEmbedderHealth.lastError = null;
45
+ // EMBEDDER_HEALTH_RESET (1.3.5): fallbackActive used to stay true
46
+ // forever after one outage, so memory_stats reported "bm25-only"
47
+ // even after the provider recovered. Reset it on any success.
48
+ globalEmbedderHealth.fallbackActive = false;
45
49
  if (globalEmbedderHealth.status === "degraded") {
46
50
  globalEmbedderHealth.status = "healthy";
47
51
  log("info", "Embedder recovered, resuming normal mode");
@@ -83,6 +87,7 @@ async function dimWithRetry(embedder, config) {
83
87
  const result = await embedder.dim();
84
88
  globalEmbedderHealth.lastSuccess = Date.now();
85
89
  globalEmbedderHealth.lastError = null;
90
+ globalEmbedderHealth.fallbackActive = false;
86
91
  return result;
87
92
  }
88
93
  catch (error) {
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.4";
14
+ const PLUGIN_VERSION = "1.3.6";
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)
@@ -160,7 +160,11 @@ const plugin = async (input) => {
160
160
  // (ProviderAuthError/UnknownError/MessageAbortedError/ApiError —
161
161
  // all expose data.message), so we also keep the raw message and
162
162
  // classify it at session end to fill failureType/errorMessage.
163
- const sid = evt.properties?.sessionID;
163
+ // ERROR_SESSION_ID_FALLBACK (1.3.5): session.error carried the
164
+ // sessionID at properties.sessionID while created/deleted use
165
+ // properties.info.id — if the SDK ever omits one, don't lose
166
+ // the failure classification.
167
+ const sid = evt.properties?.sessionID ?? evt.properties?.info?.id;
164
168
  if (sid) {
165
169
  const err = evt.properties?.error;
166
170
  const message = typeof err?.data?.message === "string"
@@ -183,19 +187,37 @@ const plugin = async (input) => {
183
187
  if (evt.type === "session.deleted") {
184
188
  const sid = evt.properties?.info?.id;
185
189
  if (sid && !isOwnSession(sid)) {
190
+ // SESSION_DELETED_FLUSH (1.3.5): capture fragments were
191
+ // only ever flushed on session.idle, so quick sessions
192
+ // lost their transcript AND leaked their captureBuffer
193
+ // entry. Flush before the session disappears.
194
+ try {
195
+ await flushAutoCapture(sid, state, input.client);
196
+ }
197
+ catch (error) {
198
+ log("warn", `failed to flush capture on session end: ${toErrorMessage(error)}`);
199
+ }
186
200
  const entry = state.sessionErrors.get(sid);
187
201
  state.sessionErrors.delete(sid);
188
202
  const hadError = entry?.failed === true;
189
203
  await handleSessionEnd(sid, state, hadError ? "failed" : "success", entry?.message);
204
+ // Session is closing — final dedup pass for its scope. Uses the
205
+ // session's own directory (Session.info.directory) rather than
206
+ // client.session.get, which may 404 after deletion. force=true
207
+ // bypasses the idle cooldown since this is a one-time cleanup.
208
+ const deletedInfo = evt.properties?.info;
209
+ const finalScope = deletedInfo?.directory ? deriveProjectScope(deletedInfo.directory) : state.defaultScope;
210
+ maybeConsolidateDuplicates(state, finalScope, true);
211
+ maybeSweepExpiredMemories(state, finalScope, true);
190
212
  }
191
- // Session is closing final dedup pass for its scope. Uses the
192
- // session's own directory (Session.info.directory) rather than
193
- // client.session.get, which may 404 after deletion. force=true
194
- // bypasses the idle cooldown since this is a one-time cleanup.
195
- const deletedInfo = evt.properties?.info;
196
- const finalScope = deletedInfo?.directory ? deriveProjectScope(deletedInfo.directory) : state.defaultScope;
197
- maybeConsolidateDuplicates(state, finalScope, true);
198
- maybeSweepExpiredMemories(state, finalScope, true);
213
+ // OWN_SESSION_CLEANUP (1.3.5): the consolidate/sweep calls
214
+ // above used to run UNCONDITIONALLY — including for the
215
+ // plugin's own ephemeral LLM-capture/digest sessions, with
216
+ // force=true bypassing the cooldown. In capture.mode="llm"
217
+ // every LLM round trip paid a full consolidate+retention scan
218
+ // on teardown and could trigger further LLM digests. Own
219
+ // sessions are now skipped entirely (handleSessionEnd guard
220
+ // covers the rest).
199
221
  return;
200
222
  }
201
223
  const sessionID = evt.properties?.sessionID;
@@ -256,6 +278,18 @@ const plugin = async (input) => {
256
278
  if (validation) {
257
279
  try {
258
280
  await state.store.addValidationOutcome(taskId, activeScope, validation);
281
+ // RETRY_ATTEMPT_WIRE (1.3.5): failed validations are the
282
+ // only real "attempt" signal the plugin sees (same command
283
+ // family retried in the sessions). Record them so
284
+ // retry_budget_suggest has data instead of always
285
+ // answering "1 retry". addRetryAttempt assigns the
286
+ // 1-based attemptNumber/timestamp.
287
+ if (validation.status === "fail") {
288
+ await state.store.addRetryAttempt(taskId, activeScope, {
289
+ outcome: "failed",
290
+ errorMessage: (validation.output ?? "").slice(0, 500),
291
+ });
292
+ }
259
293
  }
260
294
  catch (error) {
261
295
  log("warn", `failed to record validation outcome: ${toErrorMessage(error)}`);
@@ -448,6 +482,14 @@ const plugin = async (input) => {
448
482
  for (const result of limitedResults) {
449
483
  state.store.updateMemoryUsage(result.record.id, activeScope, scopes).catch(() => { });
450
484
  }
485
+ // EPISODE_RECALL_USED (1.3.5): stamp the session's task episode so
486
+ // memory_kpi's memory-lift metric can actually separate tasks that
487
+ // used recall from tasks that didn't (nothing ever set it before).
488
+ const recallEpisode = state.activeEpisodes.get(eventInput.sessionID);
489
+ if (recallEpisode) {
490
+ state.store.markEpisodeRecallUsed(recallEpisode.taskId, recallEpisode.scope)
491
+ .catch((error) => log("warn", `failed to mark episode recall used: ${toErrorMessage(error)}`));
492
+ }
451
493
  // Apply summarization if configured
452
494
  const summarizationConfig = createSummarizationConfig(state.config.injection);
453
495
  const processedResults = limitedResults.map((item) => {
@@ -554,8 +596,10 @@ async function createRuntimeState(input) {
554
596
  if (state.graph?.enabled) {
555
597
  // One-time backfill: index existing memories into the graph
556
598
  // so recall boosts work immediately, not only for new captures.
599
+ // GRAPH_BACKFILL_ALL (1.3.5): was readByScopes(["global"]),
600
+ // which skipped every project-scoped memory.
557
601
  try {
558
- const records = await state.store.readByScopes(["global"]);
602
+ const records = await state.store.readAllActive();
559
603
  state.graph.reindexMemories(records);
560
604
  }
561
605
  catch (error) {
@@ -671,6 +715,14 @@ async function flushAutoCapture(sessionID, state, client) {
671
715
  skipReason: candidates === null ? "llm-unavailable" : "llm-empty-result",
672
716
  text: combined,
673
717
  });
718
+ // LLM_EMPTY_VERDICT (1.3.5): when the LLM ran fine but deliberately
719
+ // returned [] ("nothing here is memory-worthy"), that is a real
720
+ // verdict, not a failure — falling through to the keyword heuristics
721
+ // stored transcript content the LLM explicitly rejected. Only fall
722
+ // back when extraction FAILED (candidates === null).
723
+ if (candidates !== null) {
724
+ return;
725
+ }
674
726
  }
675
727
  const result = extractCaptureCandidate(combined, state.config.minCaptureChars);
676
728
  if (!result.candidate) {
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) {
@@ -409,7 +467,10 @@ export class MemoryStore {
409
467
  }
410
468
  const cutoffTimestamp = Date.now() - retentionDays * 24 * 60 * 60 * 1000;
411
469
  const table = this.requireEventTable();
412
- const allExpired = await table.query().where(`timestamp < ${cutoffTimestamp}`).toArray();
470
+ // TTL_STATUS_BOUND (1.3.5): was an unbounded toArray() over every
471
+ // expired event (this store has seen 636MB event tables). Bound it
472
+ // like every other read path; the status is an estimate anyway.
473
+ const allExpired = await table.query().where(`timestamp < ${cutoffTimestamp}`).limit(100000).toArray();
413
474
  const expiredCount = allExpired.length;
414
475
  const scopeBreakdown = {};
415
476
  for (const row of allExpired) {
@@ -429,6 +490,14 @@ export class MemoryStore {
429
490
  tags: record.tags ?? undefined,
430
491
  status: record.status ?? "active",
431
492
  parentId: record.parentId ?? undefined,
493
+ // CITATION_CHAIN_SERIALIZE (1.3.5): citationChain is a STRING
494
+ // column (normalizeRow JSON.parses it on read; updateCitation
495
+ // stringifies it). The import path passed a raw array, which
496
+ // stored "src" via Array.prototype.toString and broke chains on
497
+ // round trip. Normalize at the chokepoint so every writer agrees.
498
+ citationChain: Array.isArray(record.citationChain)
499
+ ? JSON.stringify(record.citationChain)
500
+ : record.citationChain,
432
501
  };
433
502
  await table.add([recordWithDefaults]);
434
503
  this.invalidateScope(record.scope);
@@ -545,6 +614,20 @@ export class MemoryStore {
545
614
  this.notifyGraphRemoved(match.id);
546
615
  return true;
547
616
  }
617
+ // DELETE_BY_RAW_ID (1.3.5): exact-id hard delete that sees rows the
618
+ // filtered reads hide (digested/merged/disabled). Used by memory_import
619
+ // replace-mode so a pre-existing hidden row is actually replaced instead
620
+ // of leaving two physical rows with the same id.
621
+ async deleteByIdRaw(id) {
622
+ const table = this.requireTable();
623
+ const rows = await table.query().where(`id = '${escapeSql(id)}'`).limit(1).toArray();
624
+ if (rows.length === 0)
625
+ return false;
626
+ await table.delete(`id = '${escapeSql(id)}'`);
627
+ this.invalidateScope(rows[0].scope);
628
+ this.notifyGraphRemoved(id);
629
+ return true;
630
+ }
548
631
  async softDeleteMemory(id, scopes) {
549
632
  const rows = await this.readByScopes(scopes);
550
633
  const match = rows.find((row) => this.matchesId(row.id, id));
@@ -633,7 +716,15 @@ export class MemoryStore {
633
716
  return toDelete.length;
634
717
  }
635
718
  async consolidateDuplicates(scope, threshold, candidateLimit = 50) {
636
- const rows = await this.readByScopesIncludingMerged([scope]);
719
+ // MERGE_STATUS_FILTER (1.3.5): consolidation used to run over
720
+ // readByScopesIncludingMerged and only consulted METADATA
721
+ // status:merged/mergedFrom — so digested (retention-hidden) and
722
+ // disabled (soft-deleted) rows could be picked as merge endpoints and
723
+ // have their column status overwritten to "merged", resurrecting
724
+ // disabled rows and corrupting digest provenance. Only active/unset
725
+ // rows may participate.
726
+ let rows = await this.readByScopesIncludingMerged([scope]);
727
+ rows = rows.filter((r) => r.status === undefined || r.status === null || r.status === "" || r.status === "active");
637
728
  if (rows.length === 0) {
638
729
  return { mergedPairs: 0, updatedRecords: 0, skippedRecords: 0 };
639
730
  }
@@ -999,14 +1090,23 @@ export class MemoryStore {
999
1090
  const projects = extractRecalledProjects(metadataJson);
1000
1091
  if (!projects.has(projectScope)) {
1001
1092
  projects.add(projectScope);
1093
+ // METADATA_MERGE_FIX (1.3.5): this previously REPLACED
1094
+ // metadataJson with `{ recalledProjects: [...] }`, silently
1095
+ // dropping source / isPotentialDuplicate / graphEntities /
1096
+ // pinned on the first recall of every global memory. In the
1097
+ // default scoping:"global" mode that hit every memory, which
1098
+ // broke pruneScope (duplicate-flag based) and retention
1099
+ // (pinned protections). Merge into the existing blob instead.
1100
+ const baseMeta = parseMetadata(metadataJson);
1002
1101
  if (projects.size > 100) {
1003
1102
  const arr = Array.from(projects);
1004
1103
  arr.splice(0, arr.length - 100);
1005
- metadataJson = JSON.stringify({ recalledProjects: arr });
1104
+ baseMeta.recalledProjects = arr;
1006
1105
  }
1007
1106
  else {
1008
- metadataJson = JSON.stringify({ recalledProjects: Array.from(projects) });
1107
+ baseMeta.recalledProjects = Array.from(projects);
1009
1108
  }
1109
+ metadataJson = JSON.stringify(baseMeta);
1010
1110
  newProjectCount = projects.size;
1011
1111
  }
1012
1112
  }
@@ -1532,6 +1632,11 @@ export class MemoryStore {
1532
1632
  const records = await this.readByScopes([scope]);
1533
1633
  let sortedRecords = records;
1534
1634
  if (records.length > this.cacheConfig.maxRecordsPerScope) {
1635
+ // SCOPE_CACHE_TRUNCATE (1.3.5): truncation used to be
1636
+ // silent, which made older memories permanently invisible
1637
+ // to search. Log it once per cache rebuild so the operator
1638
+ // knows to raise cacheConfig.maxRecordsPerScope.
1639
+ log("warn", `[store] scope cache truncated: ${scope} has ${records.length} records but maxRecordsPerScope=${this.cacheConfig.maxRecordsPerScope}; older memories are not searchable until the limit is raised`);
1535
1640
  sortedRecords = [...records].sort((a, b) => b.timestamp - a.timestamp).slice(0, this.cacheConfig.maxRecordsPerScope);
1536
1641
  }
1537
1642
  const tokenized = sortedRecords.map((record) => tokenize(record.text));
@@ -1812,7 +1917,54 @@ export class MemoryStore {
1812
1917
  return patterns.sort((a, b) => b.count - a.count);
1813
1918
  }
1814
1919
  async addRetryAttempt(taskId, scope, attempt) {
1815
- return this.appendToEpisodeField(taskId, scope, "retryAttemptsJson", (raw) => JSON.parse(raw || "[]"), (items) => JSON.stringify(items), attempt, (item) => ({ ...item, timestamp: Date.now() }));
1920
+ // RETRY_ATTEMPT_COUNT (1.3.5): was a blind push via
1921
+ // appendToEpisodeField — attemptNumber was never provided by any
1922
+ // caller, so retry_budget_suggest always saw attempts.length === 0.
1923
+ // Compute the 1-based attempt number from the existing array so the
1924
+ // retry-budget median is over real values. Also the only live writer
1925
+ // (tool.execute.after validation failures) now wired in index.js.
1926
+ await this.ensureEpisodicTaskTable(384);
1927
+ const table = this.requireEpisodicTaskTable();
1928
+ const rows = await table.query().where(`taskId = '${escapeSql(taskId)}' AND scope = '${escapeSql(scope)}'`).toArray();
1929
+ if (rows.length === 0)
1930
+ return false;
1931
+ const existing = rows[0];
1932
+ const items = JSON.parse(existing.retryAttemptsJson || "[]");
1933
+ items.push({
1934
+ ...attempt,
1935
+ attemptNumber: items.length + 1,
1936
+ timestamp: Date.now(),
1937
+ });
1938
+ await table.update({
1939
+ where: `id = '${escapeSql(existing.id)}'`,
1940
+ values: { retryAttemptsJson: JSON.stringify(items) },
1941
+ });
1942
+ return true;
1943
+ }
1944
+ // EPISODE_RECALL_USED (1.3.5): stamps metadata.recallUsed on the session's
1945
+ // task episode so calculateMemoryLift can separate tasks that used recall
1946
+ // from tasks that didn't (the field existed but nothing ever set it, so
1947
+ // memory_kpi always reported "no-recall-data").
1948
+ async markEpisodeRecallUsed(taskId, scope) {
1949
+ await this.ensureEpisodicTaskTable(384);
1950
+ const table = this.requireEpisodicTaskTable();
1951
+ const rows = await table.query().where(`taskId = '${escapeSql(taskId)}' AND scope = '${escapeSql(scope)}'`).toArray();
1952
+ if (rows.length === 0)
1953
+ return false;
1954
+ const existing = rows[0];
1955
+ let metadata = {};
1956
+ try {
1957
+ metadata = JSON.parse(existing.metadataJson || "{}");
1958
+ }
1959
+ catch {
1960
+ metadata = {};
1961
+ }
1962
+ metadata.recallUsed = true;
1963
+ await table.update({
1964
+ where: `id = '${escapeSql(existing.id)}'`,
1965
+ values: { metadataJson: JSON.stringify(metadata) },
1966
+ });
1967
+ return true;
1816
1968
  }
1817
1969
  async addRecoveryStrategy(taskId, scope, strategy) {
1818
1970
  return this.appendToEpisodeField(taskId, scope, "recoveryStrategiesJson", (raw) => JSON.parse(raw || "[]"), (items) => JSON.stringify(items), strategy, (item) => ({ ...item, attemptedAt: Date.now() }));
@@ -2304,6 +2456,48 @@ export class MemoryStore {
2304
2456
  .map((row) => normalizeRow(row))
2305
2457
  .filter((row) => row !== null);
2306
2458
  }
2459
+ // GRAPH_BACKFILL_ALL (1.3.5): the one-time graph backfill previously
2460
+ // read only ["global"], so project-scoped memories (scoping:"project")
2461
+ // never entered the entity graph. Reads every active row across ALL
2462
+ // scopes with the same status filter as readByScopes.
2463
+ async readAllActive() {
2464
+ const table = this.requireTable();
2465
+ const rows = await table
2466
+ .query()
2467
+ .where(`(status != 'disabled' OR status IS NULL OR status = '') AND NOT (status = 'merged') AND NOT (status = 'digested') AND NOT (metadataJson LIKE '%"status":"merged"%')`)
2468
+ .select([
2469
+ "id",
2470
+ "text",
2471
+ "vector",
2472
+ "category",
2473
+ "scope",
2474
+ "importance",
2475
+ "timestamp",
2476
+ "lastRecalled",
2477
+ "recallCount",
2478
+ "projectCount",
2479
+ "schemaVersion",
2480
+ "embeddingModel",
2481
+ "vectorDim",
2482
+ "metadataJson",
2483
+ "userId",
2484
+ "teamId",
2485
+ "sourceSessionId",
2486
+ "confidence",
2487
+ "tags",
2488
+ "status",
2489
+ "parentId",
2490
+ "citationSource",
2491
+ "citationTimestamp",
2492
+ "citationStatus",
2493
+ "citationChain",
2494
+ ])
2495
+ .limit(100000)
2496
+ .toArray();
2497
+ return rows
2498
+ .map((row) => normalizeRow(row))
2499
+ .filter((row) => row !== null);
2500
+ }
2307
2501
  async ensureIndexes() {
2308
2502
  const table = this.requireTable();
2309
2503
  // INDEX_USAGE_FIX (1.1.7): the FTS "text" index was created here but
@@ -453,12 +453,17 @@ export function createMemoryTools(state) {
453
453
  await state.store.putEvent({
454
454
  id: generateId(),
455
455
  type: "feedback",
456
- feedbackType: "useful",
456
+ // FORGET_FEEDBACK (1.3.5): was feedbackType "useful" with
457
+ // helpful:false, polluting the unhelpful-recall stats.
458
+ // "wrong" is the semantically-correct signal (memory
459
+ // should not be stored) and feeds the false-positive
460
+ // rate / wrong penalty.
461
+ feedbackType: "wrong",
457
462
  scope: activeScope,
458
463
  sessionID: context.sessionID,
459
464
  timestamp: Date.now(),
460
465
  memoryId: args.id,
461
- helpful: false,
466
+ reason: "explicit-forget (hard delete)",
462
467
  metadataJson: JSON.stringify({ source: "explicit-forget", hardDelete: true }),
463
468
  });
464
469
  return `Permanently deleted memory ${args.id}.`;
@@ -470,12 +475,14 @@ export function createMemoryTools(state) {
470
475
  await state.store.putEvent({
471
476
  id: generateId(),
472
477
  type: "feedback",
473
- feedbackType: "useful",
478
+ // FORGET_FEEDBACK (1.3.5): was feedbackType "useful" with
479
+ // helpful:false, polluting the unhelpful-recall stats.
480
+ feedbackType: "wrong",
474
481
  scope: activeScope,
475
482
  sessionID: context.sessionID,
476
483
  timestamp: Date.now(),
477
484
  memoryId: args.id,
478
- helpful: false,
485
+ reason: "explicit-forget (soft delete)",
479
486
  metadataJson: JSON.stringify({ source: "explicit-forget", hardDelete: false }),
480
487
  });
481
488
  return `Soft-deleted (disabled) memory ${args.id}. Use force=true for permanent deletion.`;
@@ -1081,7 +1088,14 @@ ${explanations.join("\n")}`;
1081
1088
  continue;
1082
1089
  }
1083
1090
  try {
1084
- const exists = await state.store.hasMemory(m.id, scopes);
1091
+ // IMPORT_EXISTS_RAW (1.3.5): hasMemory only sees ACTIVE
1092
+ // rows, so replace-mode imported a second active row
1093
+ // with the same id when a digested/merged/disabled row
1094
+ // already existed (Lance has no primary key → two
1095
+ // physical rows per id → ambiguous lookups). Check the
1096
+ // raw id and delete the raw row on replace.
1097
+ const existingRows = await state.store.findRawRecordsByIds([m.id], scopes);
1098
+ const exists = existingRows.length > 0;
1085
1099
  if (exists && args.mode !== "replace") {
1086
1100
  skipped += 1;
1087
1101
  continue;
@@ -1096,7 +1110,7 @@ ${explanations.join("\n")}`;
1096
1110
  continue;
1097
1111
  }
1098
1112
  if (exists) {
1099
- await state.store.deleteById(m.id, scopes);
1113
+ await state.store.deleteByIdRaw(m.id);
1100
1114
  }
1101
1115
  let vector = Array.isArray(m.vector) ? m.vector.map(Number) : [];
1102
1116
  if (vector.length !== embedderDim) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-memory-pro",
3
- "version": "1.3.4",
3
+ "version": "1.3.6",
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",