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 +42 -1
- package/dist/embedder.js +5 -0
- package/dist/index.js +63 -11
- package/dist/store.js +249 -29
- 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
|
@@ -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
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
-
|
|
228
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1046
|
+
baseMeta.recalledProjects = arr;
|
|
922
1047
|
}
|
|
923
1048
|
else {
|
|
924
|
-
|
|
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
|
-
|
|
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
|
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",
|