@sage-protocol/sage-plugin 0.1.4 → 0.1.5
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 +54 -28
- package/bun.lock +1 -1
- package/index.js +241 -38
- package/index.test.js +19 -19
- package/mcp.integration.test.js +129 -0
- package/package.json +2 -2
- package/release-please-config.json +13 -0
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
name: release-please
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
|
|
7
|
+
permissions:
|
|
8
|
+
contents: write
|
|
9
|
+
pull-requests: write
|
|
10
|
+
|
|
11
|
+
jobs:
|
|
12
|
+
release-please:
|
|
13
|
+
runs-on: ubuntu-latest
|
|
14
|
+
steps:
|
|
15
|
+
- uses: googleapis/release-please-action@v4
|
|
16
|
+
with:
|
|
17
|
+
config-file: release-please-config.json
|
|
18
|
+
manifest-file: .release-please-manifest.json
|
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [0.1.5](https://github.com/sage-protocol/sage-plugin/compare/sage-plugin-v0.1.4...sage-plugin-v0.1.5) (2026-02-02)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* adding ci, release please and improvements ([0e303fe](https://github.com/sage-protocol/sage-plugin/commit/0e303fe219a96e83318a3d221b0b403d463e0923))
|
|
9
|
+
* version bump ([9aa7d91](https://github.com/sage-protocol/sage-plugin/commit/9aa7d916f758e9e6be422888b2ad15711e96df19))
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
### Bug Fixes
|
|
13
|
+
|
|
14
|
+
* bug fixes and improvements ([0b88fc9](https://github.com/sage-protocol/sage-plugin/commit/0b88fc97f6fc71f81e8a2ca843922a98a87692ae))
|
|
15
|
+
* fixing opencode plugin bindings for prompt processing and hook behavior ([289949a](https://github.com/sage-protocol/sage-plugin/commit/289949a0701ea4d5ef0b5630eb9d39d734784401))
|
|
16
|
+
* updates ([6f56a0c](https://github.com/sage-protocol/sage-plugin/commit/6f56a0cdfb91b7cb75695490ac4b899d01017951))
|
|
17
|
+
|
|
18
|
+
## Changelog
|
|
19
|
+
|
|
20
|
+
All notable changes to this package are documented here.
|
package/README.md
CHANGED
|
@@ -1,37 +1,63 @@
|
|
|
1
1
|
# Sage Plugin (OpenCode)
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
OpenCode plugin for Sage Protocol. Captures prompt/response pairs for RLM feedback and provides inline skill suggestions during coding sessions.
|
|
4
|
+
|
|
5
|
+
## What It Does
|
|
6
|
+
|
|
7
|
+
- **Prompt Capture** - Silently records prompt/response pairs with session metadata (model, tokens, cost)
|
|
8
|
+
- **Inline Suggestions** - Debounced skill and prompt suggestions injected into the OpenCode TUI
|
|
9
|
+
- **RLM Feedback** - Tracks whether suggestions were accepted, steered, or rejected within a 30-second correlation window
|
|
10
|
+
- **Session Tracking** - Maintains session and model context across streaming responses
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
mkdir -p ~/.config/opencode/plugin/@sage-protocol
|
|
16
|
+
cp -r sage-plugin ~/.config/opencode/plugin/@sage-protocol/
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Add the plugin to your `opencode.json`:
|
|
20
|
+
|
|
21
|
+
```json
|
|
22
|
+
{
|
|
23
|
+
"plugin": ["@sage-protocol/sage-plugin"],
|
|
24
|
+
"mcp": {
|
|
25
|
+
"sage": {
|
|
26
|
+
"type": "local",
|
|
27
|
+
"command": ["sage", "mcp", "start"],
|
|
28
|
+
"enabled": true
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Or run `sage init --opencode` to configure automatically.
|
|
35
|
+
|
|
36
|
+
## Configuration
|
|
37
|
+
|
|
38
|
+
| Variable | Default | Description |
|
|
39
|
+
|----------|---------|-------------|
|
|
40
|
+
| `SAGE_BIN` | `sage` | Path to the sage binary |
|
|
41
|
+
| `SAGE_SUGGEST_LIMIT` | `3` | Max suggestions per request |
|
|
42
|
+
| `SAGE_SUGGEST_DEBOUNCE_MS` | `800` | Debounce delay for TUI suggestions |
|
|
43
|
+
| `SAGE_SUGGEST_PROVISION` | `1` | Set `0` to skip MCP provisioning |
|
|
44
|
+
| `SAGE_RLM_FEEDBACK` | `1` | Set `0` to disable RLM feedback tracking |
|
|
45
|
+
| `SAGE_PLUGIN_DRY_RUN` | `0` | Set `1` to disable spawning sage (for tests) |
|
|
4
46
|
|
|
5
47
|
## Requirements
|
|
6
|
-
|
|
7
|
-
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
mkdir -p ~/.config/opencode/plugin/@sage-protocol
|
|
14
|
-
cp -r sage-plugin ~/.config/opencode/plugin/@sage-protocol/
|
|
15
|
-
```
|
|
16
|
-
2. Ensure `opencode.json` includes the plugin:
|
|
17
|
-
```json
|
|
18
|
-
{
|
|
19
|
-
"plugin": ["@sage-protocol/sage-plugin"],
|
|
20
|
-
"mcp": { "scroll": { "type": "local", "command": ["scroll", "mcp", "start"], "enabled": true } }
|
|
21
|
-
}
|
|
22
|
-
```
|
|
23
|
-
(running `scroll init --opencode` will add this automatically.)
|
|
24
|
-
|
|
25
|
-
## Environment
|
|
26
|
-
- `SCROLL_BIN`: override scroll binary path
|
|
27
|
-
- `SCROLL_SUGGEST_LIMIT`: suggestions per request (default 3)
|
|
28
|
-
- `SCROLL_SUGGEST_DEBOUNCE_MS`: debounce for TUI suggestions (default 800ms)
|
|
29
|
-
- `SCROLL_SUGGEST_PROVISION`: set `0` to skip MCP provisioning
|
|
30
|
-
- `SCROLL_PLUGIN_DRY_RUN`: set `1` to disable spawning scroll (useful for tests)
|
|
31
|
-
|
|
32
|
-
## Dev
|
|
48
|
+
|
|
49
|
+
- Sage CLI on PATH (or set `SAGE_BIN`)
|
|
50
|
+
- Bun v1.3+
|
|
51
|
+
- OpenCode
|
|
52
|
+
|
|
53
|
+
## Development
|
|
54
|
+
|
|
33
55
|
```bash
|
|
34
56
|
bun install
|
|
35
57
|
bun run lint
|
|
36
58
|
bun test
|
|
37
59
|
```
|
|
60
|
+
|
|
61
|
+
## License
|
|
62
|
+
|
|
63
|
+
MIT
|
package/bun.lock
CHANGED
package/index.js
CHANGED
|
@@ -1,20 +1,20 @@
|
|
|
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
|
|
7
|
+
export const SagePlugin = async ({ client, $, directory }) => {
|
|
8
8
|
const CONFIG = {
|
|
9
|
-
|
|
10
|
-
suggestLimit: Number.parseInt(process.env.
|
|
9
|
+
sageBin: process.env.SAGE_BIN || "sage",
|
|
10
|
+
suggestLimit: Number.parseInt(process.env.SAGE_SUGGEST_LIMIT || "3", 10),
|
|
11
11
|
debounceMs: Number.parseInt(
|
|
12
|
-
process.env.
|
|
12
|
+
process.env.SAGE_SUGGEST_DEBOUNCE_MS || "800",
|
|
13
13
|
10,
|
|
14
14
|
),
|
|
15
|
-
provision: (process.env.
|
|
16
|
-
dryRun: (process.env.
|
|
17
|
-
enableRlmFeedback: (process.env.
|
|
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
18
|
};
|
|
19
19
|
|
|
20
20
|
let promptCaptured = false;
|
|
@@ -31,9 +31,83 @@ export const ScrollPlugin = async ({ client, $, directory }) => {
|
|
|
31
31
|
// RLM Feedback tracking
|
|
32
32
|
let lastSuggestion = null;
|
|
33
33
|
let lastSuggestionTimestamp = null;
|
|
34
|
-
let lastSuggestionPromptKey = null;
|
|
34
|
+
let lastSuggestionPromptKey = null; // qualified: library/key
|
|
35
|
+
let lastSuggestionId = null;
|
|
36
|
+
let lastShownPromptKeys = [];
|
|
37
|
+
let lastAcceptedFeedbackSent = false;
|
|
38
|
+
let lastImplicitFeedbackSent = false;
|
|
35
39
|
const SUGGESTION_CORRELATION_WINDOW_MS = 30000; // 30 second window
|
|
36
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
|
+
|
|
37
111
|
const log = async (level, message, extra = {}) => {
|
|
38
112
|
try {
|
|
39
113
|
if (client?.app?.log) {
|
|
@@ -51,24 +125,24 @@ export const ScrollPlugin = async ({ client, $, directory }) => {
|
|
|
51
125
|
}
|
|
52
126
|
};
|
|
53
127
|
|
|
54
|
-
const
|
|
128
|
+
const execSage = async (args, env = {}) => {
|
|
55
129
|
if (CONFIG.dryRun) return "";
|
|
56
130
|
|
|
57
|
-
const
|
|
131
|
+
const sageEnv = { ...env, SAGE_SOURCE: "opencode" };
|
|
58
132
|
|
|
59
133
|
try {
|
|
60
134
|
if ($) {
|
|
61
135
|
// Use OpenCode's $ shell helper for portability
|
|
62
|
-
const cmd = [CONFIG.
|
|
136
|
+
const cmd = [CONFIG.sageBin, ...args]
|
|
63
137
|
.map((a) => `'${a.replace(/'/g, "'\\''")}'`)
|
|
64
138
|
.join(" ");
|
|
65
|
-
const result = await $({ env:
|
|
139
|
+
const result = await $({ env: sageEnv })`${cmd}`;
|
|
66
140
|
return (result?.stdout ?? result ?? "").toString().trim();
|
|
67
141
|
}
|
|
68
142
|
// Fallback to Bun.spawn if $ not available
|
|
69
143
|
if (typeof Bun !== "undefined") {
|
|
70
|
-
const proc = Bun.spawn([CONFIG.
|
|
71
|
-
env: { ...process.env, ...
|
|
144
|
+
const proc = Bun.spawn([CONFIG.sageBin, ...args], {
|
|
145
|
+
env: { ...process.env, ...sageEnv },
|
|
72
146
|
stdout: "pipe",
|
|
73
147
|
stderr: "pipe",
|
|
74
148
|
});
|
|
@@ -77,7 +151,7 @@ export const ScrollPlugin = async ({ client, $, directory }) => {
|
|
|
77
151
|
}
|
|
78
152
|
return "";
|
|
79
153
|
} catch (e) {
|
|
80
|
-
throw new Error(`
|
|
154
|
+
throw new Error(`sage command failed: ${e.message || e}`);
|
|
81
155
|
}
|
|
82
156
|
};
|
|
83
157
|
|
|
@@ -115,7 +189,7 @@ export const ScrollPlugin = async ({ client, $, directory }) => {
|
|
|
115
189
|
feedback: feedbackEntry,
|
|
116
190
|
});
|
|
117
191
|
|
|
118
|
-
const result = await
|
|
192
|
+
const result = await execSage([
|
|
119
193
|
"suggest",
|
|
120
194
|
"feedback",
|
|
121
195
|
promptKey,
|
|
@@ -208,7 +282,7 @@ export const ScrollPlugin = async ({ client, $, directory }) => {
|
|
|
208
282
|
if (current !== runId) return;
|
|
209
283
|
if (prompt === lastInjected) return;
|
|
210
284
|
|
|
211
|
-
await log("debug", "running
|
|
285
|
+
await log("debug", "running sage suggest", {
|
|
212
286
|
cwd: directory,
|
|
213
287
|
prompt_len: prompt.length,
|
|
214
288
|
});
|
|
@@ -218,19 +292,94 @@ export const ScrollPlugin = async ({ client, $, directory }) => {
|
|
|
218
292
|
"suggest",
|
|
219
293
|
"skill",
|
|
220
294
|
prompt,
|
|
295
|
+
"--format",
|
|
296
|
+
"json",
|
|
221
297
|
"--limit",
|
|
222
298
|
CONFIG.suggestLimit.toString(),
|
|
223
299
|
];
|
|
224
300
|
if (CONFIG.provision) args.push("--provision");
|
|
225
301
|
|
|
226
|
-
const
|
|
227
|
-
if (!
|
|
302
|
+
const output = await execSage(args);
|
|
303
|
+
if (!output) return;
|
|
228
304
|
if (current !== runId) return;
|
|
229
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
|
+
|
|
230
360
|
// Store suggestion for correlation tracking
|
|
231
|
-
lastSuggestion =
|
|
361
|
+
lastSuggestion = correlationText;
|
|
232
362
|
lastSuggestionTimestamp = Date.now();
|
|
233
|
-
lastSuggestionPromptKey =
|
|
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
|
+
});
|
|
234
383
|
|
|
235
384
|
await log("debug", "suggestion stored for correlation", {
|
|
236
385
|
key: lastSuggestionPromptKey,
|
|
@@ -239,10 +388,10 @@ export const ScrollPlugin = async ({ client, $, directory }) => {
|
|
|
239
388
|
|
|
240
389
|
lastInjected = prompt;
|
|
241
390
|
await client.tui.appendPrompt({
|
|
242
|
-
body: { text: `\n\n${
|
|
391
|
+
body: { text: `\n\n${renderedOutput}\n` },
|
|
243
392
|
});
|
|
244
393
|
} catch (e) {
|
|
245
|
-
await log("warn", "
|
|
394
|
+
await log("warn", "sage suggest failed", { error: String(e) });
|
|
246
395
|
}
|
|
247
396
|
})();
|
|
248
397
|
}, CONFIG.debounceMs);
|
|
@@ -291,17 +440,30 @@ export const ScrollPlugin = async ({ client, $, directory }) => {
|
|
|
291
440
|
await appendRlmFeedback(correlation.key, feedbackEntry);
|
|
292
441
|
}
|
|
293
442
|
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
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.
|
|
297
460
|
}
|
|
298
461
|
|
|
299
462
|
try {
|
|
300
|
-
await
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
SCROLL_WORKSPACE: directory ?? "",
|
|
463
|
+
await execSage(["capture", "hook", "prompt"], {
|
|
464
|
+
SAGE_SESSION_ID: currentSessionId ?? "",
|
|
465
|
+
SAGE_MODEL: currentModel ?? "",
|
|
466
|
+
SAGE_WORKSPACE: directory ?? "",
|
|
305
467
|
});
|
|
306
468
|
} catch (e) {
|
|
307
469
|
await log("warn", "capture prompt failed", { error: String(e) });
|
|
@@ -329,11 +491,37 @@ export const ScrollPlugin = async ({ client, $, directory }) => {
|
|
|
329
491
|
if (info?.role === "assistant" && promptCaptured) {
|
|
330
492
|
const responseText = assistantParts.join("");
|
|
331
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
|
+
|
|
332
521
|
try {
|
|
333
|
-
await
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
SCROLL_MODEL: info.modelID ?? currentModel ?? "",
|
|
522
|
+
await execSage(["capture", "hook", "response"], {
|
|
523
|
+
SAGE_SESSION_ID: info.sessionID ?? currentSessionId ?? "",
|
|
524
|
+
SAGE_MODEL: info.modelID ?? currentModel ?? "",
|
|
337
525
|
TOKENS_INPUT: String(info.tokens?.input ?? ""),
|
|
338
526
|
TOKENS_OUTPUT: String(info.tokens?.output ?? ""),
|
|
339
527
|
});
|
|
@@ -345,6 +533,21 @@ export const ScrollPlugin = async ({ client, $, directory }) => {
|
|
|
345
533
|
}
|
|
346
534
|
promptCaptured = false;
|
|
347
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
|
+
}
|
|
348
551
|
}
|
|
349
552
|
break;
|
|
350
553
|
}
|
|
@@ -375,4 +578,4 @@ export const ScrollPlugin = async ({ client, $, directory }) => {
|
|
|
375
578
|
};
|
|
376
579
|
};
|
|
377
580
|
|
|
378
|
-
export default
|
|
581
|
+
export default SagePlugin;
|
package/index.test.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { beforeEach, describe, expect, it } from "bun:test";
|
|
2
|
-
import
|
|
2
|
+
import SagePlugin from "./index.js";
|
|
3
3
|
|
|
4
|
-
describe("
|
|
4
|
+
describe("SagePlugin", () => {
|
|
5
5
|
beforeEach(() => {
|
|
6
|
-
process.env.
|
|
6
|
+
process.env.SAGE_PLUGIN_DRY_RUN = "1";
|
|
7
7
|
});
|
|
8
8
|
|
|
9
9
|
const makeClient = () => {
|
|
@@ -42,7 +42,7 @@ describe("ScrollPlugin", () => {
|
|
|
42
42
|
|
|
43
43
|
it("returns event handler and chat.message hook", async () => {
|
|
44
44
|
const { client } = makeClient();
|
|
45
|
-
const plugin = await
|
|
45
|
+
const plugin = await SagePlugin({
|
|
46
46
|
client,
|
|
47
47
|
$: make$(),
|
|
48
48
|
directory: "/tmp",
|
|
@@ -55,7 +55,7 @@ describe("ScrollPlugin", () => {
|
|
|
55
55
|
it("chat.message hook captures prompt with session/model env vars", async () => {
|
|
56
56
|
const { client } = makeClient();
|
|
57
57
|
const $mock = make$();
|
|
58
|
-
const plugin = await
|
|
58
|
+
const plugin = await SagePlugin({ client, $: $mock, directory: "/tmp" });
|
|
59
59
|
|
|
60
60
|
await plugin["chat.message"](
|
|
61
61
|
{
|
|
@@ -71,7 +71,7 @@ describe("ScrollPlugin", () => {
|
|
|
71
71
|
|
|
72
72
|
it("chat.message hook ignores empty parts", async () => {
|
|
73
73
|
const { client } = makeClient();
|
|
74
|
-
const plugin = await
|
|
74
|
+
const plugin = await SagePlugin({
|
|
75
75
|
client,
|
|
76
76
|
$: make$(),
|
|
77
77
|
directory: "/tmp",
|
|
@@ -94,7 +94,7 @@ describe("ScrollPlugin", () => {
|
|
|
94
94
|
|
|
95
95
|
it("message.part.updated accumulates assistant text parts", async () => {
|
|
96
96
|
const { client } = makeClient();
|
|
97
|
-
const plugin = await
|
|
97
|
+
const plugin = await SagePlugin({
|
|
98
98
|
client,
|
|
99
99
|
$: make$(),
|
|
100
100
|
directory: "/tmp",
|
|
@@ -154,7 +154,7 @@ describe("ScrollPlugin", () => {
|
|
|
154
154
|
|
|
155
155
|
it("message.updated ignores non-assistant roles", async () => {
|
|
156
156
|
const { client } = makeClient();
|
|
157
|
-
const plugin = await
|
|
157
|
+
const plugin = await SagePlugin({
|
|
158
158
|
client,
|
|
159
159
|
$: make$(),
|
|
160
160
|
directory: "/tmp",
|
|
@@ -193,7 +193,7 @@ describe("ScrollPlugin", () => {
|
|
|
193
193
|
|
|
194
194
|
it("session.created resets state and tracks session ID", async () => {
|
|
195
195
|
const { client, appLogCalls } = makeClient();
|
|
196
|
-
const plugin = await
|
|
196
|
+
const plugin = await SagePlugin({
|
|
197
197
|
client,
|
|
198
198
|
$: make$(),
|
|
199
199
|
directory: "/tmp",
|
|
@@ -227,7 +227,7 @@ describe("ScrollPlugin", () => {
|
|
|
227
227
|
|
|
228
228
|
it("session.created detects subagent via parentID", async () => {
|
|
229
229
|
const { client, appLogCalls } = makeClient();
|
|
230
|
-
const plugin = await
|
|
230
|
+
const plugin = await SagePlugin({
|
|
231
231
|
client,
|
|
232
232
|
$: make$(),
|
|
233
233
|
directory: "/tmp",
|
|
@@ -246,7 +246,7 @@ describe("ScrollPlugin", () => {
|
|
|
246
246
|
|
|
247
247
|
it("multiple prompt-response cycles work correctly", async () => {
|
|
248
248
|
const { client } = makeClient();
|
|
249
|
-
const plugin = await
|
|
249
|
+
const plugin = await SagePlugin({
|
|
250
250
|
client,
|
|
251
251
|
$: make$(),
|
|
252
252
|
directory: "/tmp",
|
|
@@ -297,7 +297,7 @@ describe("ScrollPlugin", () => {
|
|
|
297
297
|
|
|
298
298
|
it("handles missing/null properties gracefully", async () => {
|
|
299
299
|
const { client } = makeClient();
|
|
300
|
-
const plugin = await
|
|
300
|
+
const plugin = await SagePlugin({
|
|
301
301
|
client,
|
|
302
302
|
$: make$(),
|
|
303
303
|
directory: "/tmp",
|
|
@@ -319,7 +319,7 @@ describe("ScrollPlugin", () => {
|
|
|
319
319
|
|
|
320
320
|
it("schedules suggest on tui.prompt.append", async () => {
|
|
321
321
|
const { client } = makeClient();
|
|
322
|
-
const plugin = await
|
|
322
|
+
const plugin = await SagePlugin({
|
|
323
323
|
client,
|
|
324
324
|
$: make$(),
|
|
325
325
|
directory: "/tmp",
|
|
@@ -336,13 +336,13 @@ describe("ScrollPlugin", () => {
|
|
|
336
336
|
});
|
|
337
337
|
|
|
338
338
|
it("RLM feedback calls 'suggest feedback' (not 'prompts append-feedback')", async () => {
|
|
339
|
-
// Disable dry-run so
|
|
340
|
-
process.env.
|
|
341
|
-
process.env.
|
|
339
|
+
// Disable dry-run so exec sage actually invokes $
|
|
340
|
+
process.env.SAGE_PLUGIN_DRY_RUN = "";
|
|
341
|
+
process.env.SAGE_RLM_FEEDBACK = "1";
|
|
342
342
|
|
|
343
343
|
const { client } = makeClient();
|
|
344
344
|
const $mock = make$();
|
|
345
|
-
const plugin = await
|
|
345
|
+
const plugin = await SagePlugin({ client, $: $mock, directory: "/tmp" });
|
|
346
346
|
|
|
347
347
|
// 1. Capture a prompt
|
|
348
348
|
await plugin["chat.message"](
|
|
@@ -383,12 +383,12 @@ describe("ScrollPlugin", () => {
|
|
|
383
383
|
}
|
|
384
384
|
|
|
385
385
|
// Restore dry-run for other tests
|
|
386
|
-
process.env.
|
|
386
|
+
process.env.SAGE_PLUGIN_DRY_RUN = "1";
|
|
387
387
|
});
|
|
388
388
|
|
|
389
389
|
it("non-text parts in message.part.updated are ignored", async () => {
|
|
390
390
|
const { client } = makeClient();
|
|
391
|
-
const plugin = await
|
|
391
|
+
const plugin = await SagePlugin({
|
|
392
392
|
client,
|
|
393
393
|
$: make$(),
|
|
394
394
|
directory: "/tmp",
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
|
|
3
|
+
function createMcpClient(proc) {
|
|
4
|
+
const decoder = new TextDecoder();
|
|
5
|
+
const encoder = new TextEncoder();
|
|
6
|
+
const pending = new Map();
|
|
7
|
+
|
|
8
|
+
let closed = false;
|
|
9
|
+
let closeErr;
|
|
10
|
+
let buf = "";
|
|
11
|
+
|
|
12
|
+
(async () => {
|
|
13
|
+
try {
|
|
14
|
+
for await (const chunk of proc.stdout) {
|
|
15
|
+
buf += decoder.decode(chunk);
|
|
16
|
+
const lines = buf.split("\n");
|
|
17
|
+
buf = lines.pop() ?? "";
|
|
18
|
+
|
|
19
|
+
for (const line of lines) {
|
|
20
|
+
if (!line.trim()) continue;
|
|
21
|
+
|
|
22
|
+
let msg;
|
|
23
|
+
try {
|
|
24
|
+
msg = JSON.parse(line);
|
|
25
|
+
} catch {
|
|
26
|
+
// Ignore malformed lines (stdout should be JSON-RPC, but be resilient).
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (msg && msg.id != null) {
|
|
31
|
+
const key = String(msg.id);
|
|
32
|
+
const waiter = pending.get(key);
|
|
33
|
+
if (waiter) {
|
|
34
|
+
pending.delete(key);
|
|
35
|
+
if (msg.error)
|
|
36
|
+
waiter.reject(new Error(msg.error.message || "MCP error"));
|
|
37
|
+
else waiter.resolve(msg.result);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
closed = true;
|
|
43
|
+
} catch (e) {
|
|
44
|
+
closed = true;
|
|
45
|
+
closeErr = e;
|
|
46
|
+
} finally {
|
|
47
|
+
// Fail any outstanding requests.
|
|
48
|
+
const stderr = await new Response(proc.stderr).text().catch(() => "");
|
|
49
|
+
for (const { reject } of pending.values()) {
|
|
50
|
+
reject(
|
|
51
|
+
new Error(
|
|
52
|
+
`MCP process ended before response. stderr:\n${stderr || "<empty>"}${closeErr ? `\nstdout reader error: ${closeErr}` : ""}`,
|
|
53
|
+
),
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
pending.clear();
|
|
57
|
+
}
|
|
58
|
+
})();
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
request(method, params) {
|
|
62
|
+
if (closed) {
|
|
63
|
+
throw new Error("MCP client is closed");
|
|
64
|
+
}
|
|
65
|
+
const id = `${Date.now()}-${Math.random()}`;
|
|
66
|
+
proc.stdin.write(
|
|
67
|
+
encoder.encode(
|
|
68
|
+
`${JSON.stringify({ jsonrpc: "2.0", id, method, params })}\n`,
|
|
69
|
+
),
|
|
70
|
+
);
|
|
71
|
+
return new Promise((resolve, reject) => {
|
|
72
|
+
pending.set(String(id), { resolve, reject });
|
|
73
|
+
});
|
|
74
|
+
},
|
|
75
|
+
notify(method, params) {
|
|
76
|
+
if (closed) return;
|
|
77
|
+
proc.stdin.write(
|
|
78
|
+
encoder.encode(
|
|
79
|
+
`${JSON.stringify({ jsonrpc: "2.0", method, params })}\n`,
|
|
80
|
+
),
|
|
81
|
+
);
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
describe("sage-plugin integration: CLI <-> MCP", () => {
|
|
87
|
+
it("sage mcp start initializes and exposes native tools", async () => {
|
|
88
|
+
const sageBin =
|
|
89
|
+
process.env.SAGE_BIN ||
|
|
90
|
+
new URL("../target/debug/sage", import.meta.url).pathname;
|
|
91
|
+
const proc = Bun.spawn([sageBin, "mcp", "start"], {
|
|
92
|
+
stdin: "pipe",
|
|
93
|
+
stdout: "pipe",
|
|
94
|
+
stderr: "pipe",
|
|
95
|
+
env: { ...process.env },
|
|
96
|
+
});
|
|
97
|
+
const client = createMcpClient(proc);
|
|
98
|
+
|
|
99
|
+
try {
|
|
100
|
+
const init = await client.request("initialize", {
|
|
101
|
+
protocolVersion: "2024-11-05",
|
|
102
|
+
capabilities: {},
|
|
103
|
+
clientInfo: { name: "sage-plugin-test", version: "0.0.0" },
|
|
104
|
+
});
|
|
105
|
+
expect(init).toBeTruthy();
|
|
106
|
+
|
|
107
|
+
// MCP handshake
|
|
108
|
+
client.notify("notifications/initialized", {});
|
|
109
|
+
|
|
110
|
+
const toolsList = await client.request("tools/list", {});
|
|
111
|
+
expect(Array.isArray(toolsList?.tools)).toBe(true);
|
|
112
|
+
expect(toolsList.tools.length).toBeGreaterThan(0);
|
|
113
|
+
|
|
114
|
+
const hasProjectContext = toolsList.tools.some(
|
|
115
|
+
(t) => t.name === "get_project_context",
|
|
116
|
+
);
|
|
117
|
+
expect(hasProjectContext).toBe(true);
|
|
118
|
+
|
|
119
|
+
const callRes = await client.request("tools/call", {
|
|
120
|
+
name: "get_project_context",
|
|
121
|
+
arguments: {},
|
|
122
|
+
});
|
|
123
|
+
expect(callRes).toBeTruthy();
|
|
124
|
+
expect(callRes.isError || false).toBe(false);
|
|
125
|
+
} finally {
|
|
126
|
+
proc.kill("SIGTERM");
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
});
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sage-protocol/sage-plugin",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "OpenCode plugin for
|
|
3
|
+
"version": "0.1.5",
|
|
4
|
+
"description": "OpenCode plugin for Sage: capture + suggest in one module",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"license": "MIT",
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json",
|
|
3
|
+
"release-type": "node",
|
|
4
|
+
"include-v-in-tag": true,
|
|
5
|
+
"bump-minor-pre-major": true,
|
|
6
|
+
"bump-patch-for-minor-pre-major": true,
|
|
7
|
+
"packages": {
|
|
8
|
+
".": {
|
|
9
|
+
"package-name": "@sage-protocol/sage-plugin",
|
|
10
|
+
"changelog-path": "CHANGELOG.md"
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
}
|