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