@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/.github/workflows/release-please.yml +18 -0
- package/.release-please-manifest.json +3 -0
- package/CHANGELOG.md +20 -0
- package/README.md +58 -28
- package/bun.lock +1 -1
- package/index.js +553 -372
- package/index.test.js +467 -425
- package/mcp.integration.test.js +252 -0
- package/package.json +16 -16
- package/release-please-config.json +13 -0
- package/rlm-feedback.e2e.test.js +295 -0
- package/rlm.e2e.test.js +149 -0
- package/test-utils.js +287 -0
package/index.js
CHANGED
|
@@ -1,378 +1,559 @@
|
|
|
1
|
-
//
|
|
1
|
+
// Sage OpenCode plugin: capture + suggest + RLM feedback combined
|
|
2
2
|
//
|
|
3
3
|
// Uses the documented OpenCode plugin event handler pattern.
|
|
4
|
-
// Spawns
|
|
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
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
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
|
|
559
|
+
export default SagePlugin;
|