@sage-protocol/sage-plugin 0.1.4 → 0.1.6

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/index.js CHANGED
@@ -1,378 +1,559 @@
1
- // Scroll OpenCode plugin: capture + suggest + RLM feedback combined
1
+ // Sage OpenCode plugin: capture + suggest + RLM feedback combined
2
2
  //
3
3
  // Uses the documented OpenCode plugin event handler pattern.
4
- // Spawns scroll commands via the `$` shell helper for portability.
4
+ // Spawns sage commands via the `$` shell helper for portability.
5
5
  // Now includes RLM feedback appending when steering is detected.
6
6
 
7
- export const ScrollPlugin = async ({ client, $, directory }) => {
8
- const CONFIG = {
9
- scrollBin: process.env.SCROLL_BIN || "scroll",
10
- suggestLimit: Number.parseInt(process.env.SCROLL_SUGGEST_LIMIT || "3", 10),
11
- debounceMs: Number.parseInt(
12
- process.env.SCROLL_SUGGEST_DEBOUNCE_MS || "800",
13
- 10,
14
- ),
15
- provision: (process.env.SCROLL_SUGGEST_PROVISION || "1") === "1",
16
- dryRun: (process.env.SCROLL_PLUGIN_DRY_RUN || "0") === "1",
17
- enableRlmFeedback: (process.env.SCROLL_RLM_FEEDBACK || "1") === "1",
18
- };
19
-
20
- let promptCaptured = false;
21
- let lastInput = "";
22
- let lastInjected = "";
23
- let timer = null;
24
- let runId = 0;
25
-
26
- // Session/model tracking (populated by chat.message hook or session.created event)
27
- let currentSessionId = null;
28
- let currentModel = null;
29
- let assistantParts = []; // accumulate streaming text parts
30
-
31
- // RLM Feedback tracking
32
- let lastSuggestion = null;
33
- let lastSuggestionTimestamp = null;
34
- let lastSuggestionPromptKey = null;
35
- const SUGGESTION_CORRELATION_WINDOW_MS = 30000; // 30 second window
36
-
37
- const log = async (level, message, extra = {}) => {
38
- try {
39
- if (client?.app?.log) {
40
- await client.app.log({
41
- service: "sage-plugin",
42
- level,
43
- message,
44
- extra,
45
- });
46
- } else {
47
- console.log(`[sage-plugin:${level}]`, message, extra);
48
- }
49
- } catch {
50
- /* logging should never break the plugin */
51
- }
52
- };
53
-
54
- const execScroll = async (args, env = {}) => {
55
- if (CONFIG.dryRun) return "";
56
-
57
- const scrollEnv = { ...env, SCROLL_SOURCE: "opencode" };
58
-
59
- try {
60
- if ($) {
61
- // Use OpenCode's $ shell helper for portability
62
- const cmd = [CONFIG.scrollBin, ...args]
63
- .map((a) => `'${a.replace(/'/g, "'\\''")}'`)
64
- .join(" ");
65
- const result = await $({ env: scrollEnv })`${cmd}`;
66
- return (result?.stdout ?? result ?? "").toString().trim();
67
- }
68
- // Fallback to Bun.spawn if $ not available
69
- if (typeof Bun !== "undefined") {
70
- const proc = Bun.spawn([CONFIG.scrollBin, ...args], {
71
- env: { ...process.env, ...scrollEnv },
72
- stdout: "pipe",
73
- stderr: "pipe",
74
- });
75
- const stdout = await new Response(proc.stdout).text();
76
- return stdout.trim();
77
- }
78
- return "";
79
- } catch (e) {
80
- throw new Error(`scroll command failed: ${e.message || e}`);
81
- }
82
- };
83
-
84
- // Parse suggestion output to extract prompt key
85
- const parseSuggestionKey = (suggestionText) => {
86
- // Look for patterns like "ultrawork-parallel-orchestration" or similar keys
87
- // Format is typically: prompt_name (key: actual-key)
88
- const keyMatch = suggestionText.match(/\(key:\s*([^)]+)\)/);
89
- if (keyMatch) {
90
- return keyMatch[1].trim();
91
- }
92
-
93
- // Try to match standalone keys in the text
94
- const lines = suggestionText.split("\n");
95
- for (const line of lines) {
96
- // Look for common prompt key patterns
97
- const match = line.match(/^\s*[-•*]?\s*([a-z0-9-]+)(?:\s*[-:]\s*|\s*$)/);
98
- if (match?.[1]?.includes("-")) {
99
- return match[1];
100
- }
101
- }
102
-
103
- return null;
104
- };
105
-
106
- // Append RLM feedback to a prompt
107
- const appendRlmFeedback = async (promptKey, feedbackEntry) => {
108
- if (!CONFIG.enableRlmFeedback || !promptKey) {
109
- return false;
110
- }
111
-
112
- try {
113
- await log("debug", "appending RLM feedback", {
114
- promptKey,
115
- feedback: feedbackEntry,
116
- });
117
-
118
- const result = await execScroll([
119
- "suggest",
120
- "feedback",
121
- promptKey,
122
- feedbackEntry,
123
- "--source",
124
- "opencode-plugin",
125
- ]);
126
-
127
- if (result) {
128
- await log("info", "RLM feedback appended", { promptKey });
129
- return true;
130
- }
131
- } catch (e) {
132
- await log("warn", "failed to append RLM feedback", {
133
- promptKey,
134
- error: String(e),
135
- });
136
- }
137
-
138
- return false;
139
- };
140
-
141
- // Analyze prompt correlation with suggestion
142
- const analyzePromptCorrelation = async (userPrompt) => {
143
- if (!lastSuggestion || !lastSuggestionTimestamp) {
144
- return null;
145
- }
146
-
147
- const now = Date.now();
148
- const timeDiff = now - lastSuggestionTimestamp;
149
-
150
- // Outside correlation window
151
- if (timeDiff > SUGGESTION_CORRELATION_WINDOW_MS) {
152
- return null;
153
- }
154
-
155
- const suggestionKey = lastSuggestionPromptKey;
156
- if (!suggestionKey) {
157
- return null;
158
- }
159
-
160
- // Check if user prompt matches or differs from suggestion
161
- const userPromptLower = userPrompt.toLowerCase().trim();
162
- const suggestionLower = lastSuggestion.toLowerCase().trim();
163
-
164
- // Extract keywords from both
165
- const userKeywords = userPromptLower.split(/\s+/);
166
- const suggestionKeywords = suggestionLower.split(/\s+/);
167
-
168
- // Check for significant overlap
169
- const overlap = userKeywords.filter((k) => suggestionKeywords.includes(k));
170
- const overlapRatio =
171
- overlap.length / Math.max(userKeywords.length, suggestionKeywords.length);
172
-
173
- // Determine correlation type
174
- if (overlapRatio > 0.7) {
175
- return { type: "accepted", key: suggestionKey, overlap: overlapRatio };
176
- }
177
- if (overlapRatio > 0.3) {
178
- // Steering - user modified the suggestion
179
- const addedKeywords = userKeywords.filter(
180
- (k) => !suggestionKeywords.includes(k),
181
- );
182
- const removedKeywords = suggestionKeywords.filter(
183
- (k) => !userKeywords.includes(k),
184
- );
185
-
186
- return {
187
- type: "steered",
188
- key: suggestionKey,
189
- overlap: overlapRatio,
190
- added: addedKeywords,
191
- removed: removedKeywords,
192
- };
193
- }
194
- return { type: "rejected", key: suggestionKey, overlap: overlapRatio };
195
- };
196
-
197
- const scheduleSuggest = (text) => {
198
- lastInput = text;
199
- runId += 1;
200
- const current = runId;
201
-
202
- if (timer) clearTimeout(timer);
203
-
204
- timer = setTimeout(() => {
205
- void (async () => {
206
- const prompt = lastInput.trim();
207
- if (!prompt) return;
208
- if (current !== runId) return;
209
- if (prompt === lastInjected) return;
210
-
211
- await log("debug", "running scroll suggest", {
212
- cwd: directory,
213
- prompt_len: prompt.length,
214
- });
215
-
216
- try {
217
- const args = [
218
- "suggest",
219
- "skill",
220
- prompt,
221
- "--limit",
222
- CONFIG.suggestLimit.toString(),
223
- ];
224
- if (CONFIG.provision) args.push("--provision");
225
-
226
- const suggestions = await execScroll(args);
227
- if (!suggestions) return;
228
- if (current !== runId) return;
229
-
230
- // Store suggestion for correlation tracking
231
- lastSuggestion = prompt;
232
- lastSuggestionTimestamp = Date.now();
233
- lastSuggestionPromptKey = parseSuggestionKey(suggestions);
234
-
235
- await log("debug", "suggestion stored for correlation", {
236
- key: lastSuggestionPromptKey,
237
- timestamp: lastSuggestionTimestamp,
238
- });
239
-
240
- lastInjected = prompt;
241
- await client.tui.appendPrompt({
242
- body: { text: `\n\n${suggestions}\n` },
243
- });
244
- } catch (e) {
245
- await log("warn", "scroll suggest failed", { error: String(e) });
246
- }
247
- })();
248
- }, CONFIG.debounceMs);
249
- };
250
-
251
- return {
252
- // Structured hook: reliable way to capture user prompts with model/session info
253
- "chat.message": async (input, output) => {
254
- // input: { sessionID, agent, model: {providerID, modelID}, messageID }
255
- // output: { message: UserMessage, parts: Part[] }
256
- currentSessionId = input?.sessionID ?? currentSessionId;
257
- currentModel = input?.model?.modelID ?? currentModel;
258
-
259
- const textParts = (output?.parts ?? []).filter((p) => p.type === "text");
260
- const content = textParts.map((p) => p.text ?? "").join("\n");
261
- if (!content.trim()) return;
262
-
263
- promptCaptured = true;
264
- assistantParts = [];
265
-
266
- // Analyze correlation with previous suggestion
267
- const correlation = await analyzePromptCorrelation(content);
268
- if (correlation) {
269
- await log("debug", "prompt correlation detected", correlation);
270
-
271
- let feedbackEntry = "";
272
- const date = new Date().toISOString().split("T")[0];
273
-
274
- switch (correlation.type) {
275
- case "accepted":
276
- feedbackEntry = `[${date}] Prompt suggestion accepted (overlap: ${(correlation.overlap * 100).toFixed(0)}%)`;
277
- break;
278
- case "steered": {
279
- const added = correlation.added?.slice(0, 3).join(", ") || "none";
280
- const removed =
281
- correlation.removed?.slice(0, 3).join(", ") || "none";
282
- feedbackEntry = `[${date}] User steered from suggestion - Added keywords: "${added}" - Removed: "${removed}"`;
283
- break;
284
- }
285
- case "rejected":
286
- feedbackEntry = `[${date}] Prompt suggestion rejected (low overlap: ${(correlation.overlap * 100).toFixed(0)}%)`;
287
- break;
288
- }
289
-
290
- if (feedbackEntry) {
291
- await appendRlmFeedback(correlation.key, feedbackEntry);
292
- }
293
-
294
- lastSuggestion = null;
295
- lastSuggestionTimestamp = null;
296
- lastSuggestionPromptKey = null;
297
- }
298
-
299
- try {
300
- await execScroll(["capture", "hook", "prompt"], {
301
- PROMPT: content,
302
- SCROLL_SESSION_ID: currentSessionId ?? "",
303
- SCROLL_MODEL: currentModel ?? "",
304
- SCROLL_WORKSPACE: directory ?? "",
305
- });
306
- } catch (e) {
307
- await log("warn", "capture prompt failed", { error: String(e) });
308
- promptCaptured = false;
309
- }
310
- },
311
-
312
- event: async ({ event }) => {
313
- const { type: eventType, properties } = event;
314
-
315
- switch (eventType) {
316
- case "message.part.updated": {
317
- // OpenCode schema: { part: { id, sessionID, messageID, type, text }, delta? }
318
- const part = properties?.part;
319
- if (part?.type === "text" && promptCaptured) {
320
- // Accumulate assistant text parts during streaming
321
- assistantParts.push(part.text ?? "");
322
- }
323
- break;
324
- }
325
-
326
- case "message.updated": {
327
- // OpenCode schema: { info: { id, sessionID, role, modelID, providerID, cost, tokens: {input, output, reasoning, cache} } }
328
- const info = properties?.info;
329
- if (info?.role === "assistant" && promptCaptured) {
330
- const responseText = assistantParts.join("");
331
- if (responseText.trim()) {
332
- try {
333
- await execScroll(["capture", "hook", "response"], {
334
- CLAUDE_RESPONSE: responseText,
335
- SCROLL_SESSION_ID: info.sessionID ?? currentSessionId ?? "",
336
- SCROLL_MODEL: info.modelID ?? currentModel ?? "",
337
- TOKENS_INPUT: String(info.tokens?.input ?? ""),
338
- TOKENS_OUTPUT: String(info.tokens?.output ?? ""),
339
- });
340
- } catch (e) {
341
- await log("warn", "capture response failed", {
342
- error: String(e),
343
- });
344
- }
345
- }
346
- promptCaptured = false;
347
- assistantParts = [];
348
- }
349
- break;
350
- }
351
-
352
- case "session.created": {
353
- // OpenCode schema: { info: { id, parentID, directory, title, ... } }
354
- const info = properties?.info;
355
- currentSessionId = info?.id ?? null;
356
- promptCaptured = false;
357
- assistantParts = [];
358
- await log("info", "session created", {
359
- sessionId: currentSessionId ?? "unknown",
360
- isSubagent: info?.parentID != null,
361
- cwd: directory,
362
- });
363
- break;
364
- }
365
-
366
- case "tui.prompt.append": {
367
- const text = properties?.text ?? "";
368
- if (text.trim()) {
369
- scheduleSuggest(text);
370
- }
371
- break;
372
- }
373
- }
374
- },
375
- };
7
+ export const SagePlugin = async ({ client, $, directory }) => {
8
+ const CONFIG = {
9
+ sageBin: process.env.SAGE_BIN || "sage",
10
+ suggestLimit: Number.parseInt(process.env.SAGE_SUGGEST_LIMIT || "3", 10),
11
+ debounceMs: Number.parseInt(process.env.SAGE_SUGGEST_DEBOUNCE_MS || "800", 10),
12
+ provision: (process.env.SAGE_SUGGEST_PROVISION || "1") === "1",
13
+ dryRun: (process.env.SAGE_PLUGIN_DRY_RUN || "0") === "1",
14
+ enableRlmFeedback: (process.env.SAGE_RLM_FEEDBACK || "1") === "1",
15
+ };
16
+
17
+ let promptCaptured = false;
18
+ let lastInput = "";
19
+ let lastInjected = "";
20
+ let timer = null;
21
+ let runId = 0;
22
+
23
+ // Session/model tracking (populated by chat.message hook or session.created event)
24
+ let currentSessionId = null;
25
+ let currentModel = null;
26
+ let assistantParts = []; // accumulate streaming text parts
27
+
28
+ // RLM Feedback tracking
29
+ let lastSuggestion = null;
30
+ let lastSuggestionTimestamp = null;
31
+ let lastSuggestionPromptKey = null; // qualified: library/key
32
+ let lastSuggestionId = null;
33
+ let lastShownPromptKeys = [];
34
+ let lastAcceptedFeedbackSent = false;
35
+ let lastImplicitFeedbackSent = false;
36
+ const SUGGESTION_CORRELATION_WINDOW_MS = 30000; // 30 second window
37
+
38
+ const parsePromptKeyMarkers = (text) => {
39
+ // Explicit markers only; no fuzzy matching.
40
+ // Marker format: [[sage:prompt_key=library/key]]
41
+ const re = /\[\[sage:prompt_key=([^\]]+)\]\]/g;
42
+ const keys = new Set();
43
+ for (;;) {
44
+ const m = re.exec(text);
45
+ if (!m) break;
46
+ const key = (m[1] || "").trim();
47
+ if (key) keys.add(key);
48
+ }
49
+ return Array.from(keys);
50
+ };
51
+
52
+ const recordPromptSuggestion = async ({
53
+ suggestionId,
54
+ prompt,
55
+ shownPromptKeys,
56
+ source,
57
+ attributesJson,
58
+ }) => {
59
+ try {
60
+ await execSage([
61
+ "suggest",
62
+ "prompt",
63
+ "capture",
64
+ suggestionId,
65
+ prompt,
66
+ "--source",
67
+ source,
68
+ "--shown",
69
+ ...shownPromptKeys,
70
+ ...(attributesJson ? ["--attributes-json", attributesJson] : []),
71
+ ]);
72
+ return true;
73
+ } catch (e) {
74
+ await log("debug", "prompt suggestion capture failed (daemon may be down)", {
75
+ error: String(e),
76
+ });
77
+ return false;
78
+ }
79
+ };
80
+
81
+ const recordPromptSuggestionFeedback = async ({ suggestionId, events }) => {
82
+ try {
83
+ await execSage([
84
+ "suggest",
85
+ "prompt",
86
+ "feedback",
87
+ suggestionId,
88
+ "--events-json",
89
+ JSON.stringify(events),
90
+ ]);
91
+ return true;
92
+ } catch (e) {
93
+ await log("debug", "prompt suggestion feedback failed (daemon may be down)", {
94
+ error: String(e),
95
+ });
96
+ return false;
97
+ }
98
+ };
99
+
100
+ const log = async (level, message, extra = {}) => {
101
+ try {
102
+ if (client?.app?.log) {
103
+ await client.app.log({
104
+ service: "sage-plugin",
105
+ level,
106
+ message,
107
+ extra,
108
+ });
109
+ } else {
110
+ console.log(`[sage-plugin:${level}]`, message, extra);
111
+ }
112
+ } catch {
113
+ /* logging should never break the plugin */
114
+ }
115
+ };
116
+
117
+ const execSage = async (args, env = {}) => {
118
+ if (CONFIG.dryRun) return "";
119
+
120
+ const sageEnv = { ...env, SAGE_SOURCE: "opencode" };
121
+
122
+ try {
123
+ if ($) {
124
+ // Use OpenCode's $ shell helper for portability
125
+ const cmd = [CONFIG.sageBin, ...args].map((a) => `'${a.replace(/'/g, "'\\''")}'`).join(" ");
126
+ const result = await $({ env: sageEnv })`${cmd}`;
127
+ return (result?.stdout ?? result ?? "").toString().trim();
128
+ }
129
+ // Fallback to Bun.spawn if $ not available
130
+ if (typeof Bun !== "undefined") {
131
+ const proc = Bun.spawn([CONFIG.sageBin, ...args], {
132
+ env: { ...process.env, ...sageEnv },
133
+ stdout: "pipe",
134
+ stderr: "pipe",
135
+ });
136
+ const stdout = await new Response(proc.stdout).text();
137
+ return stdout.trim();
138
+ }
139
+ return "";
140
+ } catch (e) {
141
+ throw new Error(`sage command failed: ${e.message || e}`);
142
+ }
143
+ };
144
+
145
+ // Parse suggestion output to extract prompt key
146
+ const parseSuggestionKey = (suggestionText) => {
147
+ // Look for patterns like "ultrawork-parallel-orchestration" or similar keys
148
+ // Format is typically: prompt_name (key: actual-key)
149
+ const keyMatch = suggestionText.match(/\(key:\s*([^)]+)\)/);
150
+ if (keyMatch) {
151
+ return keyMatch[1].trim();
152
+ }
153
+
154
+ // Try to match standalone keys in the text
155
+ const lines = suggestionText.split("\n");
156
+ for (const line of lines) {
157
+ // Look for common prompt key patterns
158
+ const match = line.match(/^\s*[-•*]?\s*([a-z0-9-]+)(?:\s*[-:]\s*|\s*$)/);
159
+ if (match?.[1]?.includes("-")) {
160
+ return match[1];
161
+ }
162
+ }
163
+
164
+ return null;
165
+ };
166
+
167
+ // Append RLM feedback to a prompt
168
+ const appendRlmFeedback = async (promptKey, feedbackEntry) => {
169
+ if (!CONFIG.enableRlmFeedback || !promptKey) {
170
+ return false;
171
+ }
172
+
173
+ try {
174
+ await log("debug", "appending RLM feedback", {
175
+ promptKey,
176
+ feedback: feedbackEntry,
177
+ });
178
+
179
+ const result = await execSage([
180
+ "suggest",
181
+ "feedback",
182
+ promptKey,
183
+ feedbackEntry,
184
+ "--source",
185
+ "opencode-plugin",
186
+ ]);
187
+
188
+ if (result) {
189
+ await log("info", "RLM feedback appended", { promptKey });
190
+ return true;
191
+ }
192
+ } catch (e) {
193
+ await log("warn", "failed to append RLM feedback", {
194
+ promptKey,
195
+ error: String(e),
196
+ });
197
+ }
198
+
199
+ return false;
200
+ };
201
+
202
+ // Analyze prompt correlation with suggestion
203
+ const analyzePromptCorrelation = async (userPrompt) => {
204
+ if (!lastSuggestion || !lastSuggestionTimestamp) {
205
+ return null;
206
+ }
207
+
208
+ const now = Date.now();
209
+ const timeDiff = now - lastSuggestionTimestamp;
210
+
211
+ // Outside correlation window
212
+ if (timeDiff > SUGGESTION_CORRELATION_WINDOW_MS) {
213
+ return null;
214
+ }
215
+
216
+ const suggestionKey = lastSuggestionPromptKey;
217
+ if (!suggestionKey) {
218
+ return null;
219
+ }
220
+
221
+ // Check if user prompt matches or differs from suggestion
222
+ const userPromptLower = userPrompt.toLowerCase().trim();
223
+ const suggestionLower = lastSuggestion.toLowerCase().trim();
224
+
225
+ // Extract keywords from both
226
+ const userKeywords = userPromptLower.split(/\s+/);
227
+ const suggestionKeywords = suggestionLower.split(/\s+/);
228
+
229
+ // Check for significant overlap
230
+ const overlap = userKeywords.filter((k) => suggestionKeywords.includes(k));
231
+ const overlapRatio = overlap.length / Math.max(userKeywords.length, suggestionKeywords.length);
232
+
233
+ // Determine correlation type
234
+ if (overlapRatio > 0.7) {
235
+ return { type: "accepted", key: suggestionKey, overlap: overlapRatio };
236
+ }
237
+ if (overlapRatio > 0.3) {
238
+ // Steering - user modified the suggestion
239
+ const addedKeywords = userKeywords.filter((k) => !suggestionKeywords.includes(k));
240
+ const removedKeywords = suggestionKeywords.filter((k) => !userKeywords.includes(k));
241
+
242
+ return {
243
+ type: "steered",
244
+ key: suggestionKey,
245
+ overlap: overlapRatio,
246
+ added: addedKeywords,
247
+ removed: removedKeywords,
248
+ };
249
+ }
250
+ return { type: "rejected", key: suggestionKey, overlap: overlapRatio };
251
+ };
252
+
253
+ const scheduleSuggest = (text) => {
254
+ lastInput = text;
255
+ runId += 1;
256
+ const current = runId;
257
+
258
+ if (timer) clearTimeout(timer);
259
+
260
+ timer = setTimeout(() => {
261
+ void (async () => {
262
+ const prompt = lastInput.trim();
263
+ if (!prompt) return;
264
+ if (current !== runId) return;
265
+ if (prompt === lastInjected) return;
266
+
267
+ await log("debug", "running sage suggest", {
268
+ cwd: directory,
269
+ prompt_len: prompt.length,
270
+ });
271
+
272
+ try {
273
+ const args = [
274
+ "suggest",
275
+ "skill",
276
+ prompt,
277
+ "--format",
278
+ "json",
279
+ "--limit",
280
+ CONFIG.suggestLimit.toString(),
281
+ ];
282
+ if (CONFIG.provision) args.push("--provision");
283
+
284
+ const output = await execSage(args);
285
+ if (!output) return;
286
+ if (current !== runId) return;
287
+
288
+ let renderedOutput = "";
289
+ let correlationText = "";
290
+ let primaryKey = null;
291
+ let shownKeys = [];
292
+
293
+ try {
294
+ const json = JSON.parse(output);
295
+ if (json.results && Array.isArray(json.results) && json.results.length > 0) {
296
+ // Extract qualified keys for capture/correlation
297
+ shownKeys = json.results
298
+ .map((r) => (r.library ? `${r.library}/${r.key}` : r.key))
299
+ .filter(Boolean);
300
+ primaryKey = shownKeys[0] || null;
301
+
302
+ // Build correlation text from all results (titles/descriptions/keys)
303
+ // We exclude full content to keep overlap ratio meaningful
304
+ correlationText = json.results
305
+ .map((r) => `${r.name} ${r.description || ""} ${r.key}`)
306
+ .join(" ");
307
+
308
+ // Render output
309
+ renderedOutput = json.results
310
+ .map((r) => {
311
+ const qualifiedKey = r.library ? `${r.library}/${r.key}` : r.key;
312
+ let block = `### ${r.name} (key: ${qualifiedKey})\n`;
313
+ if (r.library) block += `*Library: ${r.library}*\n`;
314
+ if (r.description) block += `${r.description}\n`;
315
+ if (r.content) block += `\n\`\`\`\n${r.content}\n\`\`\`\n`;
316
+ block += `\n<!-- If you use this suggestion, include marker: [[sage:prompt_key=${qualifiedKey}]] -->\n`;
317
+ return block;
318
+ })
319
+ .join("\n---\n\n");
320
+ }
321
+ } catch (e) {
322
+ // Fallback: If JSON parse fails, assume it might be plain text or broken JSON.
323
+ // We treat the raw output as the suggestion.
324
+ renderedOutput = output;
325
+ primaryKey = parseSuggestionKey(output);
326
+ correlationText = output;
327
+ }
328
+
329
+ if (!renderedOutput) return;
330
+
331
+ const suggestionId =
332
+ typeof crypto !== "undefined" && crypto.randomUUID
333
+ ? crypto.randomUUID()
334
+ : `sage-suggest-${Date.now()}-${Math.random().toString(16).slice(2)}`;
335
+
336
+ // Store suggestion for correlation tracking
337
+ lastSuggestion = correlationText;
338
+ lastSuggestionTimestamp = Date.now();
339
+ lastSuggestionPromptKey = primaryKey;
340
+ lastSuggestionId = suggestionId;
341
+ lastShownPromptKeys = shownKeys;
342
+ lastAcceptedFeedbackSent = false;
343
+ lastImplicitFeedbackSent = false;
344
+
345
+ // Capture the suggestion to daemon (best-effort)
346
+ await recordPromptSuggestion({
347
+ suggestionId,
348
+ prompt,
349
+ shownPromptKeys: shownKeys,
350
+ source: "opencode",
351
+ attributesJson: JSON.stringify({
352
+ opencode: {
353
+ sessionId: currentSessionId,
354
+ model: currentModel,
355
+ workspace: directory,
356
+ },
357
+ }),
358
+ });
359
+
360
+ await log("debug", "suggestion stored for correlation", {
361
+ key: lastSuggestionPromptKey,
362
+ timestamp: lastSuggestionTimestamp,
363
+ });
364
+
365
+ lastInjected = prompt;
366
+ await client.tui.appendPrompt({
367
+ body: { text: `\n\n${renderedOutput}\n` },
368
+ });
369
+ } catch (e) {
370
+ await log("warn", "sage suggest failed", { error: String(e) });
371
+ }
372
+ })();
373
+ }, CONFIG.debounceMs);
374
+ };
375
+
376
+ return {
377
+ // Structured hook: reliable way to capture user prompts with model/session info
378
+ "chat.message": async (input, output) => {
379
+ // input: { sessionID, agent, model: {providerID, modelID}, messageID }
380
+ // output: { message: UserMessage, parts: Part[] }
381
+ currentSessionId = input?.sessionID ?? currentSessionId;
382
+ currentModel = input?.model?.modelID ?? currentModel;
383
+
384
+ const textParts = (output?.parts ?? []).filter((p) => p.type === "text");
385
+ const content = textParts.map((p) => p.text ?? "").join("\n");
386
+ if (!content.trim()) return;
387
+
388
+ promptCaptured = true;
389
+ assistantParts = [];
390
+
391
+ // Analyze correlation with previous suggestion
392
+ const correlation = await analyzePromptCorrelation(content);
393
+ if (correlation) {
394
+ await log("debug", "prompt correlation detected", correlation);
395
+
396
+ let feedbackEntry = "";
397
+ const date = new Date().toISOString().split("T")[0];
398
+
399
+ switch (correlation.type) {
400
+ case "accepted":
401
+ feedbackEntry = `[${date}] Prompt suggestion accepted (overlap: ${(correlation.overlap * 100).toFixed(0)}%)`;
402
+ break;
403
+ case "steered": {
404
+ const added = correlation.added?.slice(0, 3).join(", ") || "none";
405
+ const removed = correlation.removed?.slice(0, 3).join(", ") || "none";
406
+ feedbackEntry = `[${date}] User steered from suggestion - Added keywords: "${added}" - Removed: "${removed}"`;
407
+ break;
408
+ }
409
+ case "rejected":
410
+ feedbackEntry = `[${date}] Prompt suggestion rejected (low overlap: ${(correlation.overlap * 100).toFixed(0)}%)`;
411
+ break;
412
+ }
413
+
414
+ if (feedbackEntry) {
415
+ await appendRlmFeedback(correlation.key, feedbackEntry);
416
+ }
417
+
418
+ // Also record prompt-suggestion feedback to daemon (best-effort)
419
+ if (lastSuggestionId && !lastAcceptedFeedbackSent) {
420
+ await recordPromptSuggestionFeedback({
421
+ suggestionId: lastSuggestionId,
422
+ events: [
423
+ {
424
+ kind: correlation.type,
425
+ prompt_key: correlation.key,
426
+ confidence: correlation.overlap,
427
+ features_json: JSON.stringify({ overlap: correlation.overlap }),
428
+ },
429
+ ],
430
+ });
431
+ lastAcceptedFeedbackSent = true;
432
+ }
433
+
434
+ // Keep suggestion state for implicit marker detection on assistant completion.
435
+ }
436
+
437
+ try {
438
+ await execSage(["capture", "hook", "prompt"], {
439
+ // Capture hook expects the prompt via stdin JSON (Claude Code) or env vars.
440
+ // OpenCode plugin uses env vars.
441
+ PROMPT: content,
442
+ SAGE_SESSION_ID: currentSessionId ?? "",
443
+ SAGE_MODEL: currentModel ?? "",
444
+ SAGE_WORKSPACE: directory ?? "",
445
+ });
446
+ } catch (e) {
447
+ await log("warn", "capture prompt failed", { error: String(e) });
448
+ promptCaptured = false;
449
+ }
450
+ },
451
+
452
+ event: async ({ event }) => {
453
+ const { type: eventType, properties } = event;
454
+
455
+ switch (eventType) {
456
+ case "message.part.updated": {
457
+ // OpenCode schema: { part: { id, sessionID, messageID, type, text }, delta? }
458
+ const part = properties?.part;
459
+ if (part?.type === "text" && promptCaptured) {
460
+ // Accumulate assistant text parts during streaming
461
+ assistantParts.push(part.text ?? "");
462
+ }
463
+ break;
464
+ }
465
+
466
+ case "message.updated": {
467
+ // OpenCode schema: { info: { id, sessionID, role, modelID, providerID, cost, tokens: {input, output, reasoning, cache} } }
468
+ const info = properties?.info;
469
+ if (info?.role === "assistant" && promptCaptured) {
470
+ const responseText = assistantParts.join("");
471
+ if (responseText.trim()) {
472
+ // If assistant explicitly marks one suggested prompt key as used, record implicitly_helpful.
473
+ if (
474
+ lastSuggestionId &&
475
+ lastSuggestionTimestamp &&
476
+ !lastImplicitFeedbackSent &&
477
+ Date.now() - lastSuggestionTimestamp <= SUGGESTION_CORRELATION_WINDOW_MS
478
+ ) {
479
+ const marked = parsePromptKeyMarkers(responseText);
480
+ const allowed = new Set(lastShownPromptKeys || []);
481
+ const matched = marked.filter((k) => allowed.has(k));
482
+ if (matched.length === 1) {
483
+ await recordPromptSuggestionFeedback({
484
+ suggestionId: lastSuggestionId,
485
+ events: [
486
+ {
487
+ kind: "implicitly_helpful",
488
+ prompt_key: matched[0],
489
+ confidence: 1.0,
490
+ features_json: JSON.stringify({ marker: true }),
491
+ },
492
+ ],
493
+ });
494
+ lastImplicitFeedbackSent = true;
495
+ }
496
+ }
497
+
498
+ try {
499
+ await execSage(["capture", "hook", "response"], {
500
+ SAGE_SESSION_ID: info.sessionID ?? currentSessionId ?? "",
501
+ SAGE_MODEL: info.modelID ?? currentModel ?? "",
502
+ TOKENS_INPUT: String(info.tokens?.input ?? ""),
503
+ TOKENS_OUTPUT: String(info.tokens?.output ?? ""),
504
+ // Pass the actual response content for capture completion
505
+ SAGE_RESPONSE: responseText,
506
+ });
507
+ } catch (e) {
508
+ await log("warn", "capture response failed", {
509
+ error: String(e),
510
+ });
511
+ }
512
+ }
513
+ promptCaptured = false;
514
+ assistantParts = [];
515
+
516
+ // Clear suggestion tracking once we've had a full assistant completion after it.
517
+ if (
518
+ lastSuggestionTimestamp &&
519
+ Date.now() - lastSuggestionTimestamp > SUGGESTION_CORRELATION_WINDOW_MS
520
+ ) {
521
+ lastSuggestion = null;
522
+ lastSuggestionTimestamp = null;
523
+ lastSuggestionPromptKey = null;
524
+ lastSuggestionId = null;
525
+ lastShownPromptKeys = [];
526
+ lastAcceptedFeedbackSent = false;
527
+ lastImplicitFeedbackSent = false;
528
+ }
529
+ }
530
+ break;
531
+ }
532
+
533
+ case "session.created": {
534
+ // OpenCode schema: { info: { id, parentID, directory, title, ... } }
535
+ const info = properties?.info;
536
+ currentSessionId = info?.id ?? null;
537
+ promptCaptured = false;
538
+ assistantParts = [];
539
+ await log("info", "session created", {
540
+ sessionId: currentSessionId ?? "unknown",
541
+ isSubagent: info?.parentID != null,
542
+ cwd: directory,
543
+ });
544
+ break;
545
+ }
546
+
547
+ case "tui.prompt.append": {
548
+ const text = properties?.text ?? "";
549
+ if (text.trim()) {
550
+ scheduleSuggest(text);
551
+ }
552
+ break;
553
+ }
554
+ }
555
+ },
556
+ };
376
557
  };
377
558
 
378
- export default ScrollPlugin;
559
+ export default SagePlugin;