@ericrisco/rsc 1.1.6 → 1.2.1
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/README.md +62 -4
- package/manifest.json +834 -2
- package/package.json +3 -2
- package/schema/ecc-originality.json +16 -0
- package/schema/ecc-originality.u64 +0 -0
- package/scripts/build-manifest.js +43 -3
- package/scripts/doctor.js +47 -1
- package/scripts/install-apply.js +148 -31
- package/scripts/lib/capabilities.js +21 -0
- package/scripts/lib/manifest-file.js +4 -1
- package/scripts/originality-check.js +137 -0
- package/scripts/rsc.js +168 -14
- package/skills/ship/SKILL.md +15 -8
- package/skills/worktrees/SKILL.md +30 -22
- package/targets/agent-catalog.js +220 -0
- package/targets/agent-sources.json +48 -0
- package/targets/agents.js +61 -13
- package/targets/claude.js +3 -0
- package/targets/commands.js +212 -0
- package/targets/memory.js +276 -0
- package/targets/opencode-memory-plugin.js +65 -0
- package/targets/session-memory-adapter.mjs +117 -0
- package/targets/session-memory-core.mjs +477 -0
- package/targets/session-memory.mjs +27 -0
- package/targets/session-start.mjs +31 -0
- package/targets/worktree-reaper.mjs +458 -0
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
|
2
|
+
import { join, dirname } from 'node:path';
|
|
3
|
+
import { agentByName, allAgentNames } from './agents.js';
|
|
4
|
+
|
|
5
|
+
const COMMAND_TARGETS = Object.freeze({
|
|
6
|
+
claude: { dir: '.claude/commands', ext: '.md', format: 'claude', skillsAreCommands: true },
|
|
7
|
+
cursor: { dir: '.cursor/commands', ext: '.md', format: 'plain' },
|
|
8
|
+
gemini: { dir: '.gemini/commands', ext: '.toml', format: 'gemini' },
|
|
9
|
+
opencode: { dir: '.opencode/commands', ext: '.md', format: 'opencode' },
|
|
10
|
+
copilot: { dir: '.github/prompts', ext: '.prompt.md', format: 'copilot' },
|
|
11
|
+
windsurf: { dir: '.windsurf/workflows', ext: '.md', format: 'windsurf' },
|
|
12
|
+
cline: { dir: '.clinerules/workflows', ext: '.md', format: 'plain', invocationSuffix: '.md' },
|
|
13
|
+
roo: { dir: '.roo/commands', ext: '.md', format: 'roo' },
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
export const COMMAND_TARGET_IDS = Object.freeze(Object.keys(COMMAND_TARGETS));
|
|
17
|
+
export const targetHasCommands = (target) => Boolean(COMMAND_TARGETS[target]);
|
|
18
|
+
|
|
19
|
+
const delegate = (kind, backing) => `Delegate this request to the ${backing} ${kind}. Pass the invocation arguments unchanged: {{ARGS}}\nDo not reproduce that ${kind}'s method here; load it and follow its own stopping and verification rules.`;
|
|
20
|
+
|
|
21
|
+
const skillCommand = (name, backing = name) => ({
|
|
22
|
+
name,
|
|
23
|
+
kind: 'skill',
|
|
24
|
+
backing,
|
|
25
|
+
description: `Run the ${backing} skill through a short, non-duplicating entry point.`,
|
|
26
|
+
body: delegate('skill', backing),
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const FIXED_COMMANDS = Object.freeze([
|
|
30
|
+
...['specify', 'clarify', 'plan', 'tasks', 'analyze', 'implement', 'verify', 'review', 'ship', 'debug'].map((name) => skillCommand(name)),
|
|
31
|
+
{
|
|
32
|
+
name: 'build-fix', kind: 'resolver-selector', backing: 'build-resolver',
|
|
33
|
+
description: 'Route a build failure to the installed stack resolver without guessing.',
|
|
34
|
+
body: `Use an installed build-resolver for this failure: {{ARGS}}\nChoose from diagnostic text and affected paths. If more than one remains plausible, list the candidates and ask the user; never dispatch at random.`,
|
|
35
|
+
},
|
|
36
|
+
skillCommand('refactor-clean', 'simplify-code'),
|
|
37
|
+
{
|
|
38
|
+
name: 'test-coverage', kind: 'testing-skill', backing: 'testing-*',
|
|
39
|
+
description: 'Delegate coverage work to the installed stack testing skill.',
|
|
40
|
+
body: delegate('skill', 'testing-*'),
|
|
41
|
+
},
|
|
42
|
+
skillCommand('update-docs', 'knowledge-ops'),
|
|
43
|
+
{
|
|
44
|
+
name: 'checkpoint', kind: 'capability', backing: 'sello',
|
|
45
|
+
description: 'Freeze the current candidate for review and seal it only after the required lenses approve.',
|
|
46
|
+
body: `Use the sello backing for this checkpoint: {{ARGS}}\nRun \`npx @ericrisco/rsc sello freeze\`, complete the required review, then run \`sello approve\` only with the observed lenses. Do not treat freeze as approval.`,
|
|
47
|
+
},
|
|
48
|
+
skillCommand('harness-audit', 'harness'),
|
|
49
|
+
skillCommand('security-scan', 'security-scan'),
|
|
50
|
+
{
|
|
51
|
+
name: 'learn', kind: 'memory', backing: 'memory:learn',
|
|
52
|
+
description: 'Propose one local lesson; saving still requires individual explicit approval.',
|
|
53
|
+
body: `Invoke memory:learn for exactly one proposed lesson: {{ARGS}}\nAfter individual explicit approval, run \`npx @ericrisco/rsc memory learn\` with its text, evidence, scope, confidence and \`--approve\`. Without that approval, write nothing.`,
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
name: 'save-session', kind: 'memory', backing: 'memory:save',
|
|
57
|
+
description: 'Force a local deterministic session checkpoint.',
|
|
58
|
+
body: `Invoke memory:save with the current local session identifiers: {{ARGS}}\nRun \`npx @ericrisco/rsc memory save --session <id>\`. Persist only allowed git and SDD ledger metadata; never include conversation or file content.`,
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
name: 'resume-session', kind: 'memory', backing: 'memory:resume',
|
|
62
|
+
description: 'Read the bounded local continuation record for this branch and worktree.',
|
|
63
|
+
body: `Invoke memory:resume for the current branch and worktree: {{ARGS}}\nRun \`npx @ericrisco/rsc memory resume\`. Label a nearby branch result as nearby; never merge it silently into the exact continuation.`,
|
|
64
|
+
},
|
|
65
|
+
]);
|
|
66
|
+
|
|
67
|
+
export const fixedCommands = () => FIXED_COMMANDS.map((command) => ({ ...command }));
|
|
68
|
+
export const fixedCommandNames = () => FIXED_COMMANDS.map((command) => command.name);
|
|
69
|
+
|
|
70
|
+
function withBacking(command, backing, body) {
|
|
71
|
+
return { ...command, backing, body: body || command.body.replace(command.backing, backing) };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function fixedFor({ target, skills, agents, memoryMode }) {
|
|
75
|
+
const spec = COMMAND_TARGETS[target];
|
|
76
|
+
if (!spec) return [];
|
|
77
|
+
const skillSet = new Set(skills);
|
|
78
|
+
const resolverIds = agents.filter((id) => agentByName(id)?.role === 'build-resolver');
|
|
79
|
+
const testing = skills.filter((id) => id.startsWith('testing-')).sort();
|
|
80
|
+
const out = [];
|
|
81
|
+
for (const command of FIXED_COMMANDS) {
|
|
82
|
+
if (command.kind === 'skill') {
|
|
83
|
+
if (!skillSet.has(command.backing) || spec.skillsAreCommands) continue;
|
|
84
|
+
out.push({ ...command });
|
|
85
|
+
} else if (command.kind === 'testing-skill') {
|
|
86
|
+
if (!testing.length || spec.skillsAreCommands) continue;
|
|
87
|
+
out.push(withBacking(command, testing[0]));
|
|
88
|
+
} else if (command.kind === 'resolver-selector') {
|
|
89
|
+
if (!resolverIds.length) continue;
|
|
90
|
+
const backing = resolverIds.length === 1 ? resolverIds[0] : 'build-resolver';
|
|
91
|
+
const candidates = resolverIds.join(', ');
|
|
92
|
+
out.push(withBacking(command, backing,
|
|
93
|
+
`Choose the matching installed build-resolver from: ${candidates}. Pass the failure and invocation arguments unchanged: {{ARGS}}\nUse diagnostics and affected paths. If the choice is ambiguous, list candidates and ask; never dispatch at random.`));
|
|
94
|
+
} else if (command.kind === 'memory') {
|
|
95
|
+
if (!['full', 'assisted'].includes(memoryMode)) continue;
|
|
96
|
+
out.push({ ...command });
|
|
97
|
+
} else {
|
|
98
|
+
out.push({ ...command });
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return out;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function stackAliases({ skills, agents }) {
|
|
105
|
+
const skillSet = new Set(skills);
|
|
106
|
+
const out = [];
|
|
107
|
+
for (const id of agents) {
|
|
108
|
+
const agent = agentByName(id);
|
|
109
|
+
if (!agent || !['reviewer', 'build-resolver'].includes(agent.role) || !agent.skills?.length) continue;
|
|
110
|
+
const installedAliases = agent.skills.filter((skill) => skillSet.has(skill));
|
|
111
|
+
const aliases = installedAliases.length ? installedAliases : [agent.skills[0]];
|
|
112
|
+
const suffix = agent.role === 'reviewer' ? 'review' : 'build';
|
|
113
|
+
for (const skill of aliases) {
|
|
114
|
+
out.push({
|
|
115
|
+
name: `${skill}-${suffix}`,
|
|
116
|
+
kind: 'agent',
|
|
117
|
+
backing: id,
|
|
118
|
+
description: `Delegate ${skill} ${suffix} work to the installed ${id} agent.`,
|
|
119
|
+
body: delegate('agent', id),
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return out;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function resolveCommands({ target, skills = [], agents = [], memoryMode = 'unsupported' }) {
|
|
127
|
+
if (!targetHasCommands(target)) return [];
|
|
128
|
+
const byName = new Map();
|
|
129
|
+
for (const command of [...fixedFor({ target, skills, agents, memoryMode }), ...stackAliases({ skills, agents })]) {
|
|
130
|
+
byName.set(command.name, command);
|
|
131
|
+
}
|
|
132
|
+
return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function validateCommandCatalog(definitions = FIXED_COMMANDS) {
|
|
136
|
+
const errors = [];
|
|
137
|
+
const seen = new Set();
|
|
138
|
+
for (const command of definitions) {
|
|
139
|
+
const prefix = command?.name || '<unnamed>';
|
|
140
|
+
if (!command?.name || seen.has(command.name)) errors.push(`${prefix}: unique name required`);
|
|
141
|
+
seen.add(command?.name);
|
|
142
|
+
if (!command?.backing || !command?.body?.includes(command.backing)) errors.push(`${prefix}: body must name its exact backing`);
|
|
143
|
+
if ((command?.body || '').split('\n').length > 40) errors.push(`${prefix}: body exceeds 40 lines`);
|
|
144
|
+
if (/everything[- ]claude|affaan|worldflow/iu.test(`${command?.description || ''}\n${command?.body || ''}`)) errors.push(`${prefix}: foreign project vocabulary`);
|
|
145
|
+
}
|
|
146
|
+
return errors;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function commandPath(target, cwd, name) {
|
|
150
|
+
const spec = COMMAND_TARGETS[target];
|
|
151
|
+
return spec ? join(cwd, ...spec.dir.split('/'), `${name}${spec.ext}`) : null;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function renderArgs(target, body) {
|
|
155
|
+
const placeholder = target === 'claude' || target === 'opencode' ? '$ARGUMENTS'
|
|
156
|
+
: target === 'gemini' ? '{{args}}'
|
|
157
|
+
: 'any text following the slash command';
|
|
158
|
+
return body.replaceAll('{{ARGS}}', placeholder);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function markdown(command, target) {
|
|
162
|
+
const body = renderArgs(target, command.body);
|
|
163
|
+
if (target === 'cursor' || target === 'cline') return `# /${command.name}\n\n${command.description}\n\n${body}\n`;
|
|
164
|
+
if (target === 'windsurf') return `# /${command.name}\n\n${command.description}\n\n1. ${body.replaceAll('\n', '\n2. ')}\n`;
|
|
165
|
+
const fm = ['---', `description: "${command.description.replaceAll('"', '\\"')}"`, 'argument-hint: "[arguments]"'];
|
|
166
|
+
if (target === 'copilot') fm.push('mode: agent');
|
|
167
|
+
if (target === 'opencode' && command.kind === 'agent') fm.push(`agent: ${command.backing}`);
|
|
168
|
+
fm.push('---', '');
|
|
169
|
+
return `${fm.join('\n')}${body}\n`;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function render(command, target) {
|
|
173
|
+
if (target !== 'gemini') return markdown(command, target);
|
|
174
|
+
const body = renderArgs(target, command.body);
|
|
175
|
+
return `description = ${JSON.stringify(command.description)}\nprompt = ${JSON.stringify(body)}\n`;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function reconcileCommands(target, cwd, previousNames = [], desiredCommands = []) {
|
|
179
|
+
if (!targetHasCommands(target)) return { written: [], removed: [], collisions: [], names: [] };
|
|
180
|
+
const previous = new Set(previousNames);
|
|
181
|
+
const desiredNames = new Set(desiredCommands.map((command) => command.name));
|
|
182
|
+
const removed = [];
|
|
183
|
+
for (const name of previous) {
|
|
184
|
+
if (desiredNames.has(name)) continue;
|
|
185
|
+
const path = commandPath(target, cwd, name);
|
|
186
|
+
if (path && existsSync(path)) { rmSync(path, { force: true }); removed.push(path); }
|
|
187
|
+
}
|
|
188
|
+
const written = [];
|
|
189
|
+
const collisions = [];
|
|
190
|
+
const names = [];
|
|
191
|
+
for (const command of desiredCommands) {
|
|
192
|
+
const path = commandPath(target, cwd, command.name);
|
|
193
|
+
if (existsSync(path) && !previous.has(command.name)) {
|
|
194
|
+
collisions.push(path);
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
198
|
+
writeFileSync(path, render(command, target));
|
|
199
|
+
written.push(path);
|
|
200
|
+
names.push(command.name);
|
|
201
|
+
}
|
|
202
|
+
return { written, removed, collisions, names };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export function allPotentialCommandNames() {
|
|
206
|
+
const names = new Set(fixedCommandNames());
|
|
207
|
+
// Every stack alias follows directly from catalog metadata; no second mapping.
|
|
208
|
+
const agents = allAgentNames();
|
|
209
|
+
const skills = [...new Set(agents.flatMap((id) => agentByName(id)?.skills || []))];
|
|
210
|
+
for (const command of stackAliases({ skills, agents })) names.add(command.name);
|
|
211
|
+
return [...names];
|
|
212
|
+
}
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import {
|
|
2
|
+
appendFileSync, copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync,
|
|
3
|
+
} from 'node:fs';
|
|
4
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
5
|
+
import { execFileSync } from 'node:child_process';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
export { handleLifecycle, contextFromNativeOutput } from './session-memory-adapter.mjs';
|
|
8
|
+
|
|
9
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
10
|
+
const NEEDLE = '.rsc/session-memory-adapter.mjs';
|
|
11
|
+
const PLUGIN_MARKER = 'rsc-memory:managed';
|
|
12
|
+
|
|
13
|
+
export const MEMORY_TARGETS = Object.freeze({
|
|
14
|
+
claude: 'full',
|
|
15
|
+
codex: 'full',
|
|
16
|
+
// Cursor documents sessionStart as fire-and-forget. Its best-effort hook is useful,
|
|
17
|
+
// but only the always-on rule can make the read-before-action obligation explicit.
|
|
18
|
+
cursor: 'assisted',
|
|
19
|
+
gemini: 'full',
|
|
20
|
+
opencode: 'full',
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const CONFIG = Object.freeze({
|
|
24
|
+
claude: '.claude/settings.local.json',
|
|
25
|
+
codex: '.codex/hooks.json',
|
|
26
|
+
cursor: '.cursor/hooks.json',
|
|
27
|
+
gemini: '.gemini/settings.json',
|
|
28
|
+
opencode: '.opencode/plugins/rsc-memory.js',
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
export const memoryModeFor = (target) => MEMORY_TARGETS[target] || 'unsupported';
|
|
32
|
+
|
|
33
|
+
export function memoryEnabledForProject(cwd = process.cwd()) {
|
|
34
|
+
try { return JSON.parse(readFileSync(join(cwd, '.rsc.json'), 'utf8')).memory !== false; } catch { return true; }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function git(cwd, args) {
|
|
38
|
+
try { return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); } catch { return null; }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function rel(cwd, path) {
|
|
42
|
+
return relative(cwd, path).split(sep).join('/');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function tracked(cwd, path) {
|
|
46
|
+
const output = git(cwd, ['ls-files', '--', rel(cwd, path)]);
|
|
47
|
+
return Boolean(output);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function exclude(cwd, path) {
|
|
51
|
+
if (git(cwd, ['rev-parse', '--is-inside-work-tree']) !== 'true') return;
|
|
52
|
+
const value = git(cwd, ['rev-parse', '--git-path', 'info/exclude']);
|
|
53
|
+
if (!value) return;
|
|
54
|
+
const file = isAbsolute(value) ? value : resolve(cwd, value);
|
|
55
|
+
const pattern = `/${rel(cwd, path)}`;
|
|
56
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
57
|
+
const body = existsSync(file) ? readFileSync(file, 'utf8') : '';
|
|
58
|
+
if (!body.split('\n').includes(pattern)) appendFileSync(file, `${body && !body.endsWith('\n') ? '\n' : ''}${pattern}\n`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function readConfig(path) {
|
|
62
|
+
if (!existsSync(path)) return {};
|
|
63
|
+
try { return JSON.parse(readFileSync(path, 'utf8')); } catch { return null; }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function validHookConfig(config) {
|
|
67
|
+
if (!config || typeof config !== 'object' || Array.isArray(config)) return false;
|
|
68
|
+
if (!Object.hasOwn(config, 'hooks')) return true;
|
|
69
|
+
if (!config.hooks || typeof config.hooks !== 'object' || Array.isArray(config.hooks)) return false;
|
|
70
|
+
return Object.values(config.hooks).every(Array.isArray);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function handler(target, event) {
|
|
74
|
+
const unixScript = target === 'claude'
|
|
75
|
+
? '"${CLAUDE_PROJECT_DIR}/.rsc/session-memory-adapter.mjs"'
|
|
76
|
+
: target === 'codex'
|
|
77
|
+
? '"$(git rev-parse --show-toplevel)/.rsc/session-memory-adapter.mjs"'
|
|
78
|
+
: '".rsc/session-memory-adapter.mjs"';
|
|
79
|
+
const value = {
|
|
80
|
+
type: 'command',
|
|
81
|
+
command: `node ${unixScript} ${target} ${event}`,
|
|
82
|
+
timeout: target === 'gemini' ? 5000 : event === 'end' && target === 'codex' ? 3 : 5,
|
|
83
|
+
};
|
|
84
|
+
if (target === 'codex') {
|
|
85
|
+
value.commandWindows = `node \"$((git rev-parse --show-toplevel).Trim())/.rsc/session-memory-adapter.mjs\" ${target} ${event}`;
|
|
86
|
+
value.additionalContextLimit = 1400;
|
|
87
|
+
}
|
|
88
|
+
if (target === 'gemini') value.name = `rsc-memory-${event}`;
|
|
89
|
+
return value;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const nestedEvents = {
|
|
93
|
+
claude: [
|
|
94
|
+
['SessionStart', 'start'], ['UserPromptSubmit', 'request'], ['PostToolUse', 'edit', 'Edit|Write|NotebookEdit'],
|
|
95
|
+
['PostToolUse', 'boundary', 'Bash'], ['Stop', 'turn'], ['PreCompact', 'compact'], ['SessionEnd', 'end'],
|
|
96
|
+
],
|
|
97
|
+
codex: [
|
|
98
|
+
['SessionStart', 'start', 'startup|resume|clear|compact'], ['UserPromptSubmit', 'request'],
|
|
99
|
+
['PostToolUse', 'edit', 'apply_patch|Edit|Write'], ['PostToolUse', 'boundary', 'Bash'],
|
|
100
|
+
['Stop', 'turn'], ['PreCompact', 'compact'], ['SessionEnd', 'end'],
|
|
101
|
+
],
|
|
102
|
+
gemini: [
|
|
103
|
+
['SessionStart', 'start'], ['BeforeAgent', 'request'], ['AfterTool', 'edit', 'write_file|replace'],
|
|
104
|
+
['AfterTool', 'boundary', 'run_shell_command'], ['AfterAgent', 'turn'], ['PreCompress', 'compact'], ['SessionEnd', 'end'],
|
|
105
|
+
],
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const cursorEvents = [
|
|
109
|
+
['sessionStart', 'start'], ['beforeSubmitPrompt', 'request'], ['afterFileEdit', 'edit'],
|
|
110
|
+
['afterShellExecution', 'boundary'], ['afterAgentResponse', 'turn'], ['preCompact', 'compact'], ['sessionEnd', 'end'],
|
|
111
|
+
];
|
|
112
|
+
|
|
113
|
+
function stripManaged(config) {
|
|
114
|
+
if (!validHookConfig(config) || !config.hooks) return config;
|
|
115
|
+
for (const event of Object.keys(config.hooks)) {
|
|
116
|
+
config.hooks[event] = (config.hooks[event] || []).filter((entry) => !JSON.stringify(entry).replaceAll('\\\\', '/').includes(NEEDLE));
|
|
117
|
+
if (!config.hooks[event].length) delete config.hooks[event];
|
|
118
|
+
}
|
|
119
|
+
if (!Object.keys(config.hooks).length) delete config.hooks;
|
|
120
|
+
return config;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function writeJsonWiring(target, path, config) {
|
|
124
|
+
stripManaged(config);
|
|
125
|
+
config.hooks ||= {};
|
|
126
|
+
if (target === 'cursor') {
|
|
127
|
+
config.version ||= 1;
|
|
128
|
+
for (const [event, operation] of cursorEvents) {
|
|
129
|
+
config.hooks[event] ||= [];
|
|
130
|
+
const entry = handler(target, operation);
|
|
131
|
+
delete entry.type;
|
|
132
|
+
delete entry.timeout;
|
|
133
|
+
config.hooks[event].push(entry);
|
|
134
|
+
}
|
|
135
|
+
} else {
|
|
136
|
+
for (const [event, operation, matcher] of nestedEvents[target]) {
|
|
137
|
+
config.hooks[event] ||= [];
|
|
138
|
+
config.hooks[event].push({ ...(matcher ? { matcher } : {}), hooks: [handler(target, operation)] });
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
142
|
+
writeFileSync(path, `${JSON.stringify(config, null, 2)}\n`);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function commonPaths(cwd) {
|
|
146
|
+
return [
|
|
147
|
+
join(cwd, '.rsc', 'session-memory-core.mjs'),
|
|
148
|
+
join(cwd, '.rsc', 'session-memory-adapter.mjs'),
|
|
149
|
+
join(cwd, '.rsc', 'session-memory.mjs'),
|
|
150
|
+
];
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function memoryManagedPaths(target, cwd = process.cwd()) {
|
|
154
|
+
if (!MEMORY_TARGETS[target]) return [];
|
|
155
|
+
const paths = [...commonPaths(cwd), join(cwd, ...CONFIG[target].split('/'))];
|
|
156
|
+
if (target === 'cursor') paths.push(join(cwd, '.cursor', 'rules', 'rsc-memory.mdc'));
|
|
157
|
+
return paths;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function wireMemory(target, cwd = process.cwd()) {
|
|
161
|
+
const mode = memoryModeFor(target);
|
|
162
|
+
if (mode === 'unsupported') return { mode, reason: 'no-local-lifecycle', paths: [] };
|
|
163
|
+
if (!memoryEnabledForProject(cwd)) {
|
|
164
|
+
unwireMemory(target, cwd);
|
|
165
|
+
return { mode: 'disabled', reason: 'project-disabled', paths: [] };
|
|
166
|
+
}
|
|
167
|
+
const configPath = join(cwd, ...CONFIG[target].split('/'));
|
|
168
|
+
if (tracked(cwd, configPath)) return { mode: 'degraded', reason: 'config-tracked', paths: [] };
|
|
169
|
+
const config = target === 'opencode' ? null : readConfig(configPath);
|
|
170
|
+
if (target !== 'opencode' && !validHookConfig(config)) return { mode: 'degraded', reason: 'config-invalid', paths: [] };
|
|
171
|
+
if (target === 'opencode' && existsSync(configPath) && !readFileSync(configPath, 'utf8').includes(PLUGIN_MARKER)) {
|
|
172
|
+
return { mode: 'degraded', reason: 'plugin-collision', paths: [] };
|
|
173
|
+
}
|
|
174
|
+
const assistedRule = join(cwd, '.cursor', 'rules', 'rsc-memory.mdc');
|
|
175
|
+
if (target === 'cursor' && tracked(cwd, assistedRule)) return { mode: 'degraded', reason: 'rule-tracked', paths: [] };
|
|
176
|
+
if (target === 'cursor' && existsSync(assistedRule) && !readFileSync(assistedRule, 'utf8').includes('rsc-memory:managed')) {
|
|
177
|
+
return { mode: 'degraded', reason: 'rule-collision', paths: [] };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const [core, adapter, cli] = commonPaths(cwd);
|
|
181
|
+
mkdirSync(dirname(core), { recursive: true });
|
|
182
|
+
copyFileSync(join(HERE, 'session-memory-core.mjs'), core);
|
|
183
|
+
copyFileSync(join(HERE, 'session-memory-adapter.mjs'), adapter);
|
|
184
|
+
copyFileSync(join(HERE, 'session-memory.mjs'), cli);
|
|
185
|
+
if (target === 'opencode') {
|
|
186
|
+
mkdirSync(dirname(configPath), { recursive: true });
|
|
187
|
+
copyFileSync(join(HERE, 'opencode-memory-plugin.js'), configPath);
|
|
188
|
+
} else {
|
|
189
|
+
writeJsonWiring(target, configPath, config);
|
|
190
|
+
}
|
|
191
|
+
if (target === 'cursor') {
|
|
192
|
+
mkdirSync(dirname(assistedRule), { recursive: true });
|
|
193
|
+
writeFileSync(assistedRule, `---\ndescription: Read rsc local continuation before the first action in a new desktop session.\nalwaysApply: true\n---\n<!-- rsc-memory:managed -->\nOn the first turn of a local desktop session, run \`node .rsc/session-memory.mjs resume\` and read its \`context\` field before acting. Skip this rule for background or cloud agents.\n`);
|
|
194
|
+
}
|
|
195
|
+
const paths = memoryManagedPaths(target, cwd);
|
|
196
|
+
for (const path of paths) exclude(cwd, path);
|
|
197
|
+
return {
|
|
198
|
+
mode,
|
|
199
|
+
reason: target === 'codex' ? 'hook-trust-required' : target === 'cursor' ? 'start-hook-fire-and-forget' : 'wired',
|
|
200
|
+
paths,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export function inspectMemoryWiring(target, cwd = process.cwd(), recorded = null, installed = false) {
|
|
205
|
+
const supportedMode = memoryModeFor(target);
|
|
206
|
+
if (supportedMode === 'unsupported') return { supportedMode, mode: 'unsupported', status: 'unsupported', reason: 'no-local-lifecycle', missing: [], tracked: [] };
|
|
207
|
+
const mode = recorded?.mode || 'not-installed';
|
|
208
|
+
if (mode === 'disabled' || !memoryEnabledForProject(cwd)) {
|
|
209
|
+
return { supportedMode, mode: 'disabled', status: 'disabled', reason: 'project-disabled', missing: [], tracked: [] };
|
|
210
|
+
}
|
|
211
|
+
// A capability that has never been installed is absent, not broken. Keep the
|
|
212
|
+
// degraded state for pre-memory installs so `sync` can repair those projects.
|
|
213
|
+
if (!recorded && !installed) {
|
|
214
|
+
return { supportedMode, mode, status: 'not-installed', reason: 'not-wired', missing: [], tracked: [] };
|
|
215
|
+
}
|
|
216
|
+
const paths = recorded?.paths?.length ? recorded.paths : memoryManagedPaths(target, cwd);
|
|
217
|
+
const configPath = join(cwd, ...CONFIG[target].split('/'));
|
|
218
|
+
const cursorRule = join(cwd, '.cursor', 'rules', 'rsc-memory.mdc');
|
|
219
|
+
const missing = paths.filter((path) => {
|
|
220
|
+
if (!existsSync(path)) return true;
|
|
221
|
+
if (path === configPath) {
|
|
222
|
+
const body = readFileSync(path, 'utf8').replaceAll('\\', '/');
|
|
223
|
+
return target === 'opencode' ? !body.includes(PLUGIN_MARKER) : !body.includes(NEEDLE);
|
|
224
|
+
}
|
|
225
|
+
if (target === 'cursor' && path === cursorRule) return !readFileSync(path, 'utf8').includes(PLUGIN_MARKER);
|
|
226
|
+
return false;
|
|
227
|
+
});
|
|
228
|
+
const trackedPaths = paths.filter((path) => tracked(cwd, path));
|
|
229
|
+
const status = mode === 'degraded' || mode === 'not-installed' || missing.length || trackedPaths.length ? 'degraded' : 'ready';
|
|
230
|
+
return {
|
|
231
|
+
supportedMode,
|
|
232
|
+
mode,
|
|
233
|
+
status,
|
|
234
|
+
reason: mode === 'degraded' ? (recorded?.reason || 'adapter-degraded')
|
|
235
|
+
: trackedPaths.length ? 'memory-path-tracked'
|
|
236
|
+
: missing.length ? 'memory-path-missing'
|
|
237
|
+
: recorded?.reason || 'wired',
|
|
238
|
+
missing,
|
|
239
|
+
tracked: trackedPaths,
|
|
240
|
+
action: status === 'degraded' ? 'Run `npx @ericrisco/rsc sync`; if a config is tracked, move memory hooks to an untracked local config first.' : null,
|
|
241
|
+
trustRequired: target === 'codex' && status === 'ready',
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export function unwireMemory(target, cwd = process.cwd()) {
|
|
246
|
+
if (!MEMORY_TARGETS[target]) return [];
|
|
247
|
+
const path = join(cwd, ...CONFIG[target].split('/'));
|
|
248
|
+
const touched = [];
|
|
249
|
+
if (target === 'opencode') {
|
|
250
|
+
if (existsSync(path) && readFileSync(path, 'utf8').includes(PLUGIN_MARKER)) { rmSync(path, { force: true }); touched.push(path); }
|
|
251
|
+
} else {
|
|
252
|
+
const raw = existsSync(path) ? readFileSync(path, 'utf8') : '';
|
|
253
|
+
const config = raw.replaceAll('\\\\', '/').includes(NEEDLE) ? readConfig(path) : null;
|
|
254
|
+
if (config) { stripManaged(config); writeFileSync(path, `${JSON.stringify(config, null, 2)}\n`); touched.push(path); }
|
|
255
|
+
}
|
|
256
|
+
if (target === 'cursor') {
|
|
257
|
+
const rule = join(cwd, '.cursor', 'rules', 'rsc-memory.mdc');
|
|
258
|
+
if (existsSync(rule) && readFileSync(rule, 'utf8').includes('rsc-memory:managed')) { rmSync(rule, { force: true }); touched.push(rule); }
|
|
259
|
+
}
|
|
260
|
+
return touched;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export function memoryArtifactsPresent(target, cwd = process.cwd()) {
|
|
264
|
+
if (!MEMORY_TARGETS[target]) return [];
|
|
265
|
+
const out = commonPaths(cwd).filter(existsSync);
|
|
266
|
+
const config = join(cwd, ...CONFIG[target].split('/'));
|
|
267
|
+
if (existsSync(config)) {
|
|
268
|
+
const body = readFileSync(config, 'utf8').replaceAll('\\\\', '/');
|
|
269
|
+
if (body.includes(NEEDLE) || body.includes(PLUGIN_MARKER)) out.push(config);
|
|
270
|
+
}
|
|
271
|
+
if (target === 'cursor') {
|
|
272
|
+
const rule = join(cwd, '.cursor', 'rules', 'rsc-memory.mdc');
|
|
273
|
+
if (existsSync(rule) && readFileSync(rule, 'utf8').includes('rsc-memory:managed')) out.push(rule);
|
|
274
|
+
}
|
|
275
|
+
return out;
|
|
276
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// rsc-memory:managed
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { capture, resume } from '../../.rsc/session-memory-core.mjs';
|
|
5
|
+
|
|
6
|
+
function memorySettings(cwd) {
|
|
7
|
+
try {
|
|
8
|
+
const value = JSON.parse(readFileSync(join(cwd, '.rsc.json'), 'utf8')).memory;
|
|
9
|
+
if (value === false) return { enabled: false };
|
|
10
|
+
return value && typeof value === 'object' ? value : {};
|
|
11
|
+
} catch {
|
|
12
|
+
return {};
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function sessionId(input) {
|
|
17
|
+
return input?.sessionID || input?.session_id || input?.event?.properties?.info?.id
|
|
18
|
+
|| input?.event?.properties?.sessionID || null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const RscMemoryPlugin = async ({ directory, worktree }) => {
|
|
22
|
+
const cwd = worktree || directory;
|
|
23
|
+
const injected = new Set();
|
|
24
|
+
const local = () => process.env.RSC_REMOTE_AGENT !== '1' && process.env.OPENCODE_REMOTE !== '1';
|
|
25
|
+
const save = (id, event, editDelta = 0) => {
|
|
26
|
+
if (!id || !local()) return;
|
|
27
|
+
try { capture({ cwd, sessionId: id, target: 'opencode', event, editDelta, settings: memorySettings(cwd) }); } catch { /* fail open */ }
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
return {
|
|
31
|
+
'experimental.chat.system.transform': async (input, output) => {
|
|
32
|
+
const id = sessionId(input);
|
|
33
|
+
if (!id || injected.has(id) || !local()) return;
|
|
34
|
+
injected.add(id);
|
|
35
|
+
try {
|
|
36
|
+
const config = memorySettings(cwd);
|
|
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}`;
|
|
42
|
+
} catch { /* fail open */ }
|
|
43
|
+
},
|
|
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);
|
|
47
|
+
},
|
|
48
|
+
'experimental.session.compacting': async (input, output) => {
|
|
49
|
+
const id = sessionId(input);
|
|
50
|
+
save(id, 'compact');
|
|
51
|
+
try {
|
|
52
|
+
const result = resume({ cwd, target: 'opencode', settings: memorySettings(cwd) });
|
|
53
|
+
if (result.context && Array.isArray(output?.context)) output.context.push(result.context);
|
|
54
|
+
} catch { /* fail open */ }
|
|
55
|
+
},
|
|
56
|
+
event: async ({ event }) => {
|
|
57
|
+
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');
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
};
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { resolve, join } from 'node:path';
|
|
4
|
+
import { capture, resume } from './session-memory-core.mjs';
|
|
5
|
+
|
|
6
|
+
const LOCAL_TARGETS = new Set(['claude', 'codex', 'cursor', 'gemini', 'opencode']);
|
|
7
|
+
|
|
8
|
+
function projectSettings(cwd) {
|
|
9
|
+
try {
|
|
10
|
+
const manifest = JSON.parse(readFileSync(join(cwd, '.rsc.json'), 'utf8'));
|
|
11
|
+
if (manifest.memory === false) return { enabled: false };
|
|
12
|
+
return manifest.memory && typeof manifest.memory === 'object' ? manifest.memory : {};
|
|
13
|
+
} catch {
|
|
14
|
+
return {};
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function sessionId(native, target) {
|
|
19
|
+
return native?.session_id || native?.sessionId || native?.sessionID || native?.conversation_id
|
|
20
|
+
|| native?.thread_id || `${target}-${process.ppid}`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function isRemote(target, native) {
|
|
24
|
+
if (target === 'cursor' && (native?.is_background_agent === true || native?.isBackgroundAgent === true)) return true;
|
|
25
|
+
return process.env.RSC_REMOTE_AGENT === '1'
|
|
26
|
+
|| process.env.CURSOR_CLOUD_AGENT === '1'
|
|
27
|
+
|| process.env.CODEX_CLOUD_AGENT === '1';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function hookEventName(target, event, native) {
|
|
31
|
+
if (native?.hook_event_name) return native.hook_event_name;
|
|
32
|
+
const names = {
|
|
33
|
+
start: target === 'cursor' ? 'sessionStart' : 'SessionStart',
|
|
34
|
+
request: target === 'gemini' ? 'BeforeAgent' : 'UserPromptSubmit',
|
|
35
|
+
edit: target === 'cursor' ? 'afterFileEdit' : target === 'gemini' ? 'AfterTool' : 'PostToolUse',
|
|
36
|
+
boundary: target === 'cursor' ? 'afterShellExecution' : target === 'gemini' ? 'AfterTool' : 'PostToolUse',
|
|
37
|
+
turn: target === 'cursor' ? 'afterAgentResponse' : target === 'gemini' ? 'AfterAgent' : 'Stop',
|
|
38
|
+
compact: target === 'cursor' ? 'preCompact' : target === 'gemini' ? 'PreCompress' : 'PreCompact',
|
|
39
|
+
end: target === 'cursor' ? 'sessionEnd' : 'SessionEnd',
|
|
40
|
+
};
|
|
41
|
+
return names[event] || event;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function nativeOutput(target, eventName, context = '', notice = null, compactionHint = false) {
|
|
45
|
+
const hint = compactionHint ? 'rsc memory: consider compacting at the next phase boundary; nothing was compacted automatically.' : '';
|
|
46
|
+
const extra = [context, hint].filter(Boolean).join('\n');
|
|
47
|
+
if (!extra && !notice) return {};
|
|
48
|
+
if (target === 'cursor') return { ...(extra ? { additional_context: extra } : {}), ...(notice ? { user_message: notice } : {}) };
|
|
49
|
+
if (target === 'opencode') return { context: extra, notice };
|
|
50
|
+
return {
|
|
51
|
+
...(notice ? { systemMessage: notice } : {}),
|
|
52
|
+
...(extra ? { hookSpecificOutput: { hookEventName: eventName, additionalContext: extra } } : {}),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function contextFromNativeOutput(target, output = {}) {
|
|
57
|
+
if (target === 'cursor') return output.additional_context || '';
|
|
58
|
+
if (target === 'opencode') return output.context || '';
|
|
59
|
+
return output.hookSpecificOutput?.additionalContext || '';
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function handleLifecycle({ target, event, native = {}, cwd, settings } = {}) {
|
|
63
|
+
try {
|
|
64
|
+
if (!LOCAL_TARGETS.has(target)) throw new Error(`unsupported memory target: ${target}`);
|
|
65
|
+
if (isRemote(target, native)) return { output: {}, capture: null, remote: true, degraded: false };
|
|
66
|
+
const project = resolve(cwd || native.cwd || process.env.RSC_PROJECT_CWD || process.cwd());
|
|
67
|
+
if (!existsSync(project)) throw new Error('project directory unavailable');
|
|
68
|
+
const config = settings || projectSettings(project);
|
|
69
|
+
if (config.enabled === false) return { output: {}, capture: null, remote: false, degraded: false };
|
|
70
|
+
const id = sessionId(native, target);
|
|
71
|
+
const eventName = hookEventName(target, event, native);
|
|
72
|
+
if (event === 'start') {
|
|
73
|
+
const started = capture({ cwd: project, sessionId: id, target, event: 'start', settings: config });
|
|
74
|
+
const resumed = resume({ cwd: project, target, settings: config });
|
|
75
|
+
return {
|
|
76
|
+
output: nativeOutput(target, eventName, resumed.context),
|
|
77
|
+
capture: started,
|
|
78
|
+
resume: resumed,
|
|
79
|
+
remote: false,
|
|
80
|
+
degraded: false,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
const captureInput = {
|
|
84
|
+
cwd: project,
|
|
85
|
+
sessionId: id,
|
|
86
|
+
target,
|
|
87
|
+
event: event === 'end' ? 'sessionEnd' : event,
|
|
88
|
+
editDelta: event === 'edit' ? 1 : 0,
|
|
89
|
+
settings: config,
|
|
90
|
+
};
|
|
91
|
+
if (typeof native.cost === 'number') captureInput.cost = native.cost;
|
|
92
|
+
if (Number.isInteger(native.tool_calls)) captureInput.toolCalls = native.tool_calls;
|
|
93
|
+
const captured = capture(captureInput);
|
|
94
|
+
return {
|
|
95
|
+
output: nativeOutput(target, eventName, '', captured.notice, captured.compactionHint),
|
|
96
|
+
capture: captured,
|
|
97
|
+
remote: false,
|
|
98
|
+
degraded: false,
|
|
99
|
+
};
|
|
100
|
+
} catch (error) {
|
|
101
|
+
return { output: {}, capture: null, remote: false, degraded: true, error: error instanceof Error ? error.message : String(error) };
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function stdinJson() {
|
|
106
|
+
try {
|
|
107
|
+
const raw = readFileSync(0, 'utf8');
|
|
108
|
+
return raw.trim() ? JSON.parse(raw) : {};
|
|
109
|
+
} catch {
|
|
110
|
+
return {};
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
115
|
+
const result = handleLifecycle({ target: process.argv[2], event: process.argv[3], native: stdinJson() });
|
|
116
|
+
process.stdout.write(`${JSON.stringify(result.output)}\n`);
|
|
117
|
+
}
|