opencode-memory-pro 1.3.4 → 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.4";
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
@@ -409,7 +409,10 @@ export class MemoryStore {
409
409
  }
410
410
  const cutoffTimestamp = Date.now() - retentionDays * 24 * 60 * 60 * 1000;
411
411
  const table = this.requireEventTable();
412
- 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();
413
416
  const expiredCount = allExpired.length;
414
417
  const scopeBreakdown = {};
415
418
  for (const row of allExpired) {
@@ -429,6 +432,14 @@ export class MemoryStore {
429
432
  tags: record.tags ?? undefined,
430
433
  status: record.status ?? "active",
431
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,
432
443
  };
433
444
  await table.add([recordWithDefaults]);
434
445
  this.invalidateScope(record.scope);
@@ -545,6 +556,20 @@ export class MemoryStore {
545
556
  this.notifyGraphRemoved(match.id);
546
557
  return true;
547
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
+ }
548
573
  async softDeleteMemory(id, scopes) {
549
574
  const rows = await this.readByScopes(scopes);
550
575
  const match = rows.find((row) => this.matchesId(row.id, id));
@@ -633,7 +658,15 @@ export class MemoryStore {
633
658
  return toDelete.length;
634
659
  }
635
660
  async consolidateDuplicates(scope, threshold, candidateLimit = 50) {
636
- 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");
637
670
  if (rows.length === 0) {
638
671
  return { mergedPairs: 0, updatedRecords: 0, skippedRecords: 0 };
639
672
  }
@@ -999,14 +1032,23 @@ export class MemoryStore {
999
1032
  const projects = extractRecalledProjects(metadataJson);
1000
1033
  if (!projects.has(projectScope)) {
1001
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);
1002
1043
  if (projects.size > 100) {
1003
1044
  const arr = Array.from(projects);
1004
1045
  arr.splice(0, arr.length - 100);
1005
- metadataJson = JSON.stringify({ recalledProjects: arr });
1046
+ baseMeta.recalledProjects = arr;
1006
1047
  }
1007
1048
  else {
1008
- metadataJson = JSON.stringify({ recalledProjects: Array.from(projects) });
1049
+ baseMeta.recalledProjects = Array.from(projects);
1009
1050
  }
1051
+ metadataJson = JSON.stringify(baseMeta);
1010
1052
  newProjectCount = projects.size;
1011
1053
  }
1012
1054
  }
@@ -1532,6 +1574,11 @@ export class MemoryStore {
1532
1574
  const records = await this.readByScopes([scope]);
1533
1575
  let sortedRecords = records;
1534
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`);
1535
1582
  sortedRecords = [...records].sort((a, b) => b.timestamp - a.timestamp).slice(0, this.cacheConfig.maxRecordsPerScope);
1536
1583
  }
1537
1584
  const tokenized = sortedRecords.map((record) => tokenize(record.text));
@@ -1812,7 +1859,54 @@ export class MemoryStore {
1812
1859
  return patterns.sort((a, b) => b.count - a.count);
1813
1860
  }
1814
1861
  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() }));
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;
1816
1910
  }
1817
1911
  async addRecoveryStrategy(taskId, scope, strategy) {
1818
1912
  return this.appendToEpisodeField(taskId, scope, "recoveryStrategiesJson", (raw) => JSON.parse(raw || "[]"), (items) => JSON.stringify(items), strategy, (item) => ({ ...item, attemptedAt: Date.now() }));
@@ -2304,6 +2398,48 @@ export class MemoryStore {
2304
2398
  .map((row) => normalizeRow(row))
2305
2399
  .filter((row) => row !== null);
2306
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
+ }
2307
2443
  async ensureIndexes() {
2308
2444
  const table = this.requireTable();
2309
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.4",
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",