@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.
@@ -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
@@ -0,0 +1,3 @@
1
+ {
2
+ ".": "0.1.5"
3
+ }
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
- Unified OpenCode plugin for Scroll that handles both prompt capture (for RLM feedback) and inline skill/prompt suggestions.
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
- - Bun runtime (tested with 1.3+)
7
- - `scroll` CLI available in PATH (or set `SCROLL_BIN`)
8
-
9
- ## Setup
10
- 1. Copy the plugin into OpenCode plugins (scoped name retained):
11
- ```bash
12
- mkdir -p ~/.config/opencode/plugin
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
@@ -3,7 +3,7 @@
3
3
  "configVersion": 1,
4
4
  "workspaces": {
5
5
  "": {
6
- "name": "scroll-plugin",
6
+ "name": "sage-plugin",
7
7
  "devDependencies": {
8
8
  "@biomejs/biome": "^1.7.3",
9
9
  },
package/index.js CHANGED
@@ -1,20 +1,20 @@
1
- // Scroll OpenCode plugin: capture + suggest + RLM feedback combined
1
+ // Sage OpenCode plugin: capture + suggest + RLM feedback combined
2
2
  //
3
3
  // Uses the documented OpenCode plugin event handler pattern.
4
- // Spawns scroll commands via the `$` shell helper for portability.
4
+ // Spawns sage commands via the `$` shell helper for portability.
5
5
  // Now includes RLM feedback appending when steering is detected.
6
6
 
7
- export const ScrollPlugin = async ({ client, $, directory }) => {
7
+ export const SagePlugin = async ({ client, $, directory }) => {
8
8
  const CONFIG = {
9
- scrollBin: process.env.SCROLL_BIN || "scroll",
10
- suggestLimit: Number.parseInt(process.env.SCROLL_SUGGEST_LIMIT || "3", 10),
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.SCROLL_SUGGEST_DEBOUNCE_MS || "800",
12
+ process.env.SAGE_SUGGEST_DEBOUNCE_MS || "800",
13
13
  10,
14
14
  ),
15
- provision: (process.env.SCROLL_SUGGEST_PROVISION || "1") === "1",
16
- dryRun: (process.env.SCROLL_PLUGIN_DRY_RUN || "0") === "1",
17
- enableRlmFeedback: (process.env.SCROLL_RLM_FEEDBACK || "1") === "1",
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 execScroll = async (args, env = {}) => {
128
+ const execSage = async (args, env = {}) => {
55
129
  if (CONFIG.dryRun) return "";
56
130
 
57
- const scrollEnv = { ...env, SCROLL_SOURCE: "opencode" };
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.scrollBin, ...args]
136
+ const cmd = [CONFIG.sageBin, ...args]
63
137
  .map((a) => `'${a.replace(/'/g, "'\\''")}'`)
64
138
  .join(" ");
65
- const result = await $({ env: scrollEnv })`${cmd}`;
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.scrollBin, ...args], {
71
- env: { ...process.env, ...scrollEnv },
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(`scroll command failed: ${e.message || e}`);
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 execScroll([
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 scroll suggest", {
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 suggestions = await execScroll(args);
227
- if (!suggestions) return;
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 = prompt;
361
+ lastSuggestion = correlationText;
232
362
  lastSuggestionTimestamp = Date.now();
233
- lastSuggestionPromptKey = parseSuggestionKey(suggestions);
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${suggestions}\n` },
391
+ body: { text: `\n\n${renderedOutput}\n` },
243
392
  });
244
393
  } catch (e) {
245
- await log("warn", "scroll suggest failed", { error: String(e) });
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
- lastSuggestion = null;
295
- lastSuggestionTimestamp = null;
296
- lastSuggestionPromptKey = null;
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 execScroll(["capture", "hook", "prompt"], {
301
- PROMPT: content,
302
- SCROLL_SESSION_ID: currentSessionId ?? "",
303
- SCROLL_MODEL: currentModel ?? "",
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 execScroll(["capture", "hook", "response"], {
334
- CLAUDE_RESPONSE: responseText,
335
- SCROLL_SESSION_ID: info.sessionID ?? currentSessionId ?? "",
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 ScrollPlugin;
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 ScrollPlugin from "./index.js";
2
+ import SagePlugin from "./index.js";
3
3
 
4
- describe("ScrollPlugin", () => {
4
+ describe("SagePlugin", () => {
5
5
  beforeEach(() => {
6
- process.env.SCROLL_PLUGIN_DRY_RUN = "1";
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 ScrollPlugin({
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 ScrollPlugin({ client, $: $mock, directory: "/tmp" });
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 ScrollPlugin({
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 ScrollPlugin({
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 ScrollPlugin({
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 ScrollPlugin({
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 ScrollPlugin({
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 ScrollPlugin({
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 ScrollPlugin({
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 ScrollPlugin({
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 execScroll actually invokes $
340
- process.env.SCROLL_PLUGIN_DRY_RUN = "";
341
- process.env.SCROLL_RLM_FEEDBACK = "1";
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 ScrollPlugin({ client, $: $mock, directory: "/tmp" });
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.SCROLL_PLUGIN_DRY_RUN = "1";
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 ScrollPlugin({
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",
4
- "description": "OpenCode plugin for Scroll: capture + suggest in one module",
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
+ }