opencode-memory-pro 1.3.0
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/LICENSE +24 -0
- package/README.md +409 -0
- package/dist/config.d.ts +3 -0
- package/dist/config.js +398 -0
- package/dist/embedder.d.ts +26 -0
- package/dist/embedder.js +260 -0
- package/dist/extract.d.ts +4 -0
- package/dist/extract.js +181 -0
- package/dist/graph.js +701 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +953 -0
- package/dist/llm.d.ts +14 -0
- package/dist/llm.js +212 -0
- package/dist/logger.d.ts +9 -0
- package/dist/logger.js +126 -0
- package/dist/ports.d.ts +34 -0
- package/dist/ports.js +129 -0
- package/dist/preference.d.ts +10 -0
- package/dist/preference.js +125 -0
- package/dist/scope.d.ts +2 -0
- package/dist/scope.js +48 -0
- package/dist/store.d.ts +194 -0
- package/dist/store.js +2738 -0
- package/dist/summarize.d.ts +52 -0
- package/dist/summarize.js +350 -0
- package/dist/tools/episodic.d.ts +68 -0
- package/dist/tools/episodic.js +145 -0
- package/dist/tools/feedback.d.ts +51 -0
- package/dist/tools/feedback.js +112 -0
- package/dist/tools/index.d.ts +3 -0
- package/dist/tools/index.js +3 -0
- package/dist/tools/memory.d.ts +293 -0
- package/dist/tools/memory.js +1487 -0
- package/dist/types.d.ts +489 -0
- package/dist/types.js +54 -0
- package/dist/utils.d.ts +18 -0
- package/dist/utils.js +214 -0
- package/package.json +49 -0
|
@@ -0,0 +1,1487 @@
|
|
|
1
|
+
import { tool } from "@opencode-ai/plugin";
|
|
2
|
+
import { deriveProjectScope, buildScopeFilter } from "../scope.js";
|
|
3
|
+
import { generateId } from "../utils.js";
|
|
4
|
+
import { getEmbedderHealth } from "../embedder.js";
|
|
5
|
+
import { extractiveDigest, retentionCandidates } from "../store.js";
|
|
6
|
+
import { requestLLMDigest } from "../llm.js";
|
|
7
|
+
import { log } from "../logger.js";
|
|
8
|
+
function unavailableMessage(provider) {
|
|
9
|
+
return `Memory store unavailable (${provider} embedding may be offline). Will retry automatically.`;
|
|
10
|
+
}
|
|
11
|
+
// LLM_CAPTURE (1.1): mode-aware digest builder shared by memory_summarize
|
|
12
|
+
// and the retention sweep. capture.mode === "llm" → abstractive LLM digest
|
|
13
|
+
// via an ephemeral SDK session (falls back to the offline extractive digest
|
|
14
|
+
// on any failure); otherwise the historical extractive digest. The returned
|
|
15
|
+
// object carries digest.llm so callers can stamp provenance.
|
|
16
|
+
export async function buildGroupDigest(state, group, targetChars, groupKey, entityNames) {
|
|
17
|
+
const texts = group.map((r) => r.text);
|
|
18
|
+
const cfg = state.config?.capture;
|
|
19
|
+
if (cfg?.mode === "llm" && state.client) {
|
|
20
|
+
try {
|
|
21
|
+
const llmDigest = await requestLLMDigest(state.client, cfg.llm, texts, targetChars, groupKey);
|
|
22
|
+
if (llmDigest && llmDigest.text) {
|
|
23
|
+
return { text: llmDigest.text, sourceCount: llmDigest.sourceCount ?? group.length, llm: true };
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
catch (error) {
|
|
27
|
+
log("warn", `[digest] llm digest failed for "${groupKey}": ${error instanceof Error ? error.message : String(error)}`);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
const digest = extractiveDigest(texts, targetChars, Array.from(entityNames ?? []), groupKey);
|
|
31
|
+
return digest ? { ...digest, llm: false } : null;
|
|
32
|
+
}
|
|
33
|
+
export function createMemoryTools(state) {
|
|
34
|
+
return {
|
|
35
|
+
memory_search: tool({
|
|
36
|
+
description: "Search long-term memory using hybrid retrieval",
|
|
37
|
+
args: {
|
|
38
|
+
query: tool.schema.string().min(1),
|
|
39
|
+
limit: tool.schema.number().int().min(1).max(20).default(5),
|
|
40
|
+
scope: tool.schema.string().optional(),
|
|
41
|
+
},
|
|
42
|
+
execute: async (args, context) => {
|
|
43
|
+
await state.ensureInitialized();
|
|
44
|
+
if (!state.initialized)
|
|
45
|
+
return unavailableMessage(state.config.embedding.provider);
|
|
46
|
+
const activeScope = args.scope ?? deriveProjectScope(context.directory || context.worktree);
|
|
47
|
+
const scopes = buildScopeFilter(activeScope, state.config.includeGlobalScope);
|
|
48
|
+
let queryVector = [];
|
|
49
|
+
let embedderFailed = false;
|
|
50
|
+
try {
|
|
51
|
+
queryVector = await state.embedder.embed(args.query);
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
embedderFailed = true;
|
|
55
|
+
queryVector = [];
|
|
56
|
+
}
|
|
57
|
+
const isFallback = embedderFailed || queryVector.length === 0;
|
|
58
|
+
const effectiveVectorWeight = isFallback ? 0 : (state.config.retrieval.mode === "vector" ? 1 : state.config.retrieval.vectorWeight);
|
|
59
|
+
const effectiveBm25Weight = isFallback ? 1 : (state.config.retrieval.mode === "vector" ? 0 : state.config.retrieval.bm25Weight);
|
|
60
|
+
if (isFallback) {
|
|
61
|
+
log("info", "Using BM25-only search (embedder unavailable)");
|
|
62
|
+
}
|
|
63
|
+
const results = await state.store.search({
|
|
64
|
+
query: args.query,
|
|
65
|
+
queryVector,
|
|
66
|
+
scopes,
|
|
67
|
+
limit: args.limit ?? 5,
|
|
68
|
+
vectorWeight: effectiveVectorWeight,
|
|
69
|
+
bm25Weight: effectiveBm25Weight,
|
|
70
|
+
minScore: state.config.retrieval.minScore,
|
|
71
|
+
rrfK: state.config.retrieval.rrfK,
|
|
72
|
+
recencyBoost: state.config.retrieval.recencyBoost,
|
|
73
|
+
recencyHalfLifeHours: state.config.retrieval.recencyHalfLifeHours,
|
|
74
|
+
importanceWeight: state.config.retrieval.importanceWeight,
|
|
75
|
+
feedbackWeight: state.config.retrieval.feedbackWeight,
|
|
76
|
+
globalDiscountFactor: state.config.globalDiscountFactor,
|
|
77
|
+
});
|
|
78
|
+
// GRAPH_STORE_PHASE1: entity co-occurrence boost (same factor
|
|
79
|
+
// as the system-transform recall path).
|
|
80
|
+
let graphBoostedResults = results;
|
|
81
|
+
if (state.config.graph?.enabled && state.graph?.enabled) {
|
|
82
|
+
try {
|
|
83
|
+
graphBoostedResults = state.graph.boostResults(args.query, results, state.config.graph.boostLambda);
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
// GRAPH_STORE_PHASE2B: graph-expansion recall. BFS from the
|
|
89
|
+
// query's entities up to graph.maxHops; memories reachable via
|
|
90
|
+
// the graph but NOT text/vector-matched are merged in with a
|
|
91
|
+
// graph-origin score below the weakest real match, so the
|
|
92
|
+
// multi-hop "query mentions config.js → also surface dedup/
|
|
93
|
+
// retention memories" behavior works.
|
|
94
|
+
const graphExpanded = [];
|
|
95
|
+
if (state.config.graph?.enabled && state.graph?.enabled && state.config.graph.expansionEnabled !== false) {
|
|
96
|
+
try {
|
|
97
|
+
const candidates = state.graph.expandRecall(args.query, {
|
|
98
|
+
maxHops: state.config.graph.maxHops,
|
|
99
|
+
expansionLimit: state.config.graph.expansionLimit,
|
|
100
|
+
expansionLambda: state.config.graph.expansionLambda,
|
|
101
|
+
});
|
|
102
|
+
if (candidates.length > 0) {
|
|
103
|
+
const expandedRecords = await state.store.findRecordsByIds(candidates.map((c) => c.memoryId), scopes);
|
|
104
|
+
const recordById = new Map(expandedRecords.map((r) => [r.id, r]));
|
|
105
|
+
const existingIds = new Set(results.map((r) => r.record.id));
|
|
106
|
+
const floorScore = results.length > 0
|
|
107
|
+
? Math.min(...results.map((r) => r.score))
|
|
108
|
+
: state.config.retrieval.minScore;
|
|
109
|
+
for (const candidate of candidates) {
|
|
110
|
+
const record = recordById.get(candidate.memoryId);
|
|
111
|
+
if (!record || existingIds.has(record.id))
|
|
112
|
+
continue;
|
|
113
|
+
graphExpanded.push({
|
|
114
|
+
record,
|
|
115
|
+
score: floorScore * candidate.scoreFactor,
|
|
116
|
+
vectorScore: 0,
|
|
117
|
+
bm25Score: 0,
|
|
118
|
+
graphBFS: { hops: candidate.hops, relation: candidate.relation, typed: candidate.typed, path: candidate.path },
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
const searchLimit = args.limit ?? 5;
|
|
127
|
+
const effectiveLimit = graphExpanded.length > 0
|
|
128
|
+
? searchLimit + (state.config.graph?.expansionLimit ?? 5)
|
|
129
|
+
: searchLimit;
|
|
130
|
+
const recencyHalfLifeHours = Math.max(1, state.config.retrieval.recencyHalfLifeHours ?? 72);
|
|
131
|
+
const finalResults = [...graphBoostedResults, ...graphExpanded]
|
|
132
|
+
.slice()
|
|
133
|
+
.sort((a, b) => b.score - a.score)
|
|
134
|
+
.slice(0, effectiveLimit);
|
|
135
|
+
state.lastRecall = {
|
|
136
|
+
timestamp: Date.now(),
|
|
137
|
+
query: args.query,
|
|
138
|
+
results: finalResults.map((r) => {
|
|
139
|
+
// RECENCY_FACTORS (1.2.0): was a display stub
|
|
140
|
+
// (ageHours:0/withinHalfLife:true/decayFactor:1).
|
|
141
|
+
// Same formula as store.explainMemory so the factors
|
|
142
|
+
// agree with memory_why / memory_explain_recall.
|
|
143
|
+
const ageHours = (Date.now() - r.record.timestamp) / 3_600_000;
|
|
144
|
+
return {
|
|
145
|
+
memoryId: r.record.id,
|
|
146
|
+
score: r.score,
|
|
147
|
+
factors: {
|
|
148
|
+
relevance: { overall: r.score, vectorScore: r.vectorScore, bm25Score: r.bm25Score },
|
|
149
|
+
recency: { timestamp: r.record.timestamp, ageHours, withinHalfLife: ageHours <= recencyHalfLifeHours, decayFactor: Math.exp(-ageHours / recencyHalfLifeHours) },
|
|
150
|
+
citation: r.record.citationSource ? { source: r.record.citationSource, status: r.record.citationStatus ?? "pending" } : undefined,
|
|
151
|
+
importance: r.record.importance,
|
|
152
|
+
scope: { memoryScope: r.record.scope, matchesCurrentScope: r.record.scope === activeScope, isGlobal: r.record.scope === "global" },
|
|
153
|
+
graph: r.graphBFS ? { bfs: { hops: r.graphBFS.hops, relation: r.graphBFS.relation, typed: r.graphBFS.typed } }
|
|
154
|
+
: r.graphBoost ? { boost: r.graphBoost, overlap: r.graphOverlap ?? 0 } : undefined,
|
|
155
|
+
},
|
|
156
|
+
};
|
|
157
|
+
}),
|
|
158
|
+
};
|
|
159
|
+
await state.store.putEvent({
|
|
160
|
+
id: generateId(),
|
|
161
|
+
type: "recall",
|
|
162
|
+
source: "manual-search",
|
|
163
|
+
scope: activeScope,
|
|
164
|
+
sessionID: context.sessionID,
|
|
165
|
+
timestamp: Date.now(),
|
|
166
|
+
resultCount: finalResults.length,
|
|
167
|
+
injected: false,
|
|
168
|
+
metadataJson: JSON.stringify({ source: "manual-search" }),
|
|
169
|
+
});
|
|
170
|
+
if (finalResults.length === 0)
|
|
171
|
+
return "No relevant memory found.";
|
|
172
|
+
for (const result of finalResults) {
|
|
173
|
+
try {
|
|
174
|
+
await state.store.updateMemoryUsage(result.record.id, activeScope, scopes);
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return finalResults
|
|
180
|
+
.map((item, idx) => {
|
|
181
|
+
const percent = Math.round(item.score * 100);
|
|
182
|
+
const meta = JSON.parse(item.record.metadataJson || "{}");
|
|
183
|
+
const duplicateMarker = meta.isPotentialDuplicate ? " (duplicate)" : "";
|
|
184
|
+
const citationInfo = item.record.citationSource
|
|
185
|
+
? ` [${item.record.citationSource}|${item.record.citationStatus ?? "pending"}]`
|
|
186
|
+
: "";
|
|
187
|
+
const graphMarker = item.graphBoost
|
|
188
|
+
? ` [graph+${Math.round((item.graphBoost - 1) * 100)}%/${item.graphOverlap} entities]`
|
|
189
|
+
: item.graphBFS
|
|
190
|
+
? ` [graph-bfs: ${item.graphBFS.hops} hop${item.graphBFS.hops === 1 ? "" : "s"}]`
|
|
191
|
+
: "";
|
|
192
|
+
return `${idx + 1}. [${item.record.id}]${duplicateMarker}${citationInfo}${graphMarker} (${item.record.scope}) ${item.record.text} [${percent}%]`;
|
|
193
|
+
})
|
|
194
|
+
.join("\n");
|
|
195
|
+
},
|
|
196
|
+
}),
|
|
197
|
+
memory_delete: tool({
|
|
198
|
+
description: "Delete one memory entry by id",
|
|
199
|
+
args: {
|
|
200
|
+
id: tool.schema.string().min(8),
|
|
201
|
+
scope: tool.schema.string().optional(),
|
|
202
|
+
confirm: tool.schema.boolean().default(false),
|
|
203
|
+
},
|
|
204
|
+
execute: async (args, context) => {
|
|
205
|
+
await state.ensureInitialized();
|
|
206
|
+
if (!state.initialized)
|
|
207
|
+
return unavailableMessage(state.config.embedding.provider);
|
|
208
|
+
if (!args.confirm) {
|
|
209
|
+
return "Rejected: memory_delete requires confirm=true.";
|
|
210
|
+
}
|
|
211
|
+
const activeScope = args.scope ?? deriveProjectScope(context.directory || context.worktree);
|
|
212
|
+
const scopes = buildScopeFilter(activeScope, state.config.includeGlobalScope);
|
|
213
|
+
const deleted = await state.store.deleteById(args.id, scopes);
|
|
214
|
+
return deleted ? `Deleted memory ${args.id}.` : `Memory ${args.id} not found in current scope.`;
|
|
215
|
+
},
|
|
216
|
+
}),
|
|
217
|
+
memory_clear: tool({
|
|
218
|
+
description: "Clear all memories in a scope (requires confirm=true)",
|
|
219
|
+
args: {
|
|
220
|
+
scope: tool.schema.string(),
|
|
221
|
+
confirm: tool.schema.boolean().default(false),
|
|
222
|
+
},
|
|
223
|
+
execute: async (args) => {
|
|
224
|
+
await state.ensureInitialized();
|
|
225
|
+
if (!state.initialized)
|
|
226
|
+
return unavailableMessage(state.config.embedding.provider);
|
|
227
|
+
if (!args.confirm) {
|
|
228
|
+
return "Rejected: destructive clear requires confirm=true.";
|
|
229
|
+
}
|
|
230
|
+
const count = await state.store.clearScope(args.scope);
|
|
231
|
+
return `Cleared ${count} memories from scope ${args.scope}.`;
|
|
232
|
+
},
|
|
233
|
+
}),
|
|
234
|
+
memory_stats: tool({
|
|
235
|
+
description: "Show memory provider status and index health",
|
|
236
|
+
args: {
|
|
237
|
+
scope: tool.schema.string().optional(),
|
|
238
|
+
},
|
|
239
|
+
execute: async (args, context) => {
|
|
240
|
+
await state.ensureInitialized();
|
|
241
|
+
if (!state.initialized)
|
|
242
|
+
return unavailableMessage(state.config.embedding.provider);
|
|
243
|
+
const scope = args.scope ?? deriveProjectScope(context.directory || context.worktree);
|
|
244
|
+
const entries = await state.store.list(scope, 20);
|
|
245
|
+
const incompatibleVectors = await state.store.countIncompatibleVectors(buildScopeFilter(scope, state.config.includeGlobalScope), await state.embedder.dim());
|
|
246
|
+
const health = state.store.getIndexHealth();
|
|
247
|
+
const embedderHealth = getEmbedderHealth();
|
|
248
|
+
const searchMode = embedderHealth.fallbackActive ? "bm25-only" : state.config.retrieval.mode;
|
|
249
|
+
const eventTtl = state.config.retention
|
|
250
|
+
? await state.store.getEventTtlStatus()
|
|
251
|
+
: { enabled: false, retentionDays: 90, expiredCount: 0, scopeBreakdown: {} };
|
|
252
|
+
const graphStats = state.config.graph?.enabled && state.graph?.enabled
|
|
253
|
+
? state.graph.stats()
|
|
254
|
+
: { enabled: false, entities: 0, memoryMappings: 0, edges: 0 };
|
|
255
|
+
// MEMORY_RETENTION (1.0): report how many memories currently
|
|
256
|
+
// qualify for the digest-then-hide expiry sweep (dry-run).
|
|
257
|
+
const memoryRetention = { enabled: false, unusedDays: 0, minAgeDays: 0, expiredCandidates: 0 };
|
|
258
|
+
if (state.config.retention?.memory?.enabled !== false) {
|
|
259
|
+
try {
|
|
260
|
+
const sweep = await sweepExpiredMemories(state, { scope, dryRun: true });
|
|
261
|
+
memoryRetention.enabled = true;
|
|
262
|
+
memoryRetention.unusedDays = sweep.unusedDays ?? 0;
|
|
263
|
+
memoryRetention.minAgeDays = sweep.minAgeDays ?? 0;
|
|
264
|
+
memoryRetention.expiredCandidates = sweep.eligible ?? 0;
|
|
265
|
+
}
|
|
266
|
+
catch { }
|
|
267
|
+
}
|
|
268
|
+
return JSON.stringify({
|
|
269
|
+
provider: state.config.provider,
|
|
270
|
+
dbPath: state.config.dbPath,
|
|
271
|
+
scope,
|
|
272
|
+
recentCount: entries.length,
|
|
273
|
+
incompatibleVectors,
|
|
274
|
+
index: health,
|
|
275
|
+
embeddingModel: state.config.embedding.model,
|
|
276
|
+
searchMode,
|
|
277
|
+
embedderHealth,
|
|
278
|
+
eventTtl,
|
|
279
|
+
graph: graphStats,
|
|
280
|
+
memoryRetention,
|
|
281
|
+
}, null, 2);
|
|
282
|
+
},
|
|
283
|
+
}),
|
|
284
|
+
memory_event_cleanup: tool({
|
|
285
|
+
description: "Clean up expired effectiveness events with optional archival export",
|
|
286
|
+
args: {
|
|
287
|
+
scope: tool.schema.string().optional(),
|
|
288
|
+
dryRun: tool.schema.boolean().optional().default(false),
|
|
289
|
+
archivePath: tool.schema.string().optional(),
|
|
290
|
+
},
|
|
291
|
+
execute: async (args, context) => {
|
|
292
|
+
await state.ensureInitialized();
|
|
293
|
+
if (!state.initialized)
|
|
294
|
+
return unavailableMessage(state.config.embedding.provider);
|
|
295
|
+
if (!state.config.retention || state.config.retention.effectivenessEventsDays <= 0) {
|
|
296
|
+
return JSON.stringify({ error: "Event TTL is disabled. Configure retention.effectivenessEventsDays in config." }, null, 2);
|
|
297
|
+
}
|
|
298
|
+
const status = await state.store.getEventTtlStatus();
|
|
299
|
+
// EVENT_CLEANUP_SCOPES (1.2.0): archive and delete must cover
|
|
300
|
+
// the SAME set of events, or expired events outside the active
|
|
301
|
+
// scopes get deleted without ever being archived. Both use the
|
|
302
|
+
// active scope + global (per includeGlobalScope); the old path
|
|
303
|
+
// passed `args.scope` to cleanupExpiredEvents where `undefined`
|
|
304
|
+
// meant ALL scopes (and the store's `scope LIKE 'project:%'`
|
|
305
|
+
// matched every project scope).
|
|
306
|
+
const activeScope = args.scope ?? deriveProjectScope(context.directory || context.worktree);
|
|
307
|
+
const scopes = buildScopeFilter(activeScope, state.config.includeGlobalScope);
|
|
308
|
+
const cutoffTimestamp = Date.now() - status.retentionDays * 24 * 60 * 60 * 1000;
|
|
309
|
+
if (args.dryRun) {
|
|
310
|
+
let scopedExpired = status.expiredCount;
|
|
311
|
+
try {
|
|
312
|
+
const eventsToArchive = await state.store.readEventsByScopes(scopes);
|
|
313
|
+
scopedExpired = eventsToArchive.filter((ev) => ev.timestamp < cutoffTimestamp).length;
|
|
314
|
+
}
|
|
315
|
+
catch { }
|
|
316
|
+
return JSON.stringify({
|
|
317
|
+
wouldDelete: scopedExpired,
|
|
318
|
+
allScopesExpired: status.expiredCount,
|
|
319
|
+
scopeBreakdown: status.scopeBreakdown,
|
|
320
|
+
retentionDays: status.retentionDays,
|
|
321
|
+
activeScope,
|
|
322
|
+
message: "Dry run - no events deleted",
|
|
323
|
+
}, null, 2);
|
|
324
|
+
}
|
|
325
|
+
let archivedCount = 0;
|
|
326
|
+
let archiveFile = undefined;
|
|
327
|
+
if (args.archivePath && status.expiredCount > 0) {
|
|
328
|
+
try {
|
|
329
|
+
const eventsToArchive = await state.store.readEventsByScopes(scopes);
|
|
330
|
+
const expired = eventsToArchive.filter((ev) => ev.timestamp < cutoffTimestamp);
|
|
331
|
+
const fs = await import("node:fs");
|
|
332
|
+
const archiveDir = args.archivePath.lastIndexOf("/") > 0 ? args.archivePath.slice(0, args.archivePath.lastIndexOf("/")) : ".";
|
|
333
|
+
await fs.promises.mkdir(archiveDir, { recursive: true });
|
|
334
|
+
await fs.promises.writeFile(args.archivePath, JSON.stringify({
|
|
335
|
+
exportedAt: new Date().toISOString(),
|
|
336
|
+
retentionDays: status.retentionDays,
|
|
337
|
+
count: expired.length,
|
|
338
|
+
scopeBreakdown: status.scopeBreakdown,
|
|
339
|
+
events: expired,
|
|
340
|
+
}, null, 2));
|
|
341
|
+
archivedCount = expired.length;
|
|
342
|
+
archiveFile = args.archivePath;
|
|
343
|
+
}
|
|
344
|
+
catch (error) {
|
|
345
|
+
return JSON.stringify({ error: `Archive failed: ${error instanceof Error ? error.message : String(error)}` }, null, 2);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
const deletedCount = await state.store.cleanupExpiredEvents(scopes, status.retentionDays);
|
|
349
|
+
const remainingStatus = await state.store.getEventTtlStatus();
|
|
350
|
+
return JSON.stringify({
|
|
351
|
+
deletedCount,
|
|
352
|
+
archivedCount,
|
|
353
|
+
archiveFile,
|
|
354
|
+
remainingCount: remainingStatus.expiredCount,
|
|
355
|
+
retentionDays: status.retentionDays,
|
|
356
|
+
}, null, 2);
|
|
357
|
+
},
|
|
358
|
+
}),
|
|
359
|
+
memory_remember: tool({
|
|
360
|
+
description: "Explicitly store a memory with optional category label",
|
|
361
|
+
args: {
|
|
362
|
+
text: tool.schema.string().min(1),
|
|
363
|
+
category: tool.schema.string().optional(),
|
|
364
|
+
scope: tool.schema.string().optional(),
|
|
365
|
+
},
|
|
366
|
+
execute: async (args, context) => {
|
|
367
|
+
await state.ensureInitialized();
|
|
368
|
+
if (!state.initialized)
|
|
369
|
+
return unavailableMessage(state.config.embedding.provider);
|
|
370
|
+
if (args.text.length < state.config.minCaptureChars) {
|
|
371
|
+
return `Content too short (minimum ${state.config.minCaptureChars} characters).`;
|
|
372
|
+
}
|
|
373
|
+
const activeScope = args.scope ?? deriveProjectScope(context.directory || context.worktree);
|
|
374
|
+
let vector = [];
|
|
375
|
+
try {
|
|
376
|
+
vector = await state.embedder.embed(args.text);
|
|
377
|
+
}
|
|
378
|
+
catch {
|
|
379
|
+
vector = [];
|
|
380
|
+
}
|
|
381
|
+
if (vector.length === 0) {
|
|
382
|
+
return "Failed to create embedding vector.";
|
|
383
|
+
}
|
|
384
|
+
const memoryId = generateId();
|
|
385
|
+
const now = Date.now();
|
|
386
|
+
const graphEntities = state.config.graph?.enabled && state.graph?.enabled
|
|
387
|
+
? state.graph.extract(args.text)
|
|
388
|
+
: [];
|
|
389
|
+
await state.store.put({
|
|
390
|
+
id: memoryId,
|
|
391
|
+
text: args.text,
|
|
392
|
+
vector,
|
|
393
|
+
category: args.category ?? "other",
|
|
394
|
+
scope: activeScope,
|
|
395
|
+
importance: 0.7,
|
|
396
|
+
timestamp: now,
|
|
397
|
+
lastRecalled: 0,
|
|
398
|
+
recallCount: 0,
|
|
399
|
+
projectCount: 0,
|
|
400
|
+
schemaVersion: 1,
|
|
401
|
+
embeddingModel: state.config.embedding.model,
|
|
402
|
+
vectorDim: vector.length,
|
|
403
|
+
metadataJson: JSON.stringify({
|
|
404
|
+
source: "explicit-remember",
|
|
405
|
+
category: args.category,
|
|
406
|
+
graphEntities: graphEntities.map((e) => e.name),
|
|
407
|
+
}),
|
|
408
|
+
sourceSessionId: context.sessionID,
|
|
409
|
+
citationSource: "explicit-remember",
|
|
410
|
+
citationTimestamp: now,
|
|
411
|
+
citationStatus: "pending",
|
|
412
|
+
});
|
|
413
|
+
if (state.config.graph?.enabled && state.graph?.enabled) {
|
|
414
|
+
try {
|
|
415
|
+
state.graph.indexMemory(memoryId, args.text, now);
|
|
416
|
+
}
|
|
417
|
+
catch {
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
await state.store.putEvent({
|
|
421
|
+
id: generateId(),
|
|
422
|
+
type: "capture",
|
|
423
|
+
outcome: "stored",
|
|
424
|
+
scope: activeScope,
|
|
425
|
+
sessionID: context.sessionID,
|
|
426
|
+
timestamp: Date.now(),
|
|
427
|
+
memoryId,
|
|
428
|
+
text: args.text,
|
|
429
|
+
metadataJson: JSON.stringify({ source: "explicit-remember", category: args.category }),
|
|
430
|
+
sourceSessionId: context.sessionID,
|
|
431
|
+
});
|
|
432
|
+
return `Stored memory ${memoryId} in scope ${activeScope}.`;
|
|
433
|
+
},
|
|
434
|
+
}),
|
|
435
|
+
memory_forget: tool({
|
|
436
|
+
description: "Remove or disable a memory (soft-delete by default, hard-delete with confirm)",
|
|
437
|
+
args: {
|
|
438
|
+
id: tool.schema.string().min(8),
|
|
439
|
+
force: tool.schema.boolean().default(false),
|
|
440
|
+
scope: tool.schema.string().optional(),
|
|
441
|
+
},
|
|
442
|
+
execute: async (args, context) => {
|
|
443
|
+
await state.ensureInitialized();
|
|
444
|
+
if (!state.initialized)
|
|
445
|
+
return unavailableMessage(state.config.embedding.provider);
|
|
446
|
+
const activeScope = args.scope ?? deriveProjectScope(context.directory || context.worktree);
|
|
447
|
+
const scopes = buildScopeFilter(activeScope, state.config.includeGlobalScope);
|
|
448
|
+
if (args.force) {
|
|
449
|
+
const deleted = await state.store.deleteById(args.id, scopes);
|
|
450
|
+
if (!deleted) {
|
|
451
|
+
return `Memory ${args.id} not found in current scope.`;
|
|
452
|
+
}
|
|
453
|
+
await state.store.putEvent({
|
|
454
|
+
id: generateId(),
|
|
455
|
+
type: "feedback",
|
|
456
|
+
feedbackType: "useful",
|
|
457
|
+
scope: activeScope,
|
|
458
|
+
sessionID: context.sessionID,
|
|
459
|
+
timestamp: Date.now(),
|
|
460
|
+
memoryId: args.id,
|
|
461
|
+
helpful: false,
|
|
462
|
+
metadataJson: JSON.stringify({ source: "explicit-forget", hardDelete: true }),
|
|
463
|
+
});
|
|
464
|
+
return `Permanently deleted memory ${args.id}.`;
|
|
465
|
+
}
|
|
466
|
+
const softDeleted = await state.store.softDeleteMemory(args.id, scopes);
|
|
467
|
+
if (!softDeleted) {
|
|
468
|
+
return `Memory ${args.id} not found in current scope.`;
|
|
469
|
+
}
|
|
470
|
+
await state.store.putEvent({
|
|
471
|
+
id: generateId(),
|
|
472
|
+
type: "feedback",
|
|
473
|
+
feedbackType: "useful",
|
|
474
|
+
scope: activeScope,
|
|
475
|
+
sessionID: context.sessionID,
|
|
476
|
+
timestamp: Date.now(),
|
|
477
|
+
memoryId: args.id,
|
|
478
|
+
helpful: false,
|
|
479
|
+
metadataJson: JSON.stringify({ source: "explicit-forget", hardDelete: false }),
|
|
480
|
+
});
|
|
481
|
+
return `Soft-deleted (disabled) memory ${args.id}. Use force=true for permanent deletion.`;
|
|
482
|
+
},
|
|
483
|
+
}),
|
|
484
|
+
memory_citation: tool({
|
|
485
|
+
description: "View or update citation information for a memory",
|
|
486
|
+
args: {
|
|
487
|
+
id: tool.schema.string().min(8),
|
|
488
|
+
status: tool.schema.string().optional(),
|
|
489
|
+
scope: tool.schema.string().optional(),
|
|
490
|
+
},
|
|
491
|
+
execute: async (args, context) => {
|
|
492
|
+
await state.ensureInitialized();
|
|
493
|
+
if (!state.initialized)
|
|
494
|
+
return unavailableMessage(state.config.embedding.provider);
|
|
495
|
+
const activeScope = args.scope ?? deriveProjectScope(context.directory || context.worktree);
|
|
496
|
+
const scopes = buildScopeFilter(activeScope, state.config.includeGlobalScope);
|
|
497
|
+
const citation = await state.store.getCitation(args.id, scopes);
|
|
498
|
+
if (!citation) {
|
|
499
|
+
return `Memory ${args.id} not found or has no citation information.`;
|
|
500
|
+
}
|
|
501
|
+
if (args.status) {
|
|
502
|
+
const validStatuses = ["verified", "pending", "invalid", "expired"];
|
|
503
|
+
if (!validStatuses.includes(args.status)) {
|
|
504
|
+
return `Invalid status. Must be one of: ${validStatuses.join(", ")}`;
|
|
505
|
+
}
|
|
506
|
+
const updated = await state.store.updateCitation(args.id, scopes, { status: args.status });
|
|
507
|
+
if (!updated) {
|
|
508
|
+
return `Failed to update citation for ${args.id}.`;
|
|
509
|
+
}
|
|
510
|
+
return `Updated citation status for ${args.id} to ${args.status}.`;
|
|
511
|
+
}
|
|
512
|
+
return JSON.stringify({
|
|
513
|
+
memoryId: args.id,
|
|
514
|
+
source: citation.source,
|
|
515
|
+
timestamp: new Date(citation.timestamp).toISOString(),
|
|
516
|
+
status: citation.status,
|
|
517
|
+
chain: citation.chain,
|
|
518
|
+
}, null, 2);
|
|
519
|
+
},
|
|
520
|
+
}),
|
|
521
|
+
memory_validate_citation: tool({
|
|
522
|
+
description: "Validate a citation for a memory and update its status",
|
|
523
|
+
args: {
|
|
524
|
+
id: tool.schema.string().min(8),
|
|
525
|
+
scope: tool.schema.string().optional(),
|
|
526
|
+
},
|
|
527
|
+
execute: async (args, context) => {
|
|
528
|
+
await state.ensureInitialized();
|
|
529
|
+
if (!state.initialized)
|
|
530
|
+
return unavailableMessage(state.config.embedding.provider);
|
|
531
|
+
const activeScope = args.scope ?? deriveProjectScope(context.directory || context.worktree);
|
|
532
|
+
const scopes = buildScopeFilter(activeScope, state.config.includeGlobalScope);
|
|
533
|
+
const result = await state.store.validateCitation(args.id, scopes);
|
|
534
|
+
return JSON.stringify({
|
|
535
|
+
memoryId: args.id,
|
|
536
|
+
valid: result.valid,
|
|
537
|
+
status: result.status,
|
|
538
|
+
reason: result.reason,
|
|
539
|
+
}, null, 2);
|
|
540
|
+
},
|
|
541
|
+
}),
|
|
542
|
+
memory_what_did_you_learn: tool({
|
|
543
|
+
description: "Show recent learning summary with memory counts by category",
|
|
544
|
+
args: {
|
|
545
|
+
days: tool.schema.number().int().min(1).max(90).default(7),
|
|
546
|
+
scope: tool.schema.string().optional(),
|
|
547
|
+
},
|
|
548
|
+
execute: async (args, context) => {
|
|
549
|
+
await state.ensureInitialized();
|
|
550
|
+
if (!state.initialized)
|
|
551
|
+
return unavailableMessage(state.config.embedding.provider);
|
|
552
|
+
const activeScope = args.scope ?? deriveProjectScope(context.directory || context.worktree);
|
|
553
|
+
const sinceTimestamp = Date.now() - (args.days ?? 7) * 24 * 60 * 60 * 1000;
|
|
554
|
+
const memories = await state.store.listSince(activeScope, sinceTimestamp, 1000);
|
|
555
|
+
if (memories.length === 0) {
|
|
556
|
+
return `No memories captured in the past ${args.days} days in scope ${activeScope}.`;
|
|
557
|
+
}
|
|
558
|
+
const categoryCounts = {};
|
|
559
|
+
for (const mem of memories) {
|
|
560
|
+
categoryCounts[mem.category] = (categoryCounts[mem.category] ?? 0) + 1;
|
|
561
|
+
}
|
|
562
|
+
const total = memories.length;
|
|
563
|
+
const categoryBreakdown = Object.entries(categoryCounts)
|
|
564
|
+
.map(([cat, count]) => ` - ${cat}: ${count}`)
|
|
565
|
+
.join("\n");
|
|
566
|
+
const recentSamples = memories.slice(0, 5).map((mem, idx) => {
|
|
567
|
+
const date = new Date(mem.timestamp).toISOString().split("T")[0];
|
|
568
|
+
const snippet = mem.text.length > 60 ? `${mem.text.slice(0, 60)}...` : mem.text;
|
|
569
|
+
return ` ${idx + 1}. [${date}] ${snippet}`;
|
|
570
|
+
}).join("\n");
|
|
571
|
+
return `## Learning Summary (${args.days} days)
|
|
572
|
+
|
|
573
|
+
**Scope:** ${activeScope}
|
|
574
|
+
**Total memories:** ${total}
|
|
575
|
+
|
|
576
|
+
### By Category
|
|
577
|
+
${categoryBreakdown}
|
|
578
|
+
|
|
579
|
+
### Recent Captures
|
|
580
|
+
${recentSamples}
|
|
581
|
+
`;
|
|
582
|
+
},
|
|
583
|
+
}),
|
|
584
|
+
memory_why: tool({
|
|
585
|
+
description: "Explain why a specific memory was recalled",
|
|
586
|
+
args: {
|
|
587
|
+
id: tool.schema.string().min(8),
|
|
588
|
+
scope: tool.schema.string().optional(),
|
|
589
|
+
},
|
|
590
|
+
execute: async (args, context) => {
|
|
591
|
+
await state.ensureInitialized();
|
|
592
|
+
if (!state.initialized)
|
|
593
|
+
return unavailableMessage(state.config.embedding.provider);
|
|
594
|
+
const activeScope = args.scope ?? deriveProjectScope(context.directory || context.worktree);
|
|
595
|
+
const scopes = buildScopeFilter(activeScope, state.config.includeGlobalScope);
|
|
596
|
+
const explanation = await state.store.explainMemory(args.id, scopes, activeScope, state.config.retrieval.recencyHalfLifeHours, state.config.globalDiscountFactor);
|
|
597
|
+
if (!explanation) {
|
|
598
|
+
return `Memory ${args.id} not found in current scope.`;
|
|
599
|
+
}
|
|
600
|
+
const f = explanation.factors;
|
|
601
|
+
const recencyText = f.recency.withinHalfLife
|
|
602
|
+
? `within ${f.recency.ageHours.toFixed(1)}h half-life`
|
|
603
|
+
: `beyond half-life (${f.recency.ageHours.toFixed(1)}h old)`;
|
|
604
|
+
const citationText = f.citation
|
|
605
|
+
? `${f.citation.source ?? "unknown"}/${f.citation.status ?? "n/a"}`
|
|
606
|
+
: "N/A";
|
|
607
|
+
const scopeText = f.scope.matchesCurrentScope
|
|
608
|
+
? "matches current project"
|
|
609
|
+
: f.scope.isGlobal
|
|
610
|
+
? "from global scope"
|
|
611
|
+
: "different project scope";
|
|
612
|
+
return `Memory: "${explanation.text.slice(0, 80)}..."
|
|
613
|
+
Explanation:
|
|
614
|
+
- Recency: ${recencyText} (decay: ${(f.recency.decayFactor * 100).toFixed(0)}%)
|
|
615
|
+
- Citation: ${citationText}
|
|
616
|
+
- Importance: ${f.importance.toFixed(2)}
|
|
617
|
+
- Scope: ${scopeText}`;
|
|
618
|
+
},
|
|
619
|
+
}),
|
|
620
|
+
memory_explain_recall: tool({
|
|
621
|
+
description: "Explain the factors behind the last recall operation in this session",
|
|
622
|
+
args: {
|
|
623
|
+
scope: tool.schema.string().optional(),
|
|
624
|
+
},
|
|
625
|
+
execute: async (args, context) => {
|
|
626
|
+
await state.ensureInitialized();
|
|
627
|
+
if (!state.initialized)
|
|
628
|
+
return unavailableMessage(state.config.embedding.provider);
|
|
629
|
+
const lastRecall = state.lastRecall;
|
|
630
|
+
if (!lastRecall) {
|
|
631
|
+
return "No recent recall to explain. Use memory_search or wait for auto-recall first.";
|
|
632
|
+
}
|
|
633
|
+
const activeScope = args.scope ?? deriveProjectScope(context.directory || context.worktree);
|
|
634
|
+
const scopes = buildScopeFilter(activeScope, state.config.includeGlobalScope);
|
|
635
|
+
const explanations = [];
|
|
636
|
+
for (const result of lastRecall.results) {
|
|
637
|
+
const explanation = await state.store.explainMemory(result.memoryId, scopes, activeScope, state.config.retrieval.recencyHalfLifeHours, state.config.globalDiscountFactor);
|
|
638
|
+
if (!explanation)
|
|
639
|
+
continue;
|
|
640
|
+
const f = explanation.factors;
|
|
641
|
+
const recencyText = f.recency.withinHalfLife
|
|
642
|
+
? "recent"
|
|
643
|
+
: "older";
|
|
644
|
+
explanations.push(`${result.memoryId.slice(0, 8)}: ${(result.score * 100).toFixed(0)}% relevance, ${recencyText}, ${f.citation?.status ?? "no citation"}`);
|
|
645
|
+
}
|
|
646
|
+
return `## Last Recall Explanation
|
|
647
|
+
Query: "${lastRecall.query}"
|
|
648
|
+
Results: ${lastRecall.results.length}
|
|
649
|
+
|
|
650
|
+
${explanations.join("\n")}`;
|
|
651
|
+
},
|
|
652
|
+
}),
|
|
653
|
+
memory_scope_promote: tool({
|
|
654
|
+
description: "Promote a memory from project scope to global scope for cross-project sharing",
|
|
655
|
+
args: {
|
|
656
|
+
id: tool.schema.string().min(8),
|
|
657
|
+
confirm: tool.schema.boolean().default(false),
|
|
658
|
+
},
|
|
659
|
+
execute: async (args, context) => {
|
|
660
|
+
await state.ensureInitialized();
|
|
661
|
+
if (!state.initialized)
|
|
662
|
+
return unavailableMessage(state.config.embedding.provider);
|
|
663
|
+
if (!args.confirm) {
|
|
664
|
+
return "Rejected: memory_scope_promote requires confirm=true.";
|
|
665
|
+
}
|
|
666
|
+
const activeScope = deriveProjectScope(context.directory || context.worktree);
|
|
667
|
+
const scopes = buildScopeFilter(activeScope, state.config.includeGlobalScope);
|
|
668
|
+
const exists = await state.store.hasMemory(args.id, scopes);
|
|
669
|
+
if (!exists) {
|
|
670
|
+
return `Memory ${args.id} not found in current scope.`;
|
|
671
|
+
}
|
|
672
|
+
const updated = await state.store.updateMemoryScope(args.id, "global", scopes);
|
|
673
|
+
if (!updated) {
|
|
674
|
+
return `Failed to promote memory ${args.id}.`;
|
|
675
|
+
}
|
|
676
|
+
return `Promoted memory ${args.id} to global scope.`;
|
|
677
|
+
},
|
|
678
|
+
}),
|
|
679
|
+
memory_scope_demote: tool({
|
|
680
|
+
description: "Demote a memory from global scope to project scope",
|
|
681
|
+
args: {
|
|
682
|
+
id: tool.schema.string().min(8),
|
|
683
|
+
confirm: tool.schema.boolean().default(false),
|
|
684
|
+
scope: tool.schema.string().optional(),
|
|
685
|
+
},
|
|
686
|
+
execute: async (args, context) => {
|
|
687
|
+
await state.ensureInitialized();
|
|
688
|
+
if (!state.initialized)
|
|
689
|
+
return unavailableMessage(state.config.embedding.provider);
|
|
690
|
+
if (!args.confirm) {
|
|
691
|
+
return "Rejected: memory_scope_demote requires confirm=true.";
|
|
692
|
+
}
|
|
693
|
+
const projectScope = args.scope ?? deriveProjectScope(context.directory || context.worktree);
|
|
694
|
+
const globalExists = await state.store.hasMemory(args.id, ["global"]);
|
|
695
|
+
if (!globalExists) {
|
|
696
|
+
return `Memory ${args.id} not found in global scope or is not a global memory.`;
|
|
697
|
+
}
|
|
698
|
+
const updated = await state.store.updateMemoryScope(args.id, projectScope, ["global"]);
|
|
699
|
+
if (!updated) {
|
|
700
|
+
return `Failed to demote memory ${args.id}.`;
|
|
701
|
+
}
|
|
702
|
+
return `Demoted memory ${args.id} from global to ${projectScope}.`;
|
|
703
|
+
},
|
|
704
|
+
}),
|
|
705
|
+
memory_global_list: tool({
|
|
706
|
+
description: "List all global-scoped memories, optionally filtered by search query or unused status",
|
|
707
|
+
args: {
|
|
708
|
+
query: tool.schema.string().optional(),
|
|
709
|
+
filter: tool.schema.string().optional(),
|
|
710
|
+
limit: tool.schema.number().int().min(1).max(100).default(20),
|
|
711
|
+
},
|
|
712
|
+
execute: async (args) => {
|
|
713
|
+
await state.ensureInitialized();
|
|
714
|
+
if (!state.initialized)
|
|
715
|
+
return unavailableMessage(state.config.embedding.provider);
|
|
716
|
+
let records;
|
|
717
|
+
if (args.filter === "unused") {
|
|
718
|
+
records = await state.store.getUnusedGlobalMemories(state.config.unusedDaysThreshold, args.limit ?? 20);
|
|
719
|
+
}
|
|
720
|
+
else if (args.query) {
|
|
721
|
+
let queryVector = [];
|
|
722
|
+
try {
|
|
723
|
+
queryVector = await state.embedder.embed(args.query);
|
|
724
|
+
}
|
|
725
|
+
catch {
|
|
726
|
+
queryVector = [];
|
|
727
|
+
}
|
|
728
|
+
records = await state.store.search({
|
|
729
|
+
query: args.query,
|
|
730
|
+
queryVector,
|
|
731
|
+
scopes: ["global"],
|
|
732
|
+
limit: args.limit ?? 20,
|
|
733
|
+
vectorWeight: 0.7,
|
|
734
|
+
bm25Weight: 0.3,
|
|
735
|
+
minScore: 0.2,
|
|
736
|
+
globalDiscountFactor: 1.0,
|
|
737
|
+
}).then((results) => results.map((r) => r.record));
|
|
738
|
+
}
|
|
739
|
+
else {
|
|
740
|
+
records = await state.store.readGlobalMemories(args.limit ?? 20);
|
|
741
|
+
}
|
|
742
|
+
if (records.length === 0) {
|
|
743
|
+
return "No global memories found.";
|
|
744
|
+
}
|
|
745
|
+
return records
|
|
746
|
+
.map((record, idx) => {
|
|
747
|
+
const date = new Date(record.timestamp).toISOString().split("T")[0];
|
|
748
|
+
const lastRecalled = record.lastRecalled > 0
|
|
749
|
+
? new Date(record.lastRecalled).toISOString().split("T")[0]
|
|
750
|
+
: "never";
|
|
751
|
+
return `${idx + 1}. [${record.id}] ${record.text.slice(0, 80)}...
|
|
752
|
+
Stored: ${date} | Recalled: ${lastRecalled} | Count: ${record.recallCount} | Projects: ${record.projectCount}`;
|
|
753
|
+
})
|
|
754
|
+
.join("\n");
|
|
755
|
+
},
|
|
756
|
+
}),
|
|
757
|
+
memory_consolidate: tool({
|
|
758
|
+
description: "Scope-internally merge near-duplicate memories. Use to clean up accumulated duplicates.",
|
|
759
|
+
args: {
|
|
760
|
+
scope: tool.schema.string().optional(),
|
|
761
|
+
confirm: tool.schema.boolean().default(false),
|
|
762
|
+
},
|
|
763
|
+
execute: async (args, context) => {
|
|
764
|
+
await state.ensureInitialized();
|
|
765
|
+
if (!state.initialized)
|
|
766
|
+
return unavailableMessage(state.config.embedding.provider);
|
|
767
|
+
if (!args.confirm) {
|
|
768
|
+
return "Rejected: memory_consolidate requires confirm=true.";
|
|
769
|
+
}
|
|
770
|
+
const targetScope = args.scope ?? deriveProjectScope(context.directory || context.worktree);
|
|
771
|
+
if (state.consolidationInProgress.get(targetScope)) {
|
|
772
|
+
return JSON.stringify({ scope: targetScope, status: "already_in_progress", message: "Consolidation already in progress for this scope" });
|
|
773
|
+
}
|
|
774
|
+
state.consolidationInProgress.set(targetScope, true);
|
|
775
|
+
try {
|
|
776
|
+
const result = await state.store.consolidateDuplicates(targetScope, state.config.dedup.consolidateThreshold, state.config.dedup.candidateLimit);
|
|
777
|
+
return JSON.stringify({ scope: targetScope, ...result }, null, 2);
|
|
778
|
+
}
|
|
779
|
+
finally {
|
|
780
|
+
state.consolidationInProgress.delete(targetScope);
|
|
781
|
+
}
|
|
782
|
+
},
|
|
783
|
+
}),
|
|
784
|
+
memory_consolidate_all: tool({
|
|
785
|
+
description: "Consolidate duplicates across global scope and current project scope. Used by external cron jobs for daily cleanup.",
|
|
786
|
+
args: {
|
|
787
|
+
confirm: tool.schema.boolean().default(false),
|
|
788
|
+
},
|
|
789
|
+
execute: async (args, context) => {
|
|
790
|
+
await state.ensureInitialized();
|
|
791
|
+
if (!state.initialized)
|
|
792
|
+
return unavailableMessage(state.config.embedding.provider);
|
|
793
|
+
if (!args.confirm) {
|
|
794
|
+
return "Rejected: memory_consolidate_all requires confirm=true.";
|
|
795
|
+
}
|
|
796
|
+
const projectScope = deriveProjectScope(context.directory || context.worktree);
|
|
797
|
+
const globalInProgress = state.consolidationInProgress.get("global");
|
|
798
|
+
const projectInProgress = state.consolidationInProgress.get(projectScope);
|
|
799
|
+
if (globalInProgress || projectInProgress) {
|
|
800
|
+
return JSON.stringify({
|
|
801
|
+
global: { scope: "global", status: globalInProgress ? "already_in_progress" : "pending" },
|
|
802
|
+
project: { scope: projectScope, status: projectInProgress ? "already_in_progress" : "pending" },
|
|
803
|
+
message: "Consolidation already in progress for one or more scopes",
|
|
804
|
+
});
|
|
805
|
+
}
|
|
806
|
+
state.consolidationInProgress.set("global", true);
|
|
807
|
+
state.consolidationInProgress.set(projectScope, true);
|
|
808
|
+
try {
|
|
809
|
+
const globalResult = await state.store.consolidateDuplicates("global", state.config.dedup.consolidateThreshold, state.config.dedup.candidateLimit);
|
|
810
|
+
const projectResult = await state.store.consolidateDuplicates(projectScope, state.config.dedup.consolidateThreshold, state.config.dedup.candidateLimit);
|
|
811
|
+
return JSON.stringify({
|
|
812
|
+
global: { scope: "global", ...globalResult },
|
|
813
|
+
project: { scope: projectScope, ...projectResult },
|
|
814
|
+
}, null, 2);
|
|
815
|
+
}
|
|
816
|
+
finally {
|
|
817
|
+
state.consolidationInProgress.delete("global");
|
|
818
|
+
state.consolidationInProgress.delete(projectScope);
|
|
819
|
+
}
|
|
820
|
+
},
|
|
821
|
+
}),
|
|
822
|
+
memory_port_plan: tool({
|
|
823
|
+
description: "Plan non-conflicting host ports for compose services and optionally persist reservations",
|
|
824
|
+
args: {
|
|
825
|
+
project: tool.schema.string().min(1).optional(),
|
|
826
|
+
services: tool.schema
|
|
827
|
+
.array(tool.schema.object({
|
|
828
|
+
name: tool.schema.string().min(1),
|
|
829
|
+
containerPort: tool.schema.number().int().min(1).max(65535),
|
|
830
|
+
preferredHostPort: tool.schema.number().int().min(1).max(65535).optional(),
|
|
831
|
+
}))
|
|
832
|
+
.min(1),
|
|
833
|
+
rangeStart: tool.schema.number().int().min(1).max(65535).default(20000),
|
|
834
|
+
rangeEnd: tool.schema.number().int().min(1).max(65535).default(39999),
|
|
835
|
+
persist: tool.schema.boolean().default(true),
|
|
836
|
+
},
|
|
837
|
+
execute: async (args, context) => {
|
|
838
|
+
await state.ensureInitialized();
|
|
839
|
+
if (!state.initialized)
|
|
840
|
+
return unavailableMessage(state.config.embedding.provider);
|
|
841
|
+
if ((args.rangeStart ?? 20000) > (args.rangeEnd ?? 39999)) {
|
|
842
|
+
return "Invalid range: rangeStart must be <= rangeEnd.";
|
|
843
|
+
}
|
|
844
|
+
const project = args.project?.trim() || deriveProjectScope(context.directory || context.worktree);
|
|
845
|
+
const globalRecords = await state.store.list("global", 100000);
|
|
846
|
+
const reservations = [];
|
|
847
|
+
for (const record of globalRecords) {
|
|
848
|
+
try {
|
|
849
|
+
const meta = JSON.parse(record.metadataJson || "{}");
|
|
850
|
+
if (meta.type === "port-reservation") {
|
|
851
|
+
reservations.push({
|
|
852
|
+
id: record.id,
|
|
853
|
+
project: meta.project,
|
|
854
|
+
service: meta.service,
|
|
855
|
+
protocol: meta.protocol,
|
|
856
|
+
});
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
catch {
|
|
860
|
+
// skip invalid records
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
const assignments = [];
|
|
864
|
+
const usedPorts = new Set();
|
|
865
|
+
const warnings = [];
|
|
866
|
+
for (const res of reservations) {
|
|
867
|
+
try {
|
|
868
|
+
const record = globalRecords.find(r => r.id === res.id);
|
|
869
|
+
if (record) {
|
|
870
|
+
const meta = JSON.parse(record.metadataJson || "{}");
|
|
871
|
+
usedPorts.add(meta.hostPort);
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
catch {
|
|
875
|
+
// skip
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
for (const service of args.services) {
|
|
879
|
+
let hostPort = service.preferredHostPort;
|
|
880
|
+
if (!hostPort || usedPorts.has(hostPort)) {
|
|
881
|
+
hostPort = 0;
|
|
882
|
+
for (let port = args.rangeStart ?? 20000; port <= (args.rangeEnd ?? 39999); port++) {
|
|
883
|
+
if (!usedPorts.has(port)) {
|
|
884
|
+
hostPort = port;
|
|
885
|
+
break;
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
if (hostPort > 0) {
|
|
890
|
+
usedPorts.add(hostPort);
|
|
891
|
+
assignments.push({
|
|
892
|
+
project,
|
|
893
|
+
service: service.name,
|
|
894
|
+
containerPort: service.containerPort,
|
|
895
|
+
hostPort,
|
|
896
|
+
protocol: "tcp",
|
|
897
|
+
});
|
|
898
|
+
}
|
|
899
|
+
else {
|
|
900
|
+
warnings.push(`No free host port in range ${args.rangeStart ?? 20000}-${args.rangeEnd ?? 39999} for service ${service.name}`);
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
let persisted = 0;
|
|
904
|
+
if (args.persist) {
|
|
905
|
+
const keyToOldIds = new Map();
|
|
906
|
+
for (const reservation of reservations) {
|
|
907
|
+
const key = `${reservation.project}:${reservation.service}:${reservation.protocol}`;
|
|
908
|
+
if (!keyToOldIds.has(key)) {
|
|
909
|
+
keyToOldIds.set(key, []);
|
|
910
|
+
}
|
|
911
|
+
keyToOldIds.get(key)?.push(reservation.id);
|
|
912
|
+
}
|
|
913
|
+
for (const assignment of assignments) {
|
|
914
|
+
const key = `${assignment.project}:${assignment.service}:${assignment.protocol}`;
|
|
915
|
+
const oldIds = keyToOldIds.get(key) ?? [];
|
|
916
|
+
const text = `PORT_RESERVATION ${assignment.project} ${assignment.service} host=${assignment.hostPort} container=${assignment.containerPort} protocol=${assignment.protocol}`;
|
|
917
|
+
try {
|
|
918
|
+
const vector = await state.embedder.embed(text);
|
|
919
|
+
if (vector.length === 0) {
|
|
920
|
+
warnings.push(`Skipped persistence for ${assignment.service}: empty embedding vector.`);
|
|
921
|
+
continue;
|
|
922
|
+
}
|
|
923
|
+
await state.store.put({
|
|
924
|
+
id: generateId(),
|
|
925
|
+
text,
|
|
926
|
+
vector,
|
|
927
|
+
category: "entity",
|
|
928
|
+
scope: "global",
|
|
929
|
+
importance: 0.8,
|
|
930
|
+
timestamp: Date.now(),
|
|
931
|
+
lastRecalled: 0,
|
|
932
|
+
recallCount: 0,
|
|
933
|
+
projectCount: 0,
|
|
934
|
+
schemaVersion: 1,
|
|
935
|
+
embeddingModel: state.config.embedding.model,
|
|
936
|
+
vectorDim: vector.length,
|
|
937
|
+
metadataJson: JSON.stringify({
|
|
938
|
+
source: "port-plan",
|
|
939
|
+
type: "port-reservation",
|
|
940
|
+
project: assignment.project,
|
|
941
|
+
service: assignment.service,
|
|
942
|
+
hostPort: assignment.hostPort,
|
|
943
|
+
containerPort: assignment.containerPort,
|
|
944
|
+
protocol: assignment.protocol,
|
|
945
|
+
}),
|
|
946
|
+
});
|
|
947
|
+
for (const id of oldIds) {
|
|
948
|
+
await state.store.deleteById(id, ["global"]);
|
|
949
|
+
}
|
|
950
|
+
persisted += 1;
|
|
951
|
+
}
|
|
952
|
+
catch (error) {
|
|
953
|
+
warnings.push(`Failed to persist ${assignment.service}: ${error instanceof Error ? error.message : String(error)}`);
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
return JSON.stringify({
|
|
958
|
+
project,
|
|
959
|
+
persistRequested: args.persist,
|
|
960
|
+
persisted,
|
|
961
|
+
assignments,
|
|
962
|
+
warnings,
|
|
963
|
+
}, null, 2);
|
|
964
|
+
},
|
|
965
|
+
}),
|
|
966
|
+
memory_dashboard: tool({
|
|
967
|
+
description: "Show weekly learning dashboard with trends and insights",
|
|
968
|
+
args: {
|
|
969
|
+
days: tool.schema.number().int().min(1).max(90).default(7),
|
|
970
|
+
scope: tool.schema.string().optional(),
|
|
971
|
+
},
|
|
972
|
+
execute: async (args, context) => {
|
|
973
|
+
await state.ensureInitialized();
|
|
974
|
+
if (!state.initialized)
|
|
975
|
+
return unavailableMessage(state.config.embedding.provider);
|
|
976
|
+
const scope = args.scope ?? deriveProjectScope(context.directory || context.worktree);
|
|
977
|
+
const dashboard = await state.store.getWeeklyEffectivenessSummary(scope, state.config.includeGlobalScope, args.days ?? 7);
|
|
978
|
+
return JSON.stringify(dashboard, null, 2);
|
|
979
|
+
},
|
|
980
|
+
}),
|
|
981
|
+
memory_kpi: tool({
|
|
982
|
+
description: "Show learning KPI metrics (retry-to-success rate and memory lift)",
|
|
983
|
+
args: {
|
|
984
|
+
days: tool.schema.number().int().min(1).max(365).default(30),
|
|
985
|
+
scope: tool.schema.string().optional(),
|
|
986
|
+
},
|
|
987
|
+
execute: async (args, context) => {
|
|
988
|
+
await state.ensureInitialized();
|
|
989
|
+
if (!state.initialized)
|
|
990
|
+
return unavailableMessage(state.config.embedding.provider);
|
|
991
|
+
const scope = args.scope ?? deriveProjectScope(context.directory || context.worktree);
|
|
992
|
+
const kpi = await state.store.getKpiSummary(scope, args.days ?? 30);
|
|
993
|
+
return JSON.stringify(kpi, null, 2);
|
|
994
|
+
},
|
|
995
|
+
}),
|
|
996
|
+
// MEMORY_LIFECYCLE_TOOLS (0.9): export/import/summarize.
|
|
997
|
+
memory_export: tool({
|
|
998
|
+
description: "Backup all memories (incl. disabled/merged/digested) to a JSON file",
|
|
999
|
+
args: {
|
|
1000
|
+
path: tool.schema.string().min(1),
|
|
1001
|
+
scope: tool.schema.string().optional(),
|
|
1002
|
+
dryRun: tool.schema.boolean().optional().default(false),
|
|
1003
|
+
},
|
|
1004
|
+
execute: async (args, context) => {
|
|
1005
|
+
await state.ensureInitialized();
|
|
1006
|
+
if (!state.initialized)
|
|
1007
|
+
return unavailableMessage(state.config.embedding.provider);
|
|
1008
|
+
const activeScope = args.scope ?? deriveProjectScope(context.directory || context.worktree);
|
|
1009
|
+
const scopes = buildScopeFilter(activeScope, state.config.includeGlobalScope);
|
|
1010
|
+
const records = await state.store.exportAllRecords(scopes);
|
|
1011
|
+
if (args.dryRun) {
|
|
1012
|
+
return JSON.stringify({
|
|
1013
|
+
dryRun: true,
|
|
1014
|
+
wouldExport: records.length,
|
|
1015
|
+
scopes,
|
|
1016
|
+
message: "No file written",
|
|
1017
|
+
}, null, 2);
|
|
1018
|
+
}
|
|
1019
|
+
const payload = {
|
|
1020
|
+
format: "opencode-memory-pro/backup",
|
|
1021
|
+
version: 1,
|
|
1022
|
+
exportedAt: new Date().toISOString(),
|
|
1023
|
+
provider: state.config.provider,
|
|
1024
|
+
dbPath: state.config.dbPath,
|
|
1025
|
+
scope: activeScope,
|
|
1026
|
+
scopes,
|
|
1027
|
+
count: records.length,
|
|
1028
|
+
memories: records,
|
|
1029
|
+
};
|
|
1030
|
+
const fs = await import("node:fs");
|
|
1031
|
+
try {
|
|
1032
|
+
const exportDir = args.path.lastIndexOf("/") > 0 ? args.path.slice(0, args.path.lastIndexOf("/")) : ".";
|
|
1033
|
+
await fs.promises.mkdir(exportDir, { recursive: true });
|
|
1034
|
+
}
|
|
1035
|
+
catch {
|
|
1036
|
+
}
|
|
1037
|
+
await fs.promises.writeFile(args.path, JSON.stringify(payload, null, 2));
|
|
1038
|
+
return JSON.stringify({
|
|
1039
|
+
exportedCount: records.length,
|
|
1040
|
+
file: args.path,
|
|
1041
|
+
bytes: (await fs.promises.stat(args.path)).size,
|
|
1042
|
+
scopes,
|
|
1043
|
+
}, null, 2);
|
|
1044
|
+
},
|
|
1045
|
+
}),
|
|
1046
|
+
memory_import: tool({
|
|
1047
|
+
description: "Restore memories from a memory_export JSON backup (merge skips existing ids, replace overwrites them)",
|
|
1048
|
+
args: {
|
|
1049
|
+
path: tool.schema.string().min(1),
|
|
1050
|
+
scope: tool.schema.string().optional(),
|
|
1051
|
+
mode: tool.schema.enum(["merge", "replace"]).optional().default("merge"),
|
|
1052
|
+
dryRun: tool.schema.boolean().optional().default(false),
|
|
1053
|
+
},
|
|
1054
|
+
execute: async (args, context) => {
|
|
1055
|
+
await state.ensureInitialized();
|
|
1056
|
+
if (!state.initialized)
|
|
1057
|
+
return unavailableMessage(state.config.embedding.provider);
|
|
1058
|
+
const fs = await import("node:fs");
|
|
1059
|
+
let payload;
|
|
1060
|
+
try {
|
|
1061
|
+
payload = JSON.parse(await fs.promises.readFile(args.path, "utf8"));
|
|
1062
|
+
}
|
|
1063
|
+
catch (error) {
|
|
1064
|
+
return JSON.stringify({ error: `Failed to read ${args.path}: ${error instanceof Error ? error.message : String(error)}` }, null, 2);
|
|
1065
|
+
}
|
|
1066
|
+
if (!Array.isArray(payload?.memories)) {
|
|
1067
|
+
return JSON.stringify({ error: "Not a memory_export backup (missing memories array)" }, null, 2);
|
|
1068
|
+
}
|
|
1069
|
+
const activeScope = args.scope ?? deriveProjectScope(context.directory || context.worktree);
|
|
1070
|
+
const scopes = buildScopeFilter(activeScope, state.config.includeGlobalScope);
|
|
1071
|
+
const source = payload?.memories ?? [];
|
|
1072
|
+
let imported = 0;
|
|
1073
|
+
let replaced = 0;
|
|
1074
|
+
let skipped = 0;
|
|
1075
|
+
let failed = 0;
|
|
1076
|
+
const failures = [];
|
|
1077
|
+
const embedderDim = await state.embedder.dim();
|
|
1078
|
+
for (const m of source) {
|
|
1079
|
+
if (typeof m?.id !== "string" || typeof m?.text !== "string") {
|
|
1080
|
+
failed += 1;
|
|
1081
|
+
continue;
|
|
1082
|
+
}
|
|
1083
|
+
try {
|
|
1084
|
+
const exists = await state.store.hasMemory(m.id, scopes);
|
|
1085
|
+
if (exists && args.mode !== "replace") {
|
|
1086
|
+
skipped += 1;
|
|
1087
|
+
continue;
|
|
1088
|
+
}
|
|
1089
|
+
if (args.dryRun) {
|
|
1090
|
+
if (exists) {
|
|
1091
|
+
replaced += 1;
|
|
1092
|
+
}
|
|
1093
|
+
else {
|
|
1094
|
+
imported += 1;
|
|
1095
|
+
}
|
|
1096
|
+
continue;
|
|
1097
|
+
}
|
|
1098
|
+
if (exists) {
|
|
1099
|
+
await state.store.deleteById(m.id, scopes);
|
|
1100
|
+
}
|
|
1101
|
+
let vector = Array.isArray(m.vector) ? m.vector.map(Number) : [];
|
|
1102
|
+
if (vector.length !== embedderDim) {
|
|
1103
|
+
try {
|
|
1104
|
+
vector = await state.embedder.embed(m.text);
|
|
1105
|
+
}
|
|
1106
|
+
catch {
|
|
1107
|
+
vector = [];
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
if (vector.length === 0 || vector.length !== embedderDim) {
|
|
1111
|
+
failed += 1;
|
|
1112
|
+
failures.push({ id: m.id, reason: "embedding unavailable" });
|
|
1113
|
+
continue;
|
|
1114
|
+
}
|
|
1115
|
+
const now = Date.now();
|
|
1116
|
+
await state.store.put({
|
|
1117
|
+
id: m.id,
|
|
1118
|
+
text: m.text,
|
|
1119
|
+
vector,
|
|
1120
|
+
category: typeof m.category === "string" ? m.category : "other",
|
|
1121
|
+
scope: typeof m.scope === "string" ? m.scope : activeScope,
|
|
1122
|
+
importance: typeof m.importance === "number" ? m.importance : 0.5,
|
|
1123
|
+
timestamp: typeof m.timestamp === "number" ? m.timestamp : now,
|
|
1124
|
+
lastRecalled: typeof m.lastRecalled === "number" ? m.lastRecalled : 0,
|
|
1125
|
+
recallCount: typeof m.recallCount === "number" ? m.recallCount : 0,
|
|
1126
|
+
projectCount: typeof m.projectCount === "number" ? m.projectCount : 0,
|
|
1127
|
+
schemaVersion: typeof m.schemaVersion === "number" ? m.schemaVersion : 1,
|
|
1128
|
+
embeddingModel: typeof m.embeddingModel === "string" ? m.embeddingModel : state.config.embedding.model,
|
|
1129
|
+
vectorDim: vector.length,
|
|
1130
|
+
metadataJson: typeof m.metadataJson === "string" ? m.metadataJson : JSON.stringify({ source: "memory_import" }),
|
|
1131
|
+
sourceSessionId: typeof m.sourceSessionId === "string" ? m.sourceSessionId : undefined,
|
|
1132
|
+
citationSource: typeof m.citationSource === "string" ? m.citationSource : undefined,
|
|
1133
|
+
citationTimestamp: typeof m.citationTimestamp === "number" ? m.citationTimestamp : undefined,
|
|
1134
|
+
citationStatus: typeof m.citationStatus === "string" ? m.citationStatus : undefined,
|
|
1135
|
+
citationChain: Array.isArray(m.citationChain) ? m.citationChain : undefined,
|
|
1136
|
+
confidence: typeof m.confidence === "number" ? m.confidence : undefined,
|
|
1137
|
+
tags: Array.isArray(m.tags) ? m.tags : undefined,
|
|
1138
|
+
status: typeof m.status === "string" ? m.status : "active",
|
|
1139
|
+
parentId: typeof m.parentId === "string" ? m.parentId : undefined,
|
|
1140
|
+
});
|
|
1141
|
+
if (state.config.graph?.enabled && state.graph?.enabled && m.text) {
|
|
1142
|
+
try {
|
|
1143
|
+
state.graph.extract(m.text);
|
|
1144
|
+
state.graph.indexMemory(m.id, m.text, typeof m.timestamp === "number" ? m.timestamp : now);
|
|
1145
|
+
}
|
|
1146
|
+
catch {
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
if (exists) {
|
|
1150
|
+
replaced += 1;
|
|
1151
|
+
}
|
|
1152
|
+
else {
|
|
1153
|
+
imported += 1;
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1156
|
+
catch (error) {
|
|
1157
|
+
failed += 1;
|
|
1158
|
+
failures.push({ id: m.id, reason: error instanceof Error ? error.message : String(error) });
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
return JSON.stringify({
|
|
1162
|
+
mode: args.mode,
|
|
1163
|
+
total: source.length,
|
|
1164
|
+
imported,
|
|
1165
|
+
replaced,
|
|
1166
|
+
skipped,
|
|
1167
|
+
failed,
|
|
1168
|
+
failures: failures.slice(0, 10),
|
|
1169
|
+
scopes,
|
|
1170
|
+
}, null, 2);
|
|
1171
|
+
},
|
|
1172
|
+
}),
|
|
1173
|
+
memory_summarize: tool({
|
|
1174
|
+
description: "Create digests of old memories (store-level summarization). LLM abstractive digests when capture.mode=llm, offline extractive otherwise. Optionally mark originals 'digested' (replace=true) so only the digest remains in recall.",
|
|
1175
|
+
args: {
|
|
1176
|
+
scope: tool.schema.string().optional(),
|
|
1177
|
+
minAgeDays: tool.schema.number().int().min(2).max(3650).optional(),
|
|
1178
|
+
groupBy: tool.schema.enum(["category", "none"]).optional().default("category"),
|
|
1179
|
+
minGroupSize: tool.schema.number().int().min(2).max(100).optional(),
|
|
1180
|
+
targetChars: tool.schema.number().int().min(100).max(2000).optional(),
|
|
1181
|
+
replace: tool.schema.boolean().optional(),
|
|
1182
|
+
dryRun: tool.schema.boolean().optional().default(false),
|
|
1183
|
+
},
|
|
1184
|
+
execute: async (args, context) => {
|
|
1185
|
+
await state.ensureInitialized();
|
|
1186
|
+
if (!state.initialized)
|
|
1187
|
+
return unavailableMessage(state.config.embedding.provider);
|
|
1188
|
+
const summarizeCfg = state.config.summarize ?? { enabled: true, minAgeDays: 30, minGroupSize: 3, targetChars: 500, replace: false };
|
|
1189
|
+
if (summarizeCfg.enabled === false) {
|
|
1190
|
+
return JSON.stringify({ error: "Summarization disabled via config summarize.enabled=false" }, null, 2);
|
|
1191
|
+
}
|
|
1192
|
+
const activeScope = args.scope ?? deriveProjectScope(context.directory || context.worktree);
|
|
1193
|
+
const scopes = buildScopeFilter(activeScope, state.config.includeGlobalScope);
|
|
1194
|
+
const minAgeDays = args.minAgeDays ?? summarizeCfg.minAgeDays;
|
|
1195
|
+
const minGroupSize = args.minGroupSize ?? summarizeCfg.minGroupSize;
|
|
1196
|
+
const targetChars = args.targetChars ?? summarizeCfg.targetChars;
|
|
1197
|
+
const replace = args.replace ?? summarizeCfg.replace;
|
|
1198
|
+
const cutoff = Date.now() - minAgeDays * 24 * 60 * 60 * 1000;
|
|
1199
|
+
const records = await state.store.readByScopes(scopes);
|
|
1200
|
+
const candidates = records.filter((r) => r.timestamp < cutoff && (r.status === undefined || r.status === "active") && r.category !== "digest" && !(r.metadataJson && r.metadataJson.includes('"digestOf"')) && !(r.metadataJson && r.metadataJson.includes('"digestedInto"')));
|
|
1201
|
+
if (candidates.length === 0) {
|
|
1202
|
+
return JSON.stringify({ minAgeDays, eligible: 0, groups: 0, digestsCreated: 0, message: "No eligible memories" }, null, 2);
|
|
1203
|
+
}
|
|
1204
|
+
const groups = new Map();
|
|
1205
|
+
if (args.groupBy === "none") {
|
|
1206
|
+
groups.set("all", candidates);
|
|
1207
|
+
}
|
|
1208
|
+
else {
|
|
1209
|
+
for (const r of candidates) {
|
|
1210
|
+
const key = r.category || "other";
|
|
1211
|
+
if (!groups.has(key))
|
|
1212
|
+
groups.set(key, []);
|
|
1213
|
+
groups.get(key).push(r);
|
|
1214
|
+
}
|
|
1215
|
+
}
|
|
1216
|
+
const created = [];
|
|
1217
|
+
const dryRunSummary = [];
|
|
1218
|
+
for (const [groupKey, group] of groups) {
|
|
1219
|
+
if (group.length < minGroupSize)
|
|
1220
|
+
continue;
|
|
1221
|
+
const entityNames = new Set();
|
|
1222
|
+
for (const r of group) {
|
|
1223
|
+
try {
|
|
1224
|
+
const meta = JSON.parse(r.metadataJson || "{}");
|
|
1225
|
+
if (Array.isArray(meta.graphEntities)) {
|
|
1226
|
+
for (const e of meta.graphEntities)
|
|
1227
|
+
entityNames.add(e);
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
1230
|
+
catch {
|
|
1231
|
+
}
|
|
1232
|
+
}
|
|
1233
|
+
const digest = args.dryRun
|
|
1234
|
+
? null
|
|
1235
|
+
: await buildGroupDigest(state, group, targetChars, groupKey, entityNames);
|
|
1236
|
+
const digestText = digest && digest.text
|
|
1237
|
+
? digest.text
|
|
1238
|
+
: `SUMMARY (${groupKey}) — ${group.length} memories`;
|
|
1239
|
+
const ids = group.map((r) => r.id);
|
|
1240
|
+
if (args.dryRun) {
|
|
1241
|
+
dryRunSummary.push({ group: groupKey, memories: ids.length, digestChars: digestText.length });
|
|
1242
|
+
continue;
|
|
1243
|
+
}
|
|
1244
|
+
const now = Date.now();
|
|
1245
|
+
const digestId = generateId();
|
|
1246
|
+
let vector = [];
|
|
1247
|
+
try {
|
|
1248
|
+
vector = await state.embedder.embed(digestText);
|
|
1249
|
+
}
|
|
1250
|
+
catch {
|
|
1251
|
+
vector = [];
|
|
1252
|
+
}
|
|
1253
|
+
if (vector.length === 0) {
|
|
1254
|
+
// SUMMARIZE_SKIP_GROUP (1.2.0): was a hard abort mid-loop
|
|
1255
|
+
// claiming "no changes made" even when earlier groups had
|
|
1256
|
+
// already been digested/replaced. Skip just this group
|
|
1257
|
+
// (originals stay active) like the retention sweep does.
|
|
1258
|
+
log("warn", `[summarize] embed failed for "${groupKey}" — skipping group (${group.length} memories)`);
|
|
1259
|
+
continue;
|
|
1260
|
+
}
|
|
1261
|
+
await state.store.put({
|
|
1262
|
+
id: digestId,
|
|
1263
|
+
text: digestText,
|
|
1264
|
+
vector,
|
|
1265
|
+
category: "digest",
|
|
1266
|
+
scope: activeScope,
|
|
1267
|
+
importance: 0.6,
|
|
1268
|
+
timestamp: now,
|
|
1269
|
+
lastRecalled: 0,
|
|
1270
|
+
recallCount: 0,
|
|
1271
|
+
projectCount: 0,
|
|
1272
|
+
schemaVersion: 1,
|
|
1273
|
+
embeddingModel: state.config.embedding.model,
|
|
1274
|
+
vectorDim: vector.length,
|
|
1275
|
+
metadataJson: JSON.stringify({
|
|
1276
|
+
source: "memory-summarize",
|
|
1277
|
+
category: groupKey,
|
|
1278
|
+
digestOf: ids,
|
|
1279
|
+
digestKind: digest?.llm ? "llm" : "extractive",
|
|
1280
|
+
digestChars: digestText.length,
|
|
1281
|
+
}),
|
|
1282
|
+
sourceSessionId: context.sessionID,
|
|
1283
|
+
citationSource: "memory-summarize",
|
|
1284
|
+
});
|
|
1285
|
+
if (state.graph?.enabled) {
|
|
1286
|
+
try {
|
|
1287
|
+
state.graph.extract(digestText);
|
|
1288
|
+
state.graph.indexMemory(digestId, digestText, now);
|
|
1289
|
+
}
|
|
1290
|
+
catch {
|
|
1291
|
+
}
|
|
1292
|
+
}
|
|
1293
|
+
let digested = 0;
|
|
1294
|
+
if (replace) {
|
|
1295
|
+
digested = await state.store.markDigested(ids, digestId, scopes);
|
|
1296
|
+
}
|
|
1297
|
+
created.push({
|
|
1298
|
+
digestId,
|
|
1299
|
+
group: groupKey,
|
|
1300
|
+
absorbed: digest?.sourceCount ?? group.length,
|
|
1301
|
+
digested,
|
|
1302
|
+
digestChars: digestText.length,
|
|
1303
|
+
});
|
|
1304
|
+
}
|
|
1305
|
+
return JSON.stringify({
|
|
1306
|
+
minAgeDays,
|
|
1307
|
+
eligible: candidates.length,
|
|
1308
|
+
groups: created.length + dryRunSummary.length,
|
|
1309
|
+
digestsCreated: created.length,
|
|
1310
|
+
dryRun: args.dryRun ? true : undefined,
|
|
1311
|
+
dryRunSummary,
|
|
1312
|
+
created,
|
|
1313
|
+
}, null, 2);
|
|
1314
|
+
},
|
|
1315
|
+
}),
|
|
1316
|
+
// MEMORY_RETENTION (1.0): run the digest-then-hide expiry sweep
|
|
1317
|
+
// manually (same rule + action as the automatic session-idle sweep).
|
|
1318
|
+
memory_expire: tool({
|
|
1319
|
+
description: "Run the memory retention sweep: fold memories that are old AND unused into per-category digests (LLM abstractive when capture.mode=llm, extractive otherwise) and mark the originals 'digested' (hidden from recall, never deleted). dryRun=true lists candidates without changing anything.",
|
|
1320
|
+
args: {
|
|
1321
|
+
scope: tool.schema.string().optional(),
|
|
1322
|
+
unusedDays: tool.schema.number().int().min(30).max(3650).optional(),
|
|
1323
|
+
minAgeDays: tool.schema.number().int().min(30).max(3650).optional(),
|
|
1324
|
+
minGroupSize: tool.schema.number().int().min(1).max(100).optional(),
|
|
1325
|
+
targetChars: tool.schema.number().int().min(100).max(2000).optional(),
|
|
1326
|
+
dryRun: tool.schema.boolean().optional().default(false),
|
|
1327
|
+
},
|
|
1328
|
+
execute: async (args, context) => {
|
|
1329
|
+
await state.ensureInitialized();
|
|
1330
|
+
if (!state.initialized)
|
|
1331
|
+
return unavailableMessage(state.config.embedding.provider);
|
|
1332
|
+
if (state.config.retention?.memory?.enabled === false && args.dryRun !== true) {
|
|
1333
|
+
return JSON.stringify({ error: "Memory retention disabled via config retention.memory.enabled=false" }, null, 2);
|
|
1334
|
+
}
|
|
1335
|
+
const result = await sweepExpiredMemories(state, {
|
|
1336
|
+
scope: args.scope ?? deriveProjectScope(context.directory || context.worktree),
|
|
1337
|
+
dryRun: args.dryRun === true,
|
|
1338
|
+
unusedDays: args.unusedDays,
|
|
1339
|
+
minAgeDays: args.minAgeDays,
|
|
1340
|
+
minGroupSize: args.minGroupSize,
|
|
1341
|
+
targetChars: args.targetChars,
|
|
1342
|
+
});
|
|
1343
|
+
return JSON.stringify(result, null, 2);
|
|
1344
|
+
},
|
|
1345
|
+
}),
|
|
1346
|
+
};
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
// MEMORY_RETENTION (1.0): digest-then-hide expiry sweep — the shared core
|
|
1350
|
+
// behind the memory_expire tool and the event-driven sweep in index.js
|
|
1351
|
+
// (session.created/init/idle/compacted/deleted). Groups expired candidates by
|
|
1352
|
+
// category, builds ONE extractive digest per group ≥ minGroupSize (embedded +
|
|
1353
|
+
// graph-indexed, stamped source:"memory-retention" + digestOf), then marks the
|
|
1354
|
+
// originals status:"digested" via store.markDigested (which also strips their
|
|
1355
|
+
// graph provenance). Never deletes anything — every original is recoverable by
|
|
1356
|
+
// re-importing a backup or flipping status back to "active".
|
|
1357
|
+
export async function sweepExpiredMemories(state, opts = {}) {
|
|
1358
|
+
const retCfg = state.config?.retention?.memory ?? {
|
|
1359
|
+
enabled: true,
|
|
1360
|
+
unusedDays: 60,
|
|
1361
|
+
minAgeDays: 180,
|
|
1362
|
+
minGroupSize: 2,
|
|
1363
|
+
targetChars: 500,
|
|
1364
|
+
minImportance: 0.3,
|
|
1365
|
+
protectedCategories: ["digest"],
|
|
1366
|
+
};
|
|
1367
|
+
const enabledOverride = opts.enabledOverride;
|
|
1368
|
+
const disabled = enabledOverride === false || (enabledOverride === undefined && retCfg.enabled === false);
|
|
1369
|
+
// SWEEP_DRYRUN (1.2.0): a dry run may still list candidates when retention
|
|
1370
|
+
// is disabled (memory_expire dryRun preview); only real runs are blocked.
|
|
1371
|
+
if (disabled && opts.dryRun !== true) {
|
|
1372
|
+
return { enabled: false, unusedDays: retCfg.unusedDays, minAgeDays: retCfg.minAgeDays, eligible: 0, groups: 0, digestsCreated: 0, digested: 0, message: "Memory retention disabled via config retention.memory.enabled=false" };
|
|
1373
|
+
}
|
|
1374
|
+
if (!state.initialized)
|
|
1375
|
+
return { enabled: !disabled, unusedDays: retCfg.unusedDays, minAgeDays: retCfg.minAgeDays, eligible: 0, groups: 0, digestsCreated: 0, digested: 0, message: "Not initialized" };
|
|
1376
|
+
const activeScope = opts.scope ?? state.defaultScope ?? "global";
|
|
1377
|
+
const scopes = buildScopeFilter(activeScope, state.config.includeGlobalScope ?? true);
|
|
1378
|
+
const unusedDays = opts.unusedDays ?? retCfg.unusedDays;
|
|
1379
|
+
const minAgeDays = opts.minAgeDays ?? retCfg.minAgeDays;
|
|
1380
|
+
const minGroupSize = opts.minGroupSize ?? retCfg.minGroupSize;
|
|
1381
|
+
const targetChars = opts.targetChars ?? retCfg.targetChars;
|
|
1382
|
+
const minImportance = opts.minImportance ?? retCfg.minImportance;
|
|
1383
|
+
const protectedCategories = opts.protectedCategories ?? retCfg.protectedCategories;
|
|
1384
|
+
const records = await state.store.readByScopes(scopes);
|
|
1385
|
+
const candidates = retentionCandidates(records, { unusedDays, minAgeDays, minImportance, protectedCategories });
|
|
1386
|
+
if (candidates.length === 0) {
|
|
1387
|
+
return { enabled: !disabled, unusedDays, minAgeDays, eligible: 0, groups: 0, digestsCreated: 0, digested: 0, message: "No expired memories" };
|
|
1388
|
+
}
|
|
1389
|
+
const groups = new Map();
|
|
1390
|
+
for (const r of candidates) {
|
|
1391
|
+
const key = r.category || "other";
|
|
1392
|
+
if (!groups.has(key))
|
|
1393
|
+
groups.set(key, []);
|
|
1394
|
+
groups.get(key).push(r);
|
|
1395
|
+
}
|
|
1396
|
+
const dryRun = opts.dryRun === true;
|
|
1397
|
+
const created = [];
|
|
1398
|
+
const dryRunSummary = [];
|
|
1399
|
+
let digestedTotal = 0;
|
|
1400
|
+
for (const [groupKey, group] of groups) {
|
|
1401
|
+
if (group.length < minGroupSize)
|
|
1402
|
+
continue;
|
|
1403
|
+
const entityNames = new Set();
|
|
1404
|
+
for (const r of group) {
|
|
1405
|
+
try {
|
|
1406
|
+
const meta = JSON.parse(r.metadataJson || "{}");
|
|
1407
|
+
if (Array.isArray(meta.graphEntities)) {
|
|
1408
|
+
for (const e of meta.graphEntities)
|
|
1409
|
+
entityNames.add(e);
|
|
1410
|
+
}
|
|
1411
|
+
}
|
|
1412
|
+
catch { }
|
|
1413
|
+
}
|
|
1414
|
+
const digest = dryRun
|
|
1415
|
+
? null
|
|
1416
|
+
: await buildGroupDigest(state, group, targetChars, groupKey, entityNames);
|
|
1417
|
+
const digestText = digest && digest.text
|
|
1418
|
+
? digest.text
|
|
1419
|
+
: `SUMMARY (${groupKey}) — ${group.length} memories`;
|
|
1420
|
+
const ids = group.map((r) => r.id);
|
|
1421
|
+
if (dryRun) {
|
|
1422
|
+
dryRunSummary.push({ group: groupKey, memories: ids.length, digestChars: digestText.length });
|
|
1423
|
+
continue;
|
|
1424
|
+
}
|
|
1425
|
+
const now = Date.now();
|
|
1426
|
+
const digestId = generateId();
|
|
1427
|
+
let vector = [];
|
|
1428
|
+
try {
|
|
1429
|
+
vector = await state.embedder.embed(digestText);
|
|
1430
|
+
}
|
|
1431
|
+
catch (error) {
|
|
1432
|
+
log("warn", `[retention] embed failed for "${groupKey}": ${error instanceof Error ? error.message : String(error)}`);
|
|
1433
|
+
continue;
|
|
1434
|
+
}
|
|
1435
|
+
if (vector.length === 0)
|
|
1436
|
+
continue;
|
|
1437
|
+
try {
|
|
1438
|
+
await state.store.put({
|
|
1439
|
+
id: digestId,
|
|
1440
|
+
text: digestText,
|
|
1441
|
+
vector,
|
|
1442
|
+
category: "digest",
|
|
1443
|
+
scope: activeScope,
|
|
1444
|
+
importance: 0.6,
|
|
1445
|
+
timestamp: now,
|
|
1446
|
+
lastRecalled: 0,
|
|
1447
|
+
recallCount: 0,
|
|
1448
|
+
projectCount: 0,
|
|
1449
|
+
schemaVersion: 1,
|
|
1450
|
+
embeddingModel: state.config.embedding.model,
|
|
1451
|
+
vectorDim: vector.length,
|
|
1452
|
+
metadataJson: JSON.stringify({
|
|
1453
|
+
source: "memory-retention",
|
|
1454
|
+
category: groupKey,
|
|
1455
|
+
digestOf: ids,
|
|
1456
|
+
digestKind: digest?.llm ? "llm" : "extractive",
|
|
1457
|
+
digestChars: digestText.length,
|
|
1458
|
+
}),
|
|
1459
|
+
});
|
|
1460
|
+
if (state.graph?.enabled) {
|
|
1461
|
+
try {
|
|
1462
|
+
state.graph.extract(digestText);
|
|
1463
|
+
state.graph.indexMemory(digestId, digestText, now);
|
|
1464
|
+
}
|
|
1465
|
+
catch { }
|
|
1466
|
+
}
|
|
1467
|
+
const digested = await state.store.markDigested(ids, digestId, scopes);
|
|
1468
|
+
digestedTotal += digested;
|
|
1469
|
+
created.push({ digestId, group: groupKey, absorbed: digest?.sourceCount ?? group.length, digested, digestChars: digestText.length });
|
|
1470
|
+
}
|
|
1471
|
+
catch (error) {
|
|
1472
|
+
log("warn", `[retention] digest creation failed for "${groupKey}": ${error instanceof Error ? error.message : String(error)}`);
|
|
1473
|
+
}
|
|
1474
|
+
}
|
|
1475
|
+
return {
|
|
1476
|
+
enabled: !disabled,
|
|
1477
|
+
unusedDays,
|
|
1478
|
+
minAgeDays,
|
|
1479
|
+
eligible: candidates.length,
|
|
1480
|
+
groups: created.length + dryRunSummary.length,
|
|
1481
|
+
digestsCreated: created.length,
|
|
1482
|
+
digested: digestedTotal,
|
|
1483
|
+
dryRun: dryRun ? true : undefined,
|
|
1484
|
+
dryRunSummary,
|
|
1485
|
+
digests: created,
|
|
1486
|
+
};
|
|
1487
|
+
}
|