Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 91 additions & 11 deletions src/adapters/opencode.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,62 @@ const path = require('node:path');

const HOOKS_DIR = ${JSON.stringify(hooksDirAbsolute)};

// Require shared modules from hooks directory (side-effect-free, deployed by setup-hooks)
var runtimePaths = require(path.join(HOOKS_DIR, '_runtimePaths'));
var memoryFiltering = require(path.join(HOOKS_DIR, '_memoryFiltering'));

// DIAG: temporary diagnostic markers (remove after maintainer verification)
var evolverMarkPath = require('path').join(require('path').dirname(hooksDirAbsolute), '.evolver-diag.log');

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Plugin crashes on undefined identifier

High Severity

The generated plugin references hooksDirAbsolute in its diagnostic logging path, but this variable is not defined in the emitted file. This causes a ReferenceError during module evaluation, preventing the plugin from loading correctly.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit d4cca14. Configure here.

function evolverMark(id, data) {
try { require('fs').appendFileSync(evolverMarkPath, new Date().toISOString() + ' [' + id + '] ' + (data||'') + '\\n'); } catch(e) {}
}
evolverMark('A-module-loaded', 'Bun=' + (typeof Bun !== 'undefined'));

// Read recent entries from jsonl tail (avoid full-file parse for large graphs)
function readRecentEntries(graphPath, maxEntries) {
try {
var stat = require('fs').statSync(graphPath);
var tailBytes = Math.min(stat.size, 64 * 1024);
var fd = require('fs').openSync(graphPath, 'r');
var buf = Buffer.alloc(tailBytes);
require('fs').readSync(fd, buf, 0, tailBytes, Math.max(0, stat.size - tailBytes));
require('fs').closeSync(fd);
return buf.toString('utf8').trim().split('\\n').slice(-maxEntries).map(function(l) {
try { return JSON.parse(l); } catch(e) { return null; }
}).filter(Boolean);
} catch(e) { return []; }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

File descriptor leak on read errors

Medium Severity

readRecentEntries opens the memory graph with openSync but only calls closeSync on the success path. If readSync or later work throws, the outer catch returns [] without closing the fd, so repeated chat.message failures can leak descriptors in the long-lived opencode process.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit d4cca14. Configure here.

}

// Format outcome with correct [+]/[-] prefix and score handling
function formatOutcome(e) {
var ok = e.outcome && e.outcome.status === 'success';
var score = (e.outcome && e.outcome.score !== undefined && e.outcome.score !== null)
? e.outcome.score
: (e.score !== undefined && e.score !== null ? e.score : 'N/A');
var signals = e.signals || (e.outcome && e.outcome.signals) || [];
var note = e.outcome && e.outcome.note ? ' ' + e.outcome.note : '';
return (ok ? '[+]' : '[-]') + ' ' + (e.timestamp || 'unknown') + ' score=' + score + ' signals=' + JSON.stringify(signals) + note;
}

// Inline memory reader using shared modules for workspace-aware filtering
function readEvolverMemoryInline() {
try {
var evolverRoot = runtimePaths.findEvolverRoot();
if (!evolverRoot) return null;
var graphPath = runtimePaths.findMemoryGraph(evolverRoot);
if (!graphPath || !require('fs').existsSync(graphPath)) return null;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Null root skips memory fallback

High Severity

The readEvolverMemoryInline function exits early if runtimePaths.findEvolverRoot() returns null. This bypasses findMemoryGraph's fallback to the user's home directory, resulting in empty inline memory for environments unable to resolve the package root, a regression from the prior behavior.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit d4cca14. Configure here.

var currentDir = runtimePaths.resolveProjectDir();
var entries = readRecentEntries(graphPath, 20);
var filtered = memoryFiltering.filterRelevantOutcomes(entries);
if (!filtered || filtered.length === 0) return null;
var sc = filtered.filter(function(e) { return e.outcome && e.outcome.status === 'success'; }).length;
var fc = filtered.filter(function(e) { return !(e.outcome && e.outcome.status === 'success'); }).length;
return '[Evolution Memory] Recent ' + filtered.length + ' outcomes (' + sc + ' success, ' + fc + ' failed):\\n' +
filtered.map(formatOutcome).join('\\n') +
'\\n\\nUse successful approaches. Avoid repeating failed patterns.';
} catch(e) { return null; }
}
Comment thread
cursor[bot] marked this conversation as resolved.

function runHook(scriptName, payload, timeoutMs) {
try {
const result = spawnSync('node', [path.join(HOOKS_DIR, scriptName)], {
Expand All @@ -47,33 +103,57 @@ function runHook(scriptName, payload, timeoutMs) {
}
}

var memoryInjected = false;

const Evolver = async () => ({
// Memory injection via chat.message (can mutate output.parts for the LLM)
"chat.message": async (input, output) => {
if (memoryInjected) return;
evolverMark('C-chat-message', 'parts=' + (output && output.parts ? output.parts.length : 'N/A'));
// 1) Inline read (0ms, workspace-aware via shared modules)
var text = readEvolverMemoryInline();
evolverMark('D-inline', 'text=' + (text ? text.length : 0));
// 2) Fallback: full spawnSync (for platforms where it works)
if (!text) {
var result = runHook('evolver-session-start.js', {
session_id: input && input.sessionID,
}, 10000);
text = result && (result.agent_message || result.additionalContext);
evolverMark('D1-spawnSync', 'text=' + (text ? text.length : 0));
} else {
// 3) Best-effort spawnSync for side effects (recordMarkedSession, daemon restart)
try { runHook('evolver-session-start.js', { session_id: input && input.sessionID }, 2000); } catch(e) {}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Side-effect spawn blocks first message

Medium Severity

The chat.message handler still runs spawnSync for evolver-session-start.js side effects even after successful inline memory reading. On Windows/Bun, this spawnSync often times out, stalling the handler for up to 2 seconds and preventing session recording from completing.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit d4cca14. Configure here.

}
evolverMark('E-inject', 'text=' + (text ? text.length : 0));
if (text && output && Array.isArray(output.parts)) {
output.parts.unshift({ type: "text", text: text });
memoryInjected = true;
evolverMark('E1-injected', 'parts=' + output.parts.length);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deferred flag retries spawn every message

High Severity

The memoryInjected flag is only set when memory is successfully injected into output.parts. If memory is unavailable or output.parts is missing, the flag remains false, causing the chat.message handler to repeatedly execute blocking spawnSync calls. This leads to multi-second stalls on subsequent messages, particularly on Windows/Bun.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit d4cca14. Configure here.

}
},
// session.idle -> record outcome
event: async ({ event }) => {
if (!event || typeof event.type !== 'string') return;
if (event.type === 'session.created') {
runHook('evolver-session-start.js', {
session_id: event.properties && event.properties.info && event.properties.info.id,
}, 3000);
return;
}
if (event.type === 'session.idle') {
runHook('evolver-session-end.js', {
session_id: event.properties && event.properties.sessionID,
}, 8000);
}, 10000);
}
},
'tool.execute.after': async (input, output) => {
if (!input || typeof input.tool !== 'string') return;
if (input.tool !== 'write' && input.tool !== 'edit') return;
evolverMark('G-tool-after', 'tool=' + input.tool);
runHook('evolver-signal-detect.js', {
tool_input: (output && output.args) || {},
tool_response: (output && output.output) || (output && output.result) || {},
}, 2000);
}, 5000);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verify omits required helper scripts

Medium Severity

The generated plugin now hard-requires _runtimePaths.js and _memoryFiltering.js at load time, but verify() still only checks the three entry hook scripts. A hooks dir missing those helpers can report hook_scripts_present as ok while plugin_loadable fails with a generic MODULE_NOT_FOUND.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit d4cca14. Configure here.

},
});

module.exports = { Evolver };
module.exports.default = Evolver;
const evolverPluginModule = { id: "evolver", server: Evolver };
module.exports = evolverPluginModule;
module.exports.default = evolverPluginModule;
Comment thread
cursor[bot] marked this conversation as resolved.
`;
}

Expand Down Expand Up @@ -220,7 +300,7 @@ function verify({ configRoot }) {
// failure here is a guaranteed failure under opencode too.
delete require.cache[require.resolve(pluginPath)];
const mod = require(pluginPath);
const fn = mod && (mod.Evolver || mod.default);
const fn = mod && (mod.server || mod.Evolver || mod.default);
pluginLoadable = typeof fn === 'function';
if (!pluginLoadable) pluginLoadError = 'no Evolver/default function export';
} catch (err) {
Expand Down
4 changes: 2 additions & 2 deletions test/adapters.opencode.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ describe('opencode adapter: buildPluginSource', () => {
it('exports both named Evolver and default for opencode loader compat', () => {
const src = opencodeAdapter.buildPluginSource('/x');
assert.match(src, /module\.exports\s*=\s*\{\s*Evolver\s*\}/);
assert.match(src, /module\.exports\.default\s*=\s*Evolver/);
assert.match(src, /module\.exports\.default\s*=\s*evolverPluginModule/);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale session.created test still passes

Medium Severity

This commit removes the session.created event handler, but the test still looks for session.created near evolver-session-start.js. That regex only matches the outdated header comment, so the suite keeps passing while the real injection path is now chat.message.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit d4cca14. Configure here.

});

it('produces source that parses as valid JavaScript', () => {
Expand Down Expand Up @@ -329,7 +329,7 @@ describe('opencode adapter: verify (issue #531)', () => {
fs.mkdirSync(pluginsDir, { recursive: true });
// user-authored plugin: parses, exports a function, but no managed marker
fs.writeFileSync(path.join(pluginsDir, 'evolver.js'),
'const Evolver = async () => ({});\nmodule.exports = { Evolver };\nmodule.exports.default = Evolver;\n',
'const Evolver = async () => ({});\nconst evolverPluginModule = { id: \"evolver\", server: Evolver };\nmodule.exports = evolverPluginModule;\nmodule.exports.default = evolverPluginModule;\n',
'utf8',
);

Expand Down