opencode-memory-pro 1.3.3 → 1.3.5

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.3";
14
+ const PLUGIN_VERSION = "1.3.5";
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
@@ -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
@@ -325,7 +409,10 @@ export class MemoryStore {
325
409
  }
326
410
  const cutoffTimestamp = Date.now() - retentionDays * 24 * 60 * 60 * 1000;
327
411
  const table = this.requireEventTable();
328
- const allExpired = await table.query().where(`timestamp < ${cutoffTimestamp}`).toArray();
412
+ // TTL_STATUS_BOUND (1.3.5): was an unbounded toArray() over every
413
+ // expired event (this store has seen 636MB event tables). Bound it
414
+ // like every other read path; the status is an estimate anyway.
415
+ const allExpired = await table.query().where(`timestamp < ${cutoffTimestamp}`).limit(100000).toArray();
329
416
  const expiredCount = allExpired.length;
330
417
  const scopeBreakdown = {};
331
418
  for (const row of allExpired) {
@@ -345,6 +432,14 @@ export class MemoryStore {
345
432
  tags: record.tags ?? undefined,
346
433
  status: record.status ?? "active",
347
434
  parentId: record.parentId ?? undefined,
435
+ // CITATION_CHAIN_SERIALIZE (1.3.5): citationChain is a STRING
436
+ // column (normalizeRow JSON.parses it on read; updateCitation
437
+ // stringifies it). The import path passed a raw array, which
438
+ // stored "src" via Array.prototype.toString and broke chains on
439
+ // round trip. Normalize at the chokepoint so every writer agrees.
440
+ citationChain: Array.isArray(record.citationChain)
441
+ ? JSON.stringify(record.citationChain)
442
+ : record.citationChain,
348
443
  };
349
444
  await table.add([recordWithDefaults]);
350
445
  this.invalidateScope(record.scope);
@@ -461,6 +556,20 @@ export class MemoryStore {
461
556
  this.notifyGraphRemoved(match.id);
462
557
  return true;
463
558
  }
559
+ // DELETE_BY_RAW_ID (1.3.5): exact-id hard delete that sees rows the
560
+ // filtered reads hide (digested/merged/disabled). Used by memory_import
561
+ // replace-mode so a pre-existing hidden row is actually replaced instead
562
+ // of leaving two physical rows with the same id.
563
+ async deleteByIdRaw(id) {
564
+ const table = this.requireTable();
565
+ const rows = await table.query().where(`id = '${escapeSql(id)}'`).limit(1).toArray();
566
+ if (rows.length === 0)
567
+ return false;
568
+ await table.delete(`id = '${escapeSql(id)}'`);
569
+ this.invalidateScope(rows[0].scope);
570
+ this.notifyGraphRemoved(id);
571
+ return true;
572
+ }
464
573
  async softDeleteMemory(id, scopes) {
465
574
  const rows = await this.readByScopes(scopes);
466
575
  const match = rows.find((row) => this.matchesId(row.id, id));
@@ -549,7 +658,15 @@ export class MemoryStore {
549
658
  return toDelete.length;
550
659
  }
551
660
  async consolidateDuplicates(scope, threshold, candidateLimit = 50) {
552
- const rows = await this.readByScopesIncludingMerged([scope]);
661
+ // MERGE_STATUS_FILTER (1.3.5): consolidation used to run over
662
+ // readByScopesIncludingMerged and only consulted METADATA
663
+ // status:merged/mergedFrom — so digested (retention-hidden) and
664
+ // disabled (soft-deleted) rows could be picked as merge endpoints and
665
+ // have their column status overwritten to "merged", resurrecting
666
+ // disabled rows and corrupting digest provenance. Only active/unset
667
+ // rows may participate.
668
+ let rows = await this.readByScopesIncludingMerged([scope]);
669
+ rows = rows.filter((r) => r.status === undefined || r.status === null || r.status === "" || r.status === "active");
553
670
  if (rows.length === 0) {
554
671
  return { mergedPairs: 0, updatedRecords: 0, skippedRecords: 0 };
555
672
  }
@@ -915,14 +1032,23 @@ export class MemoryStore {
915
1032
  const projects = extractRecalledProjects(metadataJson);
916
1033
  if (!projects.has(projectScope)) {
917
1034
  projects.add(projectScope);
1035
+ // METADATA_MERGE_FIX (1.3.5): this previously REPLACED
1036
+ // metadataJson with `{ recalledProjects: [...] }`, silently
1037
+ // dropping source / isPotentialDuplicate / graphEntities /
1038
+ // pinned on the first recall of every global memory. In the
1039
+ // default scoping:"global" mode that hit every memory, which
1040
+ // broke pruneScope (duplicate-flag based) and retention
1041
+ // (pinned protections). Merge into the existing blob instead.
1042
+ const baseMeta = parseMetadata(metadataJson);
918
1043
  if (projects.size > 100) {
919
1044
  const arr = Array.from(projects);
920
1045
  arr.splice(0, arr.length - 100);
921
- metadataJson = JSON.stringify({ recalledProjects: arr });
1046
+ baseMeta.recalledProjects = arr;
922
1047
  }
923
1048
  else {
924
- metadataJson = JSON.stringify({ recalledProjects: Array.from(projects) });
1049
+ baseMeta.recalledProjects = Array.from(projects);
925
1050
  }
1051
+ metadataJson = JSON.stringify(baseMeta);
926
1052
  newProjectCount = projects.size;
927
1053
  }
928
1054
  }
@@ -1448,6 +1574,11 @@ export class MemoryStore {
1448
1574
  const records = await this.readByScopes([scope]);
1449
1575
  let sortedRecords = records;
1450
1576
  if (records.length > this.cacheConfig.maxRecordsPerScope) {
1577
+ // SCOPE_CACHE_TRUNCATE (1.3.5): truncation used to be
1578
+ // silent, which made older memories permanently invisible
1579
+ // to search. Log it once per cache rebuild so the operator
1580
+ // knows to raise cacheConfig.maxRecordsPerScope.
1581
+ 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`);
1451
1582
  sortedRecords = [...records].sort((a, b) => b.timestamp - a.timestamp).slice(0, this.cacheConfig.maxRecordsPerScope);
1452
1583
  }
1453
1584
  const tokenized = sortedRecords.map((record) => tokenize(record.text));
@@ -1728,7 +1859,54 @@ export class MemoryStore {
1728
1859
  return patterns.sort((a, b) => b.count - a.count);
1729
1860
  }
1730
1861
  async addRetryAttempt(taskId, scope, attempt) {
1731
- return this.appendToEpisodeField(taskId, scope, "retryAttemptsJson", (raw) => JSON.parse(raw || "[]"), (items) => JSON.stringify(items), attempt, (item) => ({ ...item, timestamp: Date.now() }));
1862
+ // RETRY_ATTEMPT_COUNT (1.3.5): was a blind push via
1863
+ // appendToEpisodeField — attemptNumber was never provided by any
1864
+ // caller, so retry_budget_suggest always saw attempts.length === 0.
1865
+ // Compute the 1-based attempt number from the existing array so the
1866
+ // retry-budget median is over real values. Also the only live writer
1867
+ // (tool.execute.after validation failures) now wired in index.js.
1868
+ await this.ensureEpisodicTaskTable(384);
1869
+ const table = this.requireEpisodicTaskTable();
1870
+ const rows = await table.query().where(`taskId = '${escapeSql(taskId)}' AND scope = '${escapeSql(scope)}'`).toArray();
1871
+ if (rows.length === 0)
1872
+ return false;
1873
+ const existing = rows[0];
1874
+ const items = JSON.parse(existing.retryAttemptsJson || "[]");
1875
+ items.push({
1876
+ ...attempt,
1877
+ attemptNumber: items.length + 1,
1878
+ timestamp: Date.now(),
1879
+ });
1880
+ await table.update({
1881
+ where: `id = '${escapeSql(existing.id)}'`,
1882
+ values: { retryAttemptsJson: JSON.stringify(items) },
1883
+ });
1884
+ return true;
1885
+ }
1886
+ // EPISODE_RECALL_USED (1.3.5): stamps metadata.recallUsed on the session's
1887
+ // task episode so calculateMemoryLift can separate tasks that used recall
1888
+ // from tasks that didn't (the field existed but nothing ever set it, so
1889
+ // memory_kpi always reported "no-recall-data").
1890
+ async markEpisodeRecallUsed(taskId, scope) {
1891
+ await this.ensureEpisodicTaskTable(384);
1892
+ const table = this.requireEpisodicTaskTable();
1893
+ const rows = await table.query().where(`taskId = '${escapeSql(taskId)}' AND scope = '${escapeSql(scope)}'`).toArray();
1894
+ if (rows.length === 0)
1895
+ return false;
1896
+ const existing = rows[0];
1897
+ let metadata = {};
1898
+ try {
1899
+ metadata = JSON.parse(existing.metadataJson || "{}");
1900
+ }
1901
+ catch {
1902
+ metadata = {};
1903
+ }
1904
+ metadata.recallUsed = true;
1905
+ await table.update({
1906
+ where: `id = '${escapeSql(existing.id)}'`,
1907
+ values: { metadataJson: JSON.stringify(metadata) },
1908
+ });
1909
+ return true;
1732
1910
  }
1733
1911
  async addRecoveryStrategy(taskId, scope, strategy) {
1734
1912
  return this.appendToEpisodeField(taskId, scope, "recoveryStrategiesJson", (raw) => JSON.parse(raw || "[]"), (items) => JSON.stringify(items), strategy, (item) => ({ ...item, attemptedAt: Date.now() }));
@@ -2220,6 +2398,48 @@ export class MemoryStore {
2220
2398
  .map((row) => normalizeRow(row))
2221
2399
  .filter((row) => row !== null);
2222
2400
  }
2401
+ // GRAPH_BACKFILL_ALL (1.3.5): the one-time graph backfill previously
2402
+ // read only ["global"], so project-scoped memories (scoping:"project")
2403
+ // never entered the entity graph. Reads every active row across ALL
2404
+ // scopes with the same status filter as readByScopes.
2405
+ async readAllActive() {
2406
+ const table = this.requireTable();
2407
+ const rows = await table
2408
+ .query()
2409
+ .where(`(status != 'disabled' OR status IS NULL OR status = '') AND NOT (status = 'merged') AND NOT (status = 'digested') AND NOT (metadataJson LIKE '%"status":"merged"%')`)
2410
+ .select([
2411
+ "id",
2412
+ "text",
2413
+ "vector",
2414
+ "category",
2415
+ "scope",
2416
+ "importance",
2417
+ "timestamp",
2418
+ "lastRecalled",
2419
+ "recallCount",
2420
+ "projectCount",
2421
+ "schemaVersion",
2422
+ "embeddingModel",
2423
+ "vectorDim",
2424
+ "metadataJson",
2425
+ "userId",
2426
+ "teamId",
2427
+ "sourceSessionId",
2428
+ "confidence",
2429
+ "tags",
2430
+ "status",
2431
+ "parentId",
2432
+ "citationSource",
2433
+ "citationTimestamp",
2434
+ "citationStatus",
2435
+ "citationChain",
2436
+ ])
2437
+ .limit(100000)
2438
+ .toArray();
2439
+ return rows
2440
+ .map((row) => normalizeRow(row))
2441
+ .filter((row) => row !== null);
2442
+ }
2223
2443
  async ensureIndexes() {
2224
2444
  const table = this.requireTable();
2225
2445
  // 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.3",
3
+ "version": "1.3.5",
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",