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.
@@ -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
+ }