@ericrisco/rsc 3.0.1 → 3.0.2

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/manifest.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "3.0.1",
2
+ "version": "3.0.2",
3
3
  "counts": {
4
4
  "skills": 270,
5
5
  "agents": 33,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ericrisco/rsc",
3
- "version": "3.0.1",
3
+ "version": "3.0.2",
4
4
  "description": "A guided harness builder that turns your goal into a minimal, verified AI-agent setup using strict best practices.",
5
5
  "type": "module",
6
6
  "bin": {
package/scripts/doctor.js CHANGED
@@ -256,16 +256,38 @@ export function doctor({ target, home, cwd }) {
256
256
  missingCommands,
257
257
  commandOrphans,
258
258
  commandCollisions: state.commandCollisions || [],
259
+ // #289 — OpenCode 2.x only loads a plugin from a default export with an id and setup(ctx); the
260
+ // V1-only files older versions wrote fail to load and nothing on rsc's side noticed. A plugin
261
+ // without the V2 entry point is a problem, and `sync` rewrites it.
262
+ opencodePluginsOutdated: target === 'opencode' ? opencodePluginsOutdated(root) : [],
259
263
  };
260
264
  // #277 — the three "missing" lists share one shape, { id, path, action }, so whoever builds on top
261
265
  // of doctor reads them the same way; and `healthy` is what the exit code says.
262
266
  for (const [id, e] of Object.entries(state.skills)) {
263
267
  for (const f of e.files) if (!existsSync(f)) report.missing.push({ id, path: f, action: 'Run `npx @ericrisco/rsc sync` to restore this managed skill.' });
264
268
  }
265
- report.healthy = report.hookWired && !report.missing.length && !missingAgents.length && !missingCommands.length;
269
+ report.healthy = report.hookWired && !report.missing.length && !missingAgents.length && !missingCommands.length
270
+ && !report.opencodePluginsOutdated.length;
266
271
  return report;
267
272
  }
268
273
 
274
+ /** rsc's OpenCode plugins that OpenCode 2.x cannot load (no default export with id + setup). */
275
+ export function opencodePluginsOutdated(root) {
276
+ const dir = join(root, '.opencode', 'plugins');
277
+ const out = [];
278
+ for (const name of ['rsc-update.js', 'rsc-knowledge.js', 'rsc-memory.js']) {
279
+ const path = join(dir, name);
280
+ if (!existsSync(path)) continue;
281
+ let body = '';
282
+ try { body = readFileSync(path, 'utf8'); } catch { continue; }
283
+ if (!/:managed/.test(body)) continue; // hand-written: not ours to judge
284
+ if (!/export default\s*\{/.test(body) || !/\bsetup\s*\(/.test(body)) {
285
+ out.push({ path, action: 'OpenCode 2.x cannot load this plugin (V1 API only). Run `npx @ericrisco/rsc@latest sync` to rewrite it.' });
286
+ }
287
+ }
288
+ return out;
289
+ }
290
+
269
291
  // ---------------------------------------------------------------------------------------
270
292
  // Context budget — what the harness costs in tokens before the user has typed anything.
271
293
  //
@@ -83,24 +83,48 @@ const PLUGIN = `// ${PLUGIN_MARKER}
83
83
  // rsc knowledge sync for OpenCode. OpenCode has no prompt event and no channel to the person, so what
84
84
  // sync has to say goes to the model, to be relayed. Cheap on every call: the fetch is throttled and
85
85
  // every notice is said once. Turn it off with \`rsc knowledge-sync off\`.
86
+ // One file for both plugin APIs (issue #289): V1 calls the named export or server(); V2 reads id + setup.
86
87
  import { onRequest, onTurn } from '../../.rsc/knowledge-sync.mjs';
87
88
 
89
+ const local = () => process.env.RSC_REMOTE_AGENT !== '1' && process.env.OPENCODE_REMOTE !== '1';
90
+ const tell = (cwd) => {
91
+ if (!local()) return '';
92
+ try { const said = onRequest(cwd); return said ? \`Cuéntale esto al usuario en una línea:\\n\${said}\` : ''; } catch { return ''; }
93
+ };
94
+ const turn = (cwd) => { if (local()) { try { onTurn(cwd); } catch { /* fail open */ } } };
95
+
88
96
  export const RscKnowledgePlugin = async ({ directory, worktree }) => {
89
97
  const cwd = worktree || directory;
90
- const local = () => process.env.RSC_REMOTE_AGENT !== '1' && process.env.OPENCODE_REMOTE !== '1';
91
98
  return {
92
99
  'experimental.chat.system.transform': async (_input, output) => {
93
- if (!local()) return;
94
- try {
95
- const said = onRequest(cwd);
96
- if (said && Array.isArray(output?.system)) output.system.push(\`Cuéntale esto al usuario en una línea:\\n\${said}\`);
97
- } catch { /* fail open */ }
98
- },
99
- event: async ({ event }) => {
100
- if (event?.type === 'session.idle' && local()) { try { onTurn(cwd); } catch { /* fail open */ } }
100
+ const text = tell(cwd);
101
+ if (text && Array.isArray(output?.system)) output.system.push(text);
101
102
  },
103
+ event: async ({ event }) => { if (event?.type === 'session.idle') turn(cwd); },
102
104
  };
103
105
  };
106
+
107
+ // V2 has no session.idle: a turn ends with session.execution.succeeded (or .failed).
108
+ export default {
109
+ id: 'rsc.knowledge',
110
+ server: RscKnowledgePlugin,
111
+ async setup(ctx) {
112
+ const cwd = ctx.location?.project?.directory || ctx.location?.directory || process.cwd();
113
+ await ctx.session.hook('context', async (event) => {
114
+ const text = tell(cwd);
115
+ if (text && Array.isArray(event?.system)) event.system.push({ type: 'text', text });
116
+ });
117
+ const stop = new AbortController();
118
+ (async () => {
119
+ try {
120
+ for await (const event of ctx.event.subscribe({ signal: stop.signal })) {
121
+ if (event?.type === 'session.execution.succeeded' || event?.type === 'session.execution.failed') turn(cwd);
122
+ }
123
+ } catch { /* unloading, or the stream ended: fail open */ }
124
+ })();
125
+ return () => stop.abort();
126
+ },
127
+ };
104
128
  `;
105
129
 
106
130
  export function knowledgeManagedPaths(target, cwd = process.cwd()) {
@@ -1,4 +1,6 @@
1
1
  // rsc-memory:managed
2
+ // One file for both OpenCode plugin APIs (issue #289). V1 (1.x) calls the named export, or the default
3
+ // export's server(); V2 (2.x) reads the default export's id + setup(ctx) and ignores the rest.
2
4
  import { readFileSync } from 'node:fs';
3
5
  import { join } from 'node:path';
4
6
  import { capture, resume } from '../../.rsc/session-memory-core.mjs';
@@ -15,51 +17,94 @@ function memorySettings(cwd) {
15
17
 
16
18
  function sessionId(input) {
17
19
  return input?.sessionID || input?.session_id || input?.event?.properties?.info?.id
18
- || input?.event?.properties?.sessionID || null;
20
+ || input?.event?.properties?.sessionID || input?.event?.data?.sessionID || null;
19
21
  }
20
22
 
21
- export const RscMemoryPlugin = async ({ directory, worktree }) => {
22
- const cwd = worktree || directory;
23
+ /** What the plugin does, whichever API delivers the moment. `put(text)` adds to the system prompt. */
24
+ function memory(cwd) {
23
25
  const injected = new Set();
24
26
  const local = () => process.env.RSC_REMOTE_AGENT !== '1' && process.env.OPENCODE_REMOTE !== '1';
25
27
  const save = (id, event, editDelta = 0) => {
26
28
  if (!id || !local()) return;
27
29
  try { capture({ cwd, sessionId: id, target: 'opencode', event, editDelta, settings: memorySettings(cwd) }); } catch { /* fail open */ }
28
30
  };
29
-
30
31
  return {
31
- 'experimental.chat.system.transform': async (input, output) => {
32
- const id = sessionId(input);
32
+ save,
33
+ context(id, put) {
33
34
  if (!id || injected.has(id) || !local()) return;
34
35
  injected.add(id);
35
36
  try {
36
- const config = memorySettings(cwd);
37
37
  save(id, 'start');
38
- const result = resume({ cwd, target: 'opencode', settings: config });
39
- if (!result.context || !Array.isArray(output?.system)) return;
40
- if (output.system.length === 0) output.system.push(result.context);
41
- else output.system[0] = `${output.system[0]}\n\n${result.context}`;
38
+ const result = resume({ cwd, target: 'opencode', settings: memorySettings(cwd) });
39
+ if (result.context) put(result.context);
42
40
  } catch { /* fail open */ }
43
41
  },
44
- 'tool.execute.after': async (input) => {
45
- const tool = String(input?.tool || '').toLowerCase();
46
- save(sessionId(input), 'boundary', /write|edit|patch/u.test(tool) ? 1 : 0);
42
+ tool(id, name) {
43
+ save(id, 'boundary', /write|edit|patch/u.test(String(name || '').toLowerCase()) ? 1 : 0);
47
44
  },
48
- 'experimental.session.compacting': async (input, output) => {
49
- const id = sessionId(input);
45
+ compact(id, put) {
50
46
  save(id, 'compact');
51
47
  try {
52
48
  const result = resume({ cwd, target: 'opencode', settings: memorySettings(cwd) });
53
- if (result.context && Array.isArray(output?.context)) output.context.push(result.context);
49
+ if (result.context) put(result.context);
54
50
  } catch { /* fail open */ }
55
51
  },
52
+ };
53
+ }
54
+
55
+ // ---------------------------------------------------------------- OpenCode 1.x (V1 plugin API)
56
+
57
+ export const RscMemoryPlugin = async ({ directory, worktree }) => {
58
+ const m = memory(worktree || directory);
59
+ return {
60
+ 'experimental.chat.system.transform': async (input, output) => {
61
+ if (!Array.isArray(output?.system)) return;
62
+ m.context(sessionId(input), (text) => {
63
+ if (output.system.length === 0) output.system.push(text);
64
+ else output.system[0] = `${output.system[0]}\n\n${text}`;
65
+ });
66
+ },
67
+ 'tool.execute.after': async (input) => m.tool(sessionId(input), input?.tool),
68
+ 'experimental.session.compacting': async (input, output) => {
69
+ m.compact(sessionId(input), (text) => { if (Array.isArray(output?.context)) output.context.push(text); });
70
+ },
56
71
  event: async ({ event }) => {
57
72
  const id = sessionId({ event });
58
- if (event?.type === 'session.created') save(id, 'start');
59
- else if (event?.type === 'file.edited') save(id, 'edit', 1);
60
- else if (event?.type === 'session.compacted') save(id, 'compact');
61
- else if (event?.type === 'session.idle') save(id, 'turn');
62
- else if (event?.type === 'session.deleted') save(id, 'sessionEnd');
73
+ if (event?.type === 'session.created') m.save(id, 'start');
74
+ else if (event?.type === 'file.edited') m.save(id, 'edit', 1);
75
+ else if (event?.type === 'session.compacted') m.save(id, 'compact');
76
+ else if (event?.type === 'session.idle') m.save(id, 'turn');
77
+ else if (event?.type === 'session.deleted') m.save(id, 'sessionEnd');
63
78
  },
64
79
  };
65
80
  };
81
+
82
+ // ---------------------------------------------------------------- OpenCode 2.x (V2 plugin API)
83
+ // Hooks register through the context; the end of a turn is `session.execution.*` (there is no
84
+ // `session.idle`), and every event carries its session in `data.sessionID`.
85
+
86
+ const TURN_END = new Set(['session.execution.succeeded', 'session.execution.failed']);
87
+
88
+ export default {
89
+ id: 'rsc.memory',
90
+ server: RscMemoryPlugin,
91
+ async setup(ctx) {
92
+ const m = memory(ctx.location?.project?.directory || ctx.location?.directory || process.cwd());
93
+ const part = (event) => (text) => { if (Array.isArray(event?.system)) event.system.push({ type: 'text', text }); };
94
+ await ctx.session.hook('context', async (event) => m.context(event?.sessionID, part(event)));
95
+ await ctx.session.hook('compaction', async (event) => m.compact(event?.sessionID, part(event)));
96
+ await ctx.tool.hook('execute.after', async (event) => m.tool(event?.sessionID, event?.tool));
97
+ const stop = new AbortController();
98
+ (async () => {
99
+ try {
100
+ for await (const event of ctx.event.subscribe({ signal: stop.signal })) {
101
+ const id = event?.data?.sessionID;
102
+ if (event?.type === 'session.created') m.save(id, 'start');
103
+ else if (TURN_END.has(event?.type)) m.save(id, 'turn');
104
+ else if (event?.type === 'session.deleted') m.save(id, 'sessionEnd');
105
+ }
106
+ } catch { /* unloading, or the stream ended: fail open */ }
107
+ })();
108
+ return () => stop.abort();
109
+ },
110
+ };
@@ -78,19 +78,34 @@ const PLUGIN = `// ${PLUGIN_MARKER}
78
78
  // rsc update check for OpenCode: checked once per OpenCode run, and the notice goes to the model to be
79
79
  // relayed. It rides on EVERY system prompt of the run, not just the first: OpenCode's first call is
80
80
  // its title generator, which would swallow a once-only notice before the agent ever sees it.
81
+ // One file for both plugin APIs (issue #289): V1 calls the named export or server(); V2 reads id + setup.
81
82
  // Turn auto-update off with .rsc/.no-auto-update.
82
83
  import { updateNotice } from '../../.rsc/auto-update.mjs';
83
84
 
85
+ const NOTE = '\\nMention this only in your first reply of the session.';
86
+ const check = (cwd) => (process.env.RSC_NO_UPDATE_CHECK ? Promise.resolve('') : updateNotice(cwd).catch(() => ''));
87
+
84
88
  export const RscUpdatePlugin = async ({ directory, worktree }) => {
85
- const cwd = worktree || directory;
86
- const pending = process.env.RSC_NO_UPDATE_CHECK ? Promise.resolve('') : updateNotice(cwd).catch(() => '');
89
+ const pending = check(worktree || directory);
87
90
  return {
88
91
  'experimental.chat.system.transform': async (_input, output) => {
89
92
  const said = (await pending).trim();
90
- if (said && Array.isArray(output?.system)) output.system.push(said + '\\nMention this only in your first reply of the session.');
93
+ if (said && Array.isArray(output?.system)) output.system.push(said + NOTE);
91
94
  },
92
95
  };
93
96
  };
97
+
98
+ export default {
99
+ id: 'rsc.update',
100
+ server: RscUpdatePlugin,
101
+ async setup(ctx) {
102
+ const pending = check(ctx.location?.project?.directory || ctx.location?.directory || process.cwd());
103
+ await ctx.session.hook('context', async (event) => {
104
+ const said = (await pending).trim();
105
+ if (said && Array.isArray(event?.system)) event.system.push({ type: 'text', text: said + NOTE });
106
+ });
107
+ },
108
+ };
94
109
  `;
95
110
 
96
111
  export function updateManagedPaths(target, cwd = process.cwd()) {