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 +42 -1
- package/dist/embedder.js +5 -0
- package/dist/index.js +63 -11
- package/dist/store.js +237 -43
- 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.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
|
-
|
|
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
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
-
|
|
184
|
-
|
|
243
|
+
if (force) {
|
|
244
|
+
this.lastOptimizeAt = Date.now();
|
|
245
|
+
attempted = true;
|
|
185
246
|
}
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1104
|
+
baseMeta.recalledProjects = arr;
|
|
1006
1105
|
}
|
|
1007
1106
|
else {
|
|
1008
|
-
|
|
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
|
-
|
|
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
|
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.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",
|