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/dist/index.js ADDED
@@ -0,0 +1,953 @@
1
+ import { resolveMemoryConfig } from "./config.js";
2
+ import { createEmbedder } from "./embedder.js";
3
+ import { extractCaptureCandidate } from "./extract.js";
4
+ import { extractPreferenceSignals, aggregatePreferences, resolveConflicts, buildPreferenceInjection } from "./preference.js";
5
+ import { buildScopeFilter, deriveProjectScope } from "./scope.js";
6
+ import { MemoryStore } from "./store.js";
7
+ import { generateId, classifyFailure } from "./utils.js";
8
+ import { initLogger, configureLogger, log } from "./logger.js";
9
+ import { calculateInjectionLimit, createSummarizationConfig, summarizeContent, truncateText } from "./summarize.js";
10
+ import { requestLLMCapture, isOwnSession } from "./llm.js";
11
+ import { createMemoryTools, createFeedbackTools, createEpisodicTools } from "./tools/index.js";
12
+ import { sweepExpiredMemories } from "./tools/memory.js";
13
+ import { createGraphStore } from "./graph.js";
14
+ const PLUGIN_VERSION = "1.3.0";
15
+ const SCHEMA_VERSION = 1;
16
+ // Event-driven dedup: run consolidateDuplicates on session.idle (throttled to
17
+ // this interval so chatty sessions aren't re-scanning the store every turn)
18
+ // and on session.deleted (force=true, bypasses cooldown — final cleanup).
19
+ const CONSOLIDATE_COOLDOWN_MS = 30 * 60 * 1000;
20
+ // Task-type detection keywords
21
+ const TASK_TYPE_KEYWORDS = {
22
+ coding: ["code", "function", "class", "implement", "debug", "fix", "refactor", "api", "bug", "error", "test", "寫程式", "程式", "代碼", "函數"],
23
+ documentation: ["doc", "document", "readme", "comment", "guide", "tutorial", "說明", "文檔", "文"],
24
+ review: ["review", "review code", "pull request", "pr", "merge", "審查", "檢視"],
25
+ release: ["release", "publish", "deploy", "version", "build", "npm", "publish", "發布", "版本"],
26
+ general: [],
27
+ };
28
+ /**
29
+ * Detect task type from user message
30
+ */
31
+ function detectTaskType(messages) {
32
+ // Find the last user message
33
+ let userText = "";
34
+ for (let i = messages.length - 1; i >= 0; i -= 1) {
35
+ const msg = messages[i];
36
+ if (msg.info?.role === "user" && msg.parts) {
37
+ userText = msg.parts.filter((p) => p.type === "text").map((p) => p.text).join(" ").toLowerCase();
38
+ break;
39
+ }
40
+ }
41
+ // Score each task type
42
+ const scores = { coding: 0, documentation: 0, review: 0, release: 0, general: 0 };
43
+ for (const [taskType, keywords] of Object.entries(TASK_TYPE_KEYWORDS)) {
44
+ for (const keyword of keywords) {
45
+ if (userText.includes(keyword.toLowerCase())) {
46
+ scores[taskType] += 1;
47
+ }
48
+ }
49
+ }
50
+ // Find the task type with highest score (excluding general)
51
+ let maxScore = 0;
52
+ let detectedType = "general";
53
+ for (const [taskType, score] of Object.entries(scores)) {
54
+ if (taskType !== "general" && score > maxScore) {
55
+ maxScore = score;
56
+ detectedType = taskType;
57
+ }
58
+ }
59
+ return maxScore > 0 ? detectedType : "general";
60
+ }
61
+ /**
62
+ * Get category weights for a specific task type
63
+ */
64
+ function getCategoryWeights(taskType, profiles) {
65
+ return profiles[taskType]?.categoryWeights ?? profiles.general.categoryWeights;
66
+ }
67
+ // Command-sequence heuristics below mirror the tool-name regex already used
68
+ // by store.js's extractSuccessPatternsFromScope (npm|yarn|pnpm|npx|cargo|go|
69
+ // pytest|jest|tsc|eslint|prettier), so only real shell commands (the "bash"
70
+ // tool) are tracked into episodic history — not every tool call.
71
+ const VALIDATION_TYPE_PATTERNS = [
72
+ { type: "type-check", re: /\b(tsc\b|type-?check)/i },
73
+ { type: "test", re: /\b(test|jest|vitest|pytest|mocha|rspec)\b/i },
74
+ { type: "build", re: /\b(build|webpack|rollup|tsup|vite build)\b/i },
75
+ ];
76
+ /**
77
+ * Extract a loggable shell command string from a tool.execute.after input,
78
+ * or null if this tool call isn't a shell command worth tracking in the
79
+ * episode's command history.
80
+ */
81
+ function extractCommandText(toolName, args) {
82
+ if (toolName !== "bash")
83
+ return null;
84
+ const command = typeof args?.command === "string" ? args.command.trim() : "";
85
+ return command.length > 0 ? command.slice(0, 500) : null;
86
+ }
87
+ /**
88
+ * Best-effort detection of a validation outcome (test/build/type-check)
89
+ * from a bash command + its tool output. Uses the tool's metadata exit
90
+ * code when the host exposes one; otherwise falls back to a text heuristic
91
+ * on the captured output. Returns null for commands that aren't validation
92
+ * commands.
93
+ */
94
+ function detectValidationOutcome(command, toolOutput) {
95
+ const match = VALIDATION_TYPE_PATTERNS.find((p) => p.re.test(command));
96
+ if (!match)
97
+ return null;
98
+ const output = typeof toolOutput?.output === "string" ? toolOutput.output : "";
99
+ const metadata = (toolOutput?.metadata ?? {});
100
+ const exitCode = typeof metadata.exit === "number"
101
+ ? metadata.exit
102
+ : typeof metadata.exitCode === "number"
103
+ ? metadata.exitCode
104
+ : undefined;
105
+ let status;
106
+ if (exitCode !== undefined) {
107
+ status = exitCode === 0 ? "pass" : "fail";
108
+ }
109
+ else {
110
+ const failureSignal = /(^|\s)(FAIL|Failed|Error|Exception)\b/.test(output)
111
+ && !/\b0 (failing|failed|errors?)\b/i.test(output);
112
+ status = failureSignal ? "fail" : "pass";
113
+ }
114
+ const errorMatches = output.match(/error TS\d+|✗|FAIL /g);
115
+ return {
116
+ type: match.type,
117
+ status,
118
+ timestamp: Date.now(),
119
+ errorCount: errorMatches ? errorMatches.length : undefined,
120
+ output: output.slice(0, 500),
121
+ };
122
+ }
123
+ const plugin = async (input) => {
124
+ initLogger(input.client);
125
+ const state = await createRuntimeState(input);
126
+ const hooks = {
127
+ config: async (config) => {
128
+ const nextConfig = resolveMemoryConfig(config, input.worktree);
129
+ if (hasEmbeddingConfigChanged(state.config.embedding, nextConfig.embedding)) {
130
+ state.embedder = createEmbedder(nextConfig.embedding);
131
+ state.initialized = false;
132
+ }
133
+ state.config = nextConfig;
134
+ configureLogger(nextConfig.logging ?? {});
135
+ // Startup banner logs after the config hook has armed the file
136
+ // sink (factory-time logging would miss the configured log file);
137
+ // guarded so re-resolutions don't reprint it.
138
+ if (!state.startupLogged) {
139
+ state.startupLogged = true;
140
+ log("info", `Plugin v${PLUGIN_VERSION} initialized`);
141
+ }
142
+ },
143
+ event: async ({ event }) => {
144
+ const evt = event;
145
+ // NOTE: This OpenCode version (>=1.x) does not emit "session.start"/
146
+ // "session.end" events (those never existed in the public event bus).
147
+ // The real lifecycle events are "session.created" and "session.deleted",
148
+ // and their sessionID lives at properties.info.id, not properties.sessionID.
149
+ if (evt.type === "session.created") {
150
+ const sid = evt.properties?.info?.id;
151
+ if (sid && !isOwnSession(sid)) {
152
+ await handleSessionStart(sid, state, input);
153
+ }
154
+ return;
155
+ }
156
+ if (evt.type === "session.error") {
157
+ // Track failures so handleSessionEnd (fired on session.deleted) can
158
+ // report an accurate outcome instead of a hardcoded "unknown".
159
+ // EPISODIC_FAILURE (1.3.0): SDK events carry the error object
160
+ // (ProviderAuthError/UnknownError/MessageAbortedError/ApiError —
161
+ // all expose data.message), so we also keep the raw message and
162
+ // classify it at session end to fill failureType/errorMessage.
163
+ const sid = evt.properties?.sessionID;
164
+ if (sid) {
165
+ const err = evt.properties?.error;
166
+ const message = typeof err?.data?.message === "string"
167
+ ? err.data.message
168
+ : (typeof err?.message === "string" ? err.message : undefined);
169
+ state.sessionErrors.set(sid, message
170
+ ? { failed: true, message }
171
+ : { failed: true });
172
+ // Bound growth in case session.deleted never fires for a
173
+ // given session (e.g. crash) — simple FIFO eviction since
174
+ // Map iteration order is insertion order in JS.
175
+ if (state.sessionErrors.size > 500) {
176
+ const oldestKey = state.sessionErrors.keys().next().value;
177
+ if (oldestKey !== undefined)
178
+ state.sessionErrors.delete(oldestKey);
179
+ }
180
+ }
181
+ return;
182
+ }
183
+ if (evt.type === "session.deleted") {
184
+ const sid = evt.properties?.info?.id;
185
+ if (sid && !isOwnSession(sid)) {
186
+ const entry = state.sessionErrors.get(sid);
187
+ state.sessionErrors.delete(sid);
188
+ const hadError = entry?.failed === true;
189
+ await handleSessionEnd(sid, state, hadError ? "failed" : "success", entry?.message);
190
+ }
191
+ // Session is closing — final dedup pass for its scope. Uses the
192
+ // session's own directory (Session.info.directory) rather than
193
+ // client.session.get, which may 404 after deletion. force=true
194
+ // bypasses the idle cooldown since this is a one-time cleanup.
195
+ const deletedInfo = evt.properties?.info;
196
+ const finalScope = deletedInfo?.directory ? deriveProjectScope(deletedInfo.directory) : state.defaultScope;
197
+ maybeConsolidateDuplicates(state, finalScope, true);
198
+ maybeSweepExpiredMemories(state, finalScope, true);
199
+ return;
200
+ }
201
+ const sessionID = evt.properties?.sessionID;
202
+ if (!sessionID)
203
+ return;
204
+ if (isOwnSession(sessionID))
205
+ return;
206
+ if (evt.type === "session.idle" || evt.type === "session.compacted") {
207
+ await flushAutoCapture(sessionID, state, input.client);
208
+ if (state.config.dedup.enabled) {
209
+ // Use the session's actual directory (not the static plugin-init
210
+ // worktree) since a single opencode server process can host
211
+ // sessions across multiple project directories.
212
+ const activeScope = await resolveSessionScope(sessionID, input.client, state.defaultScope);
213
+ // idle = throttled background pass (cooldown-gated);
214
+ // compacted = explicit compaction, consolidate right away.
215
+ maybeConsolidateDuplicates(state, activeScope, evt.type === "session.compacted");
216
+ maybeSweepExpiredMemories(state, activeScope, evt.type === "session.compacted");
217
+ }
218
+ }
219
+ },
220
+ "experimental.text.complete": async (eventInput, eventOutput) => {
221
+ if (isOwnSession(eventInput.sessionID))
222
+ return;
223
+ const list = state.captureBuffer.get(eventInput.sessionID) ?? [];
224
+ list.push(eventOutput.text);
225
+ state.captureBuffer.set(eventInput.sessionID, list);
226
+ },
227
+ // Wires the episodic learning store (addCommandToEpisode/
228
+ // addValidationOutcome) to real tool executions. Previously these
229
+ // store methods existed but were never called from anywhere, so
230
+ // episodic_tasks rows stayed permanently empty and similar_task_recall/
231
+ // retry_budget_suggest/recovery_strategy_suggest always ran on
232
+ // empty data. Only tracks the "bash" tool (real shell commands),
233
+ // matching the tool-name heuristics already used elsewhere in the
234
+ // store for pattern extraction.
235
+ "tool.execute.after": async (toolInput, toolOutput) => {
236
+ const { tool: toolName, sessionID, args } = toolInput;
237
+ if (!sessionID)
238
+ return;
239
+ const entry = state.activeEpisodes.get(sessionID);
240
+ if (!entry)
241
+ return;
242
+ const command = extractCommandText(toolName, args);
243
+ if (!command)
244
+ return;
245
+ await state.ensureInitialized();
246
+ if (!state.initialized)
247
+ return;
248
+ const { taskId, scope: activeScope } = entry;
249
+ try {
250
+ await state.store.addCommandToEpisode(taskId, activeScope, command);
251
+ }
252
+ catch (error) {
253
+ log("warn", `failed to record command in episode: ${toErrorMessage(error)}`);
254
+ }
255
+ const validation = detectValidationOutcome(command, toolOutput);
256
+ if (validation) {
257
+ try {
258
+ await state.store.addValidationOutcome(taskId, activeScope, validation);
259
+ }
260
+ catch (error) {
261
+ log("warn", `failed to record validation outcome: ${toErrorMessage(error)}`);
262
+ }
263
+ }
264
+ },
265
+ "experimental.chat.system.transform": async (eventInput, eventOutput) => {
266
+ if (!eventInput.sessionID)
267
+ return;
268
+ await state.ensureInitialized();
269
+ if (!state.initialized)
270
+ return;
271
+ const query = await getLastUserText(eventInput.sessionID, input.client);
272
+ if (!query)
273
+ return;
274
+ // Resolve the session's actual directory rather than the static
275
+ // plugin-init worktree, since a single opencode server process can
276
+ // host sessions across multiple project directories (mirrors the
277
+ // fix already applied to flushAutoCapture/handleSessionStart/Idle).
278
+ // Without this, memories captured under the correct per-session
279
+ // scope become permanently invisible to recall whenever this
280
+ // server's static init worktree diverges from the session's
281
+ // actual directory.
282
+ const activeScope = await resolveSessionScope(eventInput.sessionID, input.client, deriveProjectScope(input.worktree));
283
+ const scopes = buildScopeFilter(activeScope, state.config.includeGlobalScope);
284
+ let messages = [];
285
+ try {
286
+ const rawMessages = await input.client.session.messages({ path: { id: eventInput.sessionID } });
287
+ const unwrapped = rawMessages.data;
288
+ if (Array.isArray(unwrapped)) {
289
+ messages = unwrapped;
290
+ }
291
+ }
292
+ catch {
293
+ messages = [];
294
+ }
295
+ const taskType = detectTaskType(messages);
296
+ const profile = state.config.injection.taskTypeProfiles[taskType] ?? state.config.injection.taskTypeProfiles.general;
297
+ const categoryWeights = getCategoryWeights(taskType, state.config.injection.taskTypeProfiles);
298
+ let queryVector = [];
299
+ let embedderFailed = false;
300
+ try {
301
+ queryVector = await state.embedder.embed(query);
302
+ }
303
+ catch (error) {
304
+ embedderFailed = true;
305
+ log("warn", `embedding unavailable during recall: ${toErrorMessage(error)}`);
306
+ queryVector = [];
307
+ }
308
+ const isFallback = embedderFailed || queryVector.length === 0;
309
+ const effectiveVectorWeight = isFallback ? 0 : (state.config.retrieval.mode === "vector" ? 1 : state.config.retrieval.vectorWeight);
310
+ const effectiveBm25Weight = isFallback ? 1 : (state.config.retrieval.mode === "vector" ? 0 : state.config.retrieval.bm25Weight);
311
+ if (isFallback) {
312
+ log("info", "Using BM25-only search (embedder unavailable)");
313
+ }
314
+ const results = await state.store.search({
315
+ query,
316
+ queryVector,
317
+ scopes,
318
+ limit: profile.maxMemories * 2,
319
+ vectorWeight: effectiveVectorWeight,
320
+ bm25Weight: effectiveBm25Weight,
321
+ minScore: Math.max(state.config.retrieval.minScore, state.config.injection.injectionFloor),
322
+ rrfK: state.config.retrieval.rrfK,
323
+ recencyBoost: state.config.retrieval.recencyBoost,
324
+ recencyHalfLifeHours: state.config.retrieval.recencyHalfLifeHours,
325
+ importanceWeight: state.config.retrieval.importanceWeight,
326
+ feedbackWeight: state.config.retrieval.feedbackWeight,
327
+ globalDiscountFactor: state.config.globalDiscountFactor,
328
+ });
329
+ // GRAPH_STORE_PHASE1: entity-co-occurrence boost on top of the
330
+ // hybrid score. Multiplicative, conservative (1 + lambda*strength),
331
+ // and a no-op when the graph is disabled or the query has no
332
+ // extractable entities.
333
+ let graphBoostedResults = results;
334
+ if (state.config.graph?.enabled && state.graph?.enabled) {
335
+ try {
336
+ graphBoostedResults = state.graph.boostResults(query, results, state.config.graph.boostLambda);
337
+ }
338
+ catch (error) {
339
+ log("warn", `graph boost failed: ${toErrorMessage(error)}`);
340
+ }
341
+ }
342
+ const weightedResults = graphBoostedResults.map((r) => {
343
+ const catWeight = categoryWeights[r.record.category] ?? 1.0;
344
+ return { ...r, score: r.score * catWeight };
345
+ }).sort((a, b) => b.score - a.score);
346
+ // GRAPH_STORE_PHASE2B: graph-expansion recall for injection. BFS
347
+ // from the query's entities; reachable memories that did NOT
348
+ // text/vector-match are appended with a graph-origin score below
349
+ // the weakest real match, so they only get injected when the
350
+ // primary recall comes up thin (injectionLimit caps the block).
351
+ const graphExpanded = [];
352
+ if (state.config.graph?.enabled && state.graph?.enabled && state.config.graph.expansionEnabled !== false) {
353
+ try {
354
+ const candidates = state.graph.expandRecall(query, {
355
+ maxHops: state.config.graph.maxHops,
356
+ expansionLimit: state.config.graph.expansionLimit,
357
+ expansionLambda: state.config.graph.expansionLambda,
358
+ });
359
+ if (candidates.length > 0) {
360
+ const expandedRecords = await state.store.findRecordsByIds(candidates.map((c) => c.memoryId), scopes);
361
+ const recordById = new Map(expandedRecords.map((r) => [r.id, r]));
362
+ const existingIds = new Set(weightedResults.map((r) => r.record.id));
363
+ const floorScore = weightedResults.length > 0
364
+ ? Math.min(...weightedResults.map((r) => r.score))
365
+ : Math.max(state.config.retrieval.minScore, state.config.injection.injectionFloor);
366
+ for (const candidate of candidates) {
367
+ const record = recordById.get(candidate.memoryId);
368
+ if (!record || existingIds.has(record.id))
369
+ continue;
370
+ graphExpanded.push({
371
+ record,
372
+ score: floorScore * candidate.scoreFactor,
373
+ vectorScore: 0,
374
+ bm25Score: 0,
375
+ graphBFS: { hops: candidate.hops, relation: candidate.relation, typed: candidate.typed, path: candidate.path },
376
+ });
377
+ }
378
+ }
379
+ }
380
+ catch (error) {
381
+ log("warn", `graph expansion failed: ${toErrorMessage(error)}`);
382
+ }
383
+ }
384
+ const mergedResults = [...weightedResults, ...graphExpanded].sort((a, b) => b.score - a.score);
385
+ // RECENCY_FACTORS (1.2.0): was a display stub (ageHours:0/
386
+ // withinHalfLife:true/decayFactor:1) — kept in sync with the
387
+ // manual-search path in tools/memory.js and store.explainMemory.
388
+ const recencyHalfLifeHours = Math.max(1, state.config.retrieval.recencyHalfLifeHours ?? 72);
389
+ state.lastRecall = {
390
+ timestamp: Date.now(),
391
+ query,
392
+ results: mergedResults.map((r) => {
393
+ const ageHours = (Date.now() - r.record.timestamp) / 3_600_000;
394
+ return {
395
+ memoryId: r.record.id,
396
+ score: r.score,
397
+ factors: {
398
+ relevance: { overall: r.score, vectorScore: r.vectorScore, bm25Score: r.bm25Score },
399
+ recency: { timestamp: r.record.timestamp, ageHours, withinHalfLife: ageHours <= recencyHalfLifeHours, decayFactor: Math.exp(-ageHours / recencyHalfLifeHours) },
400
+ citation: r.record.citationSource ? { source: r.record.citationSource, status: r.record.citationStatus } : undefined,
401
+ importance: r.record.importance,
402
+ scope: { memoryScope: r.record.scope, matchesCurrentScope: r.record.scope === activeScope, isGlobal: r.record.scope === "global" },
403
+ graph: r.graphBFS ? { bfs: { hops: r.graphBFS.hops, relation: r.graphBFS.relation, typed: r.graphBFS.typed } }
404
+ : r.graphBoost ? { boost: r.graphBoost, overlap: r.graphOverlap ?? 0 } : undefined,
405
+ },
406
+ };
407
+ }),
408
+ };
409
+ // Extract preference signals from memories
410
+ const allSignals = results.map((r) => extractPreferenceSignals(r.record)).flat();
411
+ const projectSignals = allSignals.filter((s) => !activeScope.startsWith("global"));
412
+ const globalSignals = allSignals.filter((s) => activeScope.startsWith("global"));
413
+ const projectProfile = aggregatePreferences(projectSignals, "project");
414
+ const globalProfile = aggregatePreferences(globalSignals, "global");
415
+ const effectivePreferences = resolveConflicts(projectProfile.preferences, globalProfile.preferences);
416
+ const preferenceInjection = buildPreferenceInjection(effectivePreferences, {
417
+ mode: state.config.injection.mode === "adaptive" ? "fixed" : state.config.injection.mode,
418
+ maxMemories: profile.maxMemories,
419
+ tokenBudget: 300,
420
+ });
421
+ // Apply injection control with task-type profile
422
+ const injectionConfig = {
423
+ ...state.config.injection,
424
+ maxMemories: profile.maxMemories,
425
+ budgetTokens: profile.budgetTokens,
426
+ summaryTargetChars: profile.summaryTargetChars,
427
+ };
428
+ const injectionLimit = calculateInjectionLimit(mergedResults, injectionConfig);
429
+ const limitedResults = mergedResults.slice(0, injectionLimit);
430
+ await state.store.putEvent({
431
+ id: generateId(),
432
+ type: "recall",
433
+ source: "system-transform",
434
+ scope: activeScope,
435
+ sessionID: eventInput.sessionID,
436
+ timestamp: Date.now(),
437
+ resultCount: limitedResults.length,
438
+ injected: limitedResults.length > 0,
439
+ metadataJson: JSON.stringify({
440
+ source: "system-transform",
441
+ includeGlobalScope: state.config.includeGlobalScope,
442
+ injectionMode: state.config.injection.mode,
443
+ injectionLimit: injectionLimit,
444
+ }),
445
+ });
446
+ if (limitedResults.length === 0)
447
+ return;
448
+ for (const result of limitedResults) {
449
+ state.store.updateMemoryUsage(result.record.id, activeScope, scopes).catch(() => { });
450
+ }
451
+ // Apply summarization if configured
452
+ const summarizationConfig = createSummarizationConfig(state.config.injection);
453
+ const processedResults = limitedResults.map((item) => {
454
+ if (state.config.injection.summarization === "none") {
455
+ return { ...item, text: item.record.text };
456
+ }
457
+ const summarized = summarizeContent(item.record.text, summarizationConfig);
458
+ return { ...item, text: summarized.content };
459
+ });
460
+ const blocks = [];
461
+ if (preferenceInjection) {
462
+ blocks.push(preferenceInjection);
463
+ }
464
+ blocks.push("[Memory Recall - optional historical context]", ...processedResults.map((item, index) => {
465
+ const citationInfo = item.record.citationSource
466
+ ? ` [${item.record.citationSource}|${item.record.citationStatus ?? "pending"}]`
467
+ : "";
468
+ return `${index + 1}. [${item.record.id}]${citationInfo}${item.graphBFS ? ` [graph-bfs: ${item.graphBFS.hops} hop${item.graphBFS.hops === 1 ? "" : "s"}]` : ""} (${item.record.scope}) ${item.text}`;
469
+ }), "Use these as optional hints only; prioritize current user intent and current repo state.");
470
+ // === Similar Task Recall (Episodic Learning) ===
471
+ try {
472
+ const queryVector = await state.embedder.embed(query);
473
+ const similarTasks = await state.store.findSimilarTasks(activeScope, query, 0.85, queryVector);
474
+ if (similarTasks.length > 0) {
475
+ const taskContext = similarTasks.slice(0, 2).map((ep) => {
476
+ const commands = JSON.parse(ep.commandsJson || "[]");
477
+ const outcomes = JSON.parse(ep.validationOutcomesJson || "[]");
478
+ const passed = outcomes.filter((o) => o.status === "pass").length;
479
+ const total = outcomes.length;
480
+ return `Similar task: ${ep.taskId} (${ep.state}) - Commands: ${commands.slice(0, 3).join(" → ")} - Validations: ${passed}/${total} passed`;
481
+ });
482
+ blocks.push("[Similar Task Recall - based on past successful solutions]", ...taskContext, "Consider these approaches for solving the current task.");
483
+ }
484
+ }
485
+ catch (error) {
486
+ log("warn", `similar task recall failed: ${toErrorMessage(error)}`);
487
+ }
488
+ eventOutput.system.push(blocks.join("\n\n"));
489
+ },
490
+ tool: {
491
+ ...createMemoryTools(state),
492
+ ...createFeedbackTools(state),
493
+ ...createEpisodicTools(state),
494
+ },
495
+ };
496
+ return hooks;
497
+ };
498
+ async function createRuntimeState(input) {
499
+ const resolved = resolveMemoryConfig(undefined, input.worktree);
500
+ const embedder = createEmbedder(resolved.embedding);
501
+ const store = new MemoryStore(resolved.dbPath);
502
+ // GRACEFUL_SHUTDOWN: lance runs auto_cleanup_hook in a background tokio
503
+ // task after each commit; exiting without closing the connection cancels
504
+ // it and logs a noisy "task was cancelled" ERROR. Connection close is
505
+ // synchronous, so a process.once("exit") listener is enough.
506
+ process.once("exit", () => {
507
+ try {
508
+ store.close();
509
+ }
510
+ catch (error) {
511
+ log("warn", `[store] shutdown close failed: ${toErrorMessage(error)}`);
512
+ }
513
+ });
514
+ if (resolved.retention) {
515
+ store.setRetentionConfig(resolved.retention);
516
+ }
517
+ const graph = resolved.graph?.enabled ? await createGraphStore(resolved.graph) : null;
518
+ if (graph) {
519
+ try {
520
+ store.attachGraph(graph);
521
+ }
522
+ catch (error) {
523
+ log("warn", `failed to attach graph to store: ${toErrorMessage(error)}`);
524
+ }
525
+ }
526
+ const state = {
527
+ config: resolved,
528
+ embedder,
529
+ store,
530
+ // LLM_CAPTURE (1.1): the opencode SDK client, plumbed into state so
531
+ // tools (digests) and the capture path can open ephemeral sessions.
532
+ client: input.client,
533
+ graph: graph ?? { enabled: false, extract: () => [], boostResults: (_q, r) => r, indexMemory: () => { } },
534
+ defaultScope: deriveProjectScope(input.worktree),
535
+ initialized: false,
536
+ startupLogged: false,
537
+ captureBuffer: new Map(),
538
+ activeEpisodes: new Map(),
539
+ sessionErrors: new Map(),
540
+ lastRecall: null,
541
+ consolidationInProgress: new Map(),
542
+ lastConsolidateAt: 0,
543
+ // MEMORY_RETENTION (1.0): digest-then-hide expiry sweep state — same
544
+ // throttle pattern as consolidation (cooldown-gated, one per scope).
545
+ sweepInProgress: new Map(),
546
+ lastSweepAt: 0,
547
+ ensureInitialized: async () => {
548
+ if (state.initialized)
549
+ return;
550
+ try {
551
+ const dim = await state.embedder.dim();
552
+ await state.store.init(dim);
553
+ state.initialized = true;
554
+ if (state.graph?.enabled) {
555
+ // One-time backfill: index existing memories into the graph
556
+ // so recall boosts work immediately, not only for new captures.
557
+ try {
558
+ const records = await state.store.readByScopes(["global"]);
559
+ state.graph.reindexMemories(records);
560
+ }
561
+ catch (error) {
562
+ log("warn", `graph backfill failed: ${toErrorMessage(error)}`);
563
+ }
564
+ }
565
+ // MEMORY_RETENTION (1.0): one pass at startup so a long-idle
566
+ // store gets its expired memories digested without waiting for
567
+ // the next session.idle event.
568
+ maybeSweepExpiredMemories(state, state.defaultScope, true).catch(() => { });
569
+ }
570
+ catch (error) {
571
+ log("warn", `initialization deferred: ${toErrorMessage(error)}`);
572
+ }
573
+ },
574
+ };
575
+ return state;
576
+ }
577
+ async function getLastUserText(sessionID, client) {
578
+ try {
579
+ const response = await client.session.messages({ path: { id: sessionID } });
580
+ const payload = unwrapData(response);
581
+ if (!Array.isArray(payload))
582
+ return "";
583
+ for (let i = payload.length - 1; i >= 0; i -= 1) {
584
+ const item = payload[i];
585
+ if (item.info?.role !== "user" || !Array.isArray(item.parts))
586
+ continue;
587
+ const textParts = item.parts.filter((part) => part.type === "text" && typeof part.text === "string");
588
+ const text = textParts.map((part) => part.text).join("\n").trim();
589
+ if (text.length > 0)
590
+ return text;
591
+ }
592
+ return "";
593
+ }
594
+ catch {
595
+ return "";
596
+ }
597
+ }
598
+ async function flushAutoCapture(sessionID, state, client) {
599
+ const fragments = state.captureBuffer.get(sessionID) ?? [];
600
+ if (fragments.length === 0) {
601
+ await recordCaptureEvent(state, {
602
+ sessionID,
603
+ scope: state.defaultScope,
604
+ outcome: "skipped",
605
+ skipReason: "empty-buffer",
606
+ text: "",
607
+ });
608
+ return;
609
+ }
610
+ state.captureBuffer.delete(sessionID);
611
+ const combined = fragments.join("\n").trim();
612
+ const activeScope = await resolveSessionScope(sessionID, client, state.defaultScope);
613
+ await state.ensureInitialized();
614
+ if (!state.initialized) {
615
+ return;
616
+ }
617
+ await recordCaptureEvent(state, {
618
+ sessionID,
619
+ scope: activeScope,
620
+ outcome: "considered",
621
+ text: combined,
622
+ });
623
+ // LLM_CAPTURE (1.1): mode "llm" runs structured SDK extraction first.
624
+ // On any failure (provider offline, unparseable reply, empty result) it
625
+ // falls back to the offline heuristics pipeline and records an explicit
626
+ // "llm-fallback" capture event so the degradation is auditable.
627
+ if (state.config.capture?.mode === "llm") {
628
+ let candidates = null;
629
+ try {
630
+ candidates = await requestLLMCapture(client, state.config.capture.llm, combined, sessionID);
631
+ }
632
+ catch (error) {
633
+ log("warn", `[capture] llm extraction failed: ${toErrorMessage(error)}`);
634
+ candidates = null;
635
+ }
636
+ if (candidates && candidates.length > 0) {
637
+ let storedCount = 0;
638
+ let firstId = null;
639
+ for (const cand of candidates) {
640
+ const result = await storeCapturedMemory(state, {
641
+ sessionID,
642
+ scope: activeScope,
643
+ text: truncateText(cand.content, 1200),
644
+ category: cand.type,
645
+ importance: cand.importance,
646
+ source: "llm-capture",
647
+ });
648
+ if (result.id) {
649
+ storedCount += 1;
650
+ if (firstId === null)
651
+ firstId = result.id;
652
+ }
653
+ }
654
+ await recordCaptureEvent(state, {
655
+ sessionID,
656
+ scope: activeScope,
657
+ outcome: storedCount > 0 ? "stored" : "skipped",
658
+ skipReason: storedCount > 0 ? undefined : "llm-no-storable",
659
+ memoryId: firstId,
660
+ text: combined,
661
+ });
662
+ if (storedCount > 0) {
663
+ await state.store.pruneScope(activeScope, state.config.maxEntriesPerScope);
664
+ }
665
+ return;
666
+ }
667
+ await recordCaptureEvent(state, {
668
+ sessionID,
669
+ scope: activeScope,
670
+ outcome: "llm-fallback",
671
+ skipReason: candidates === null ? "llm-unavailable" : "llm-empty-result",
672
+ text: combined,
673
+ });
674
+ }
675
+ const result = extractCaptureCandidate(combined, state.config.minCaptureChars);
676
+ if (!result.candidate) {
677
+ await recordCaptureEvent(state, {
678
+ sessionID,
679
+ scope: activeScope,
680
+ outcome: "skipped",
681
+ skipReason: result.skipReason,
682
+ text: combined,
683
+ });
684
+ return;
685
+ }
686
+ const stored = await storeCapturedMemory(state, {
687
+ sessionID,
688
+ scope: activeScope,
689
+ text: result.candidate.text,
690
+ category: result.candidate.category,
691
+ importance: result.candidate.importance,
692
+ source: "auto-capture",
693
+ });
694
+ if (!stored.id) {
695
+ await recordCaptureEvent(state, {
696
+ sessionID,
697
+ scope: activeScope,
698
+ outcome: "skipped",
699
+ skipReason: stored.skipReason,
700
+ text: combined,
701
+ });
702
+ return;
703
+ }
704
+ await recordCaptureEvent(state, {
705
+ sessionID,
706
+ scope: activeScope,
707
+ outcome: "stored",
708
+ memoryId: stored.id,
709
+ text: result.candidate.text,
710
+ });
711
+ await state.store.pruneScope(activeScope, state.config.maxEntriesPerScope);
712
+ }
713
+ /**
714
+ * Shared capture-store path (used by both heuristics and LLM modes): embed,
715
+ * advisory dedup check, store, graph-index. Returns { id, skipReason } —
716
+ * id null + skipReason when the memory could not be stored (embedding
717
+ * unavailable, empty vector).
718
+ */
719
+ async function storeCapturedMemory(state, opts) {
720
+ let vector = [];
721
+ try {
722
+ vector = await state.embedder.embed(opts.text);
723
+ }
724
+ catch (error) {
725
+ log("warn", `embedding unavailable during auto-capture: ${toErrorMessage(error)}`);
726
+ return { id: null, skipReason: "embedding-unavailable" };
727
+ }
728
+ if (vector.length === 0) {
729
+ log("warn", "auto-capture skipped because embedding vector is empty");
730
+ return { id: null, skipReason: "empty-embedding" };
731
+ }
732
+ let isPotentialDuplicate = false;
733
+ let duplicateOf = null;
734
+ if (state.config.dedup.enabled) {
735
+ const similar = await state.store.search({
736
+ query: opts.text,
737
+ queryVector: vector,
738
+ scopes: [opts.scope],
739
+ limit: 1,
740
+ vectorWeight: 1.0,
741
+ bm25Weight: 0.0,
742
+ minScore: 0.0,
743
+ rrfK: 60,
744
+ recencyBoost: false,
745
+ globalDiscountFactor: 1.0,
746
+ });
747
+ if (similar.length > 0 && similar[0].score >= state.config.dedup.writeThreshold) {
748
+ isPotentialDuplicate = true;
749
+ duplicateOf = similar[0].record.id;
750
+ }
751
+ }
752
+ const memoryId = generateId();
753
+ const now = Date.now();
754
+ const graphEntities = state.config.graph?.enabled && state.graph?.enabled
755
+ ? state.graph.extract(opts.text)
756
+ : [];
757
+ await state.store.put({
758
+ id: memoryId,
759
+ text: opts.text,
760
+ vector,
761
+ category: opts.category,
762
+ scope: opts.scope,
763
+ importance: opts.importance,
764
+ timestamp: now,
765
+ lastRecalled: 0,
766
+ recallCount: 0,
767
+ projectCount: 0,
768
+ schemaVersion: SCHEMA_VERSION,
769
+ embeddingModel: state.config.embedding.model,
770
+ vectorDim: vector.length,
771
+ metadataJson: JSON.stringify({
772
+ source: opts.source ?? "auto-capture",
773
+ sessionID: opts.sessionID,
774
+ isPotentialDuplicate,
775
+ duplicateOf,
776
+ graphEntities: graphEntities.map((e) => e.name),
777
+ }),
778
+ citationSource: opts.source ?? "auto-capture",
779
+ citationTimestamp: now,
780
+ citationStatus: "pending",
781
+ });
782
+ if (state.config.graph?.enabled && state.graph?.enabled) {
783
+ try {
784
+ state.graph.indexMemory(memoryId, opts.text, now);
785
+ }
786
+ catch (error) {
787
+ log("warn", `graph indexMemory failed: ${toErrorMessage(error)}`);
788
+ }
789
+ }
790
+ return { id: memoryId, skipReason: null };
791
+ }
792
+ async function maybeConsolidateDuplicates(state, scope, force = false) {
793
+ if (!state.config.dedup.enabled)
794
+ return;
795
+ if (!state.initialized)
796
+ return;
797
+ if (state.consolidationInProgress.get(scope))
798
+ return;
799
+ if (!force) {
800
+ const elapsed = Date.now() - state.lastConsolidateAt;
801
+ if (elapsed < CONSOLIDATE_COOLDOWN_MS)
802
+ return;
803
+ }
804
+ state.lastConsolidateAt = Date.now();
805
+ state.consolidationInProgress.set(scope, true);
806
+ state.store
807
+ .consolidateDuplicates(scope, state.config.dedup.consolidateThreshold, state.config.dedup.candidateLimit)
808
+ .catch(() => { })
809
+ .finally(() => state.consolidationInProgress.delete(scope));
810
+ }
811
+ // MEMORY_RETENTION (1.0): digest-then-hide expiry sweep, fired alongside
812
+ // consolidation on session.idle/compacted/deleted + once at init. Shares the
813
+ // same 30-min cooldown so chatty sessions don't re-scan the store every turn.
814
+ // Non-destructive by design: expired memories are folded into digests and
815
+ // marked status:"digested", never deleted.
816
+ async function maybeSweepExpiredMemories(state, scope, force = false) {
817
+ if (!state.initialized)
818
+ return;
819
+ if (state.sweepInProgress.get(scope))
820
+ return;
821
+ if (!force) {
822
+ const elapsed = Date.now() - state.lastSweepAt;
823
+ if (elapsed < CONSOLIDATE_COOLDOWN_MS)
824
+ return;
825
+ }
826
+ state.lastSweepAt = Date.now();
827
+ state.sweepInProgress.set(scope, true);
828
+ sweepExpiredMemories(state, { scope })
829
+ .then((result) => {
830
+ if (result.digestsCreated > 0) {
831
+ log("info", `[retention] sweep: ${result.eligible} expired, ${result.digestsCreated} digest(s), ${result.digested} original(s) digested`, { scope });
832
+ }
833
+ })
834
+ .catch((error) => log("warn", `[retention] sweep failed: ${toErrorMessage(error)}`))
835
+ .finally(() => state.sweepInProgress.delete(scope));
836
+ }
837
+ async function recordCaptureEvent(state, input) {
838
+ if (!state.initialized)
839
+ return;
840
+ await state.store.putEvent({
841
+ id: generateId(),
842
+ type: "capture",
843
+ scope: input.scope,
844
+ sessionID: input.sessionID,
845
+ timestamp: Date.now(),
846
+ outcome: input.outcome,
847
+ skipReason: input.skipReason,
848
+ memoryId: input.memoryId,
849
+ text: input.text,
850
+ metadataJson: JSON.stringify({ source: "auto-capture" }),
851
+ });
852
+ }
853
+ async function resolveSessionScope(sessionID, client, fallback) {
854
+ try {
855
+ const response = await client.session.get({ path: { id: sessionID } });
856
+ const payload = unwrapData(response);
857
+ if (payload?.directory && payload.directory.trim().length > 0) {
858
+ return deriveProjectScope(payload.directory);
859
+ }
860
+ }
861
+ catch { }
862
+ return fallback;
863
+ }
864
+ function toErrorMessage(error) {
865
+ return error instanceof Error ? error.message : String(error);
866
+ }
867
+ function unwrapData(value) {
868
+ if (value && typeof value === "object" && "data" in value) {
869
+ return value.data;
870
+ }
871
+ return value;
872
+ }
873
+ async function handleSessionStart(sessionID, state, input) {
874
+ await state.ensureInitialized();
875
+ if (!state.initialized)
876
+ return;
877
+ // Resolve the session's actual directory rather than the static
878
+ // plugin-init worktree, since one opencode server process can host
879
+ // sessions across multiple project directories.
880
+ const activeScope = await resolveSessionScope(sessionID, input.client, deriveProjectScope(input.worktree));
881
+ const taskId = `session-${sessionID.slice(0, 8)}`;
882
+ const episode = {
883
+ id: generateId(),
884
+ sessionId: sessionID,
885
+ scope: activeScope,
886
+ taskId,
887
+ state: "running",
888
+ startTime: Date.now(),
889
+ commandsJson: "[]",
890
+ validationOutcomesJson: "[]",
891
+ successPatternsJson: "[]",
892
+ retryAttemptsJson: "[]",
893
+ recoveryStrategiesJson: "[]",
894
+ metadataJson: "{}",
895
+ };
896
+ await state.store.createTaskEpisode(episode);
897
+ state.activeEpisodes.set(sessionID, { taskId, scope: activeScope });
898
+ }
899
+ async function handleSessionEnd(sessionID, state, outcome, errorMessage) {
900
+ await state.ensureInitialized();
901
+ if (!state.initialized)
902
+ return;
903
+ const entry = state.activeEpisodes.get(sessionID);
904
+ if (!entry)
905
+ return;
906
+ const finalState = outcome === "success" ? "success" : "failed";
907
+ // EPISODIC_FAILURE (1.3.0): when the bus gave us an error message,
908
+ // classify it (syntax/runtime/logic/resource/unknown) and persist it with
909
+ // the raw message. Truncated to match putEvent's 4000-char safety cap.
910
+ let failureType;
911
+ let failureMessage = errorMessage;
912
+ if (finalState === "failed" && typeof failureMessage === "string" && failureMessage.length > 0) {
913
+ failureType = classifyFailure(failureMessage);
914
+ failureMessage = failureMessage.slice(0, 4000);
915
+ }
916
+ await state.store.updateTaskState(entry.taskId, finalState, entry.scope, failureType, failureMessage);
917
+ if (finalState === "success") {
918
+ // Previously this scope-wide pattern extraction ran on every
919
+ // session.idle (i.e. every turn) and its result was discarded via a
920
+ // pointless updateTaskState(taskId, episode.state, ...) no-op that
921
+ // rewrote the episode with its own unchanged state. Moved here to
922
+ // run once, at actual task completion, and persist real patterns.
923
+ await persistSuccessPatterns(entry.taskId, entry.scope, state).catch((error) => {
924
+ log("warn", `failed to persist success patterns: ${toErrorMessage(error)}`);
925
+ });
926
+ }
927
+ state.activeEpisodes.delete(sessionID);
928
+ }
929
+ async function persistSuccessPatterns(taskId, scope, state) {
930
+ const patterns = await state.store.extractSuccessPatternsFromScope(scope);
931
+ if (patterns.length === 0)
932
+ return;
933
+ const episode = await state.store.getTaskEpisode(taskId, scope);
934
+ const existingSignatures = new Set((episode ? JSON.parse(episode.successPatternsJson || "[]") : [])
935
+ .map((p) => p.commands.join("|")));
936
+ const newPatterns = patterns
937
+ .map((p) => p.pattern)
938
+ .filter((p) => !existingSignatures.has(p.commands.join("|")));
939
+ if (newPatterns.length === 0)
940
+ return;
941
+ await state.store.addSuccessPatterns(taskId, scope, newPatterns);
942
+ }
943
+ function unavailableMessage(provider) {
944
+ return `Memory store unavailable (${provider} embedding may be offline). Will retry automatically.`;
945
+ }
946
+ function hasEmbeddingConfigChanged(current, next) {
947
+ return (current.provider !== next.provider
948
+ || current.model !== next.model
949
+ || (current.baseUrl ?? "") !== (next.baseUrl ?? "")
950
+ || (current.apiKey ?? "") !== (next.apiKey ?? "")
951
+ || (current.timeoutMs ?? 0) !== (next.timeoutMs ?? 0));
952
+ }
953
+ export default plugin;