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 +42 -1
- package/dist/embedder.js +5 -0
- package/dist/index.js +63 -11
- package/dist/store.js +141 -5
- package/dist/tools/memory.js +20 -6
- package/package.json +1 -1
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
//
|
|
192
|
-
//
|
|
193
|
-
//
|
|
194
|
-
//
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1046
|
+
baseMeta.recalledProjects = arr;
|
|
1006
1047
|
}
|
|
1007
1048
|
else {
|
|
1008
|
-
|
|
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
|
-
|
|
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
|
package/dist/tools/memory.js
CHANGED
|
@@ -453,12 +453,17 @@ export function createMemoryTools(state) {
|
|
|
453
453
|
await state.store.putEvent({
|
|
454
454
|
id: generateId(),
|
|
455
455
|
type: "feedback",
|
|
456
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
+
"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",
|