opencode-memory-pro 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +24 -0
- package/README.md +409 -0
- package/dist/config.d.ts +3 -0
- package/dist/config.js +398 -0
- package/dist/embedder.d.ts +26 -0
- package/dist/embedder.js +260 -0
- package/dist/extract.d.ts +4 -0
- package/dist/extract.js +181 -0
- package/dist/graph.js +701 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +953 -0
- package/dist/llm.d.ts +14 -0
- package/dist/llm.js +212 -0
- package/dist/logger.d.ts +9 -0
- package/dist/logger.js +126 -0
- package/dist/ports.d.ts +34 -0
- package/dist/ports.js +129 -0
- package/dist/preference.d.ts +10 -0
- package/dist/preference.js +125 -0
- package/dist/scope.d.ts +2 -0
- package/dist/scope.js +48 -0
- package/dist/store.d.ts +194 -0
- package/dist/store.js +2738 -0
- package/dist/summarize.d.ts +52 -0
- package/dist/summarize.js +350 -0
- package/dist/tools/episodic.d.ts +68 -0
- package/dist/tools/episodic.js +145 -0
- package/dist/tools/feedback.d.ts +51 -0
- package/dist/tools/feedback.js +112 -0
- package/dist/tools/index.d.ts +3 -0
- package/dist/tools/index.js +3 -0
- package/dist/tools/memory.d.ts +293 -0
- package/dist/tools/memory.js +1487 -0
- package/dist/types.d.ts +489 -0
- package/dist/types.js +54 -0
- package/dist/utils.d.ts +18 -0
- package/dist/utils.js +214 -0
- package/package.json +49 -0
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;
|