fix(memory): deliver OpenCode extraction prompt on stdin (#3238)

`opencode run`'s `-f, --file` is a yargs array option that greedily
consumes every trailing non-flag token, so the memory extractor's
`--file <prompt-file> "<message>"` invocation made OpenCode treat the
message text as a second attachment and exit 1 with "File not found".
Every LLM memory extraction failed for OpenCode Local CLI users.

Deliver the prompt on stdin like the chat-run path (def.promptViaStdin)
and drop the --file attachment. The connector-memory test now models
the real yargs --file array-greediness so it would catch a regression.
This commit is contained in:
Weston Houghton 2026-05-30 00:48:42 -04:00 committed by GitHub
parent f12679185c
commit 7a9dcf38d7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 42 additions and 42 deletions

View file

@ -61,9 +61,6 @@ import {
} from './memory-extractions.js'; } from './memory-extractions.js';
import { resolveProviderConfig } from './media-config.js'; import { resolveProviderConfig } from './media-config.js';
import { spawn } from 'node:child_process'; import { spawn } from 'node:child_process';
import { promises as fsp } from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { createCommandInvocation } from '@open-design/platform'; import { createCommandInvocation } from '@open-design/platform';
import { import {
applyAgentLaunchEnv, applyAgentLaunchEnv,
@ -789,16 +786,6 @@ function extractJsonEventText(kind, raw, agentName) {
.trim(); .trim();
} }
async function writeLocalCliPromptAttachment(agentId, prompt) {
const dir = await fsp.mkdtemp(path.join(os.tmpdir(), `od-memory-${agentId}-`));
const file = path.join(dir, 'prompt.md');
await fsp.writeFile(file, prompt, 'utf8');
return {
file,
cleanup: () => fsp.rm(dir, { recursive: true, force: true }).catch(() => {}),
};
}
async function callLocalCli(provider, system, user, options) { async function callLocalCli(provider, system, user, options) {
if (typeof options?.localCliRunner === 'function') { if (typeof options?.localCliRunner === 'function') {
return options.localCliRunner({ return options.localCliRunner({
@ -843,7 +830,6 @@ async function callLocalCli(provider, system, user, options) {
let args; let args;
let stdinText = prompt; let stdinText = prompt;
let cleanupPromptAttachment = () => Promise.resolve();
let parseStdout = (raw) => raw.trim(); let parseStdout = (raw) => raw.trim();
if (provider.agentId === 'claude') { if (provider.agentId === 'claude') {
args = ['-p', '--input-format', 'text', '--output-format', 'text']; args = ['-p', '--input-format', 'text', '--output-format', 'text'];
@ -860,8 +846,12 @@ async function callLocalCli(provider, system, user, options) {
); );
parseStdout = (raw) => extractJsonEventText(def.eventParser || def.id, raw, def.name); parseStdout = (raw) => extractJsonEventText(def.eventParser || def.id, raw, def.name);
} else if (provider.agentId === 'opencode') { } else if (provider.agentId === 'opencode') {
const attachment = await writeLocalCliPromptAttachment(provider.agentId, prompt); // Deliver the prompt on stdin, matching the chat-run path
cleanupPromptAttachment = attachment.cleanup; // (def.promptViaStdin). `opencode run`'s `-f, --file` is a yargs array
// option that greedily consumes every trailing non-flag token, so
// `--file <prompt-file> "<message>"` made OpenCode treat the message
// text as a second attachment and exit with "File not found". Bare
// `opencode run --format json` reads the message from stdin instead.
args = def.buildArgs( args = def.buildArgs(
'', '',
[], [],
@ -869,12 +859,6 @@ async function callLocalCli(provider, system, user, options) {
{ model: provider.model }, { model: provider.model },
{ cwd }, { cwd },
); );
args.push(
'--file',
attachment.file,
'Read the attached OpenDesign memory extraction prompt and return strict JSON only.',
);
stdinText = '';
parseStdout = (raw) => extractJsonEventText(def.eventParser || def.id, raw, def.name); parseStdout = (raw) => extractJsonEventText(def.eventParser || def.id, raw, def.name);
} else { } else {
throw new Error(`Local CLI memory extraction is not supported for ${provider.agentId}`); throw new Error(`Local CLI memory extraction is not supported for ${provider.agentId}`);
@ -907,10 +891,8 @@ async function callLocalCli(provider, system, user, options) {
if (settled) return; if (settled) return;
settled = true; settled = true;
clearTimeout(timeout); clearTimeout(timeout);
void cleanupPromptAttachment().finally(() => { if (err) reject(err);
if (err) reject(err); else resolve(text);
else resolve(text);
});
}; };
const timeout = setTimeout(() => { const timeout = setTimeout(() => {

View file

@ -1023,7 +1023,7 @@ process.stdout.write(JSON.stringify({
} }
}); });
it('runs OpenCode Local CLI with a message argument and attached prompt file', async () => { it('runs OpenCode Local CLI memory extraction with the prompt on stdin', async () => {
await writeMemoryConfig(dataDir, { extraction: null }); await writeMemoryConfig(dataDir, { extraction: null });
const tempDir = await fsp.mkdtemp(path.join(tmpdir(), 'od-opencode-memory-')); const tempDir = await fsp.mkdtemp(path.join(tmpdir(), 'od-opencode-memory-'));
const binPath = path.join(tempDir, 'opencode-cli'); const binPath = path.join(tempDir, 'opencode-cli');
@ -1031,16 +1031,33 @@ process.stdout.write(JSON.stringify({
const previousPath = process.env.PATH; const previousPath = process.env.PATH;
const previousCapture = process.env.OD_MEMORY_OPENCODE_ARGS_OUT; const previousCapture = process.env.OD_MEMORY_OPENCODE_ARGS_OUT;
// Model the real `opencode run` arg parser: `-f, --file` is a yargs
// *array* option, so it greedily swallows every following non-flag
// token as a file path. Any captured path that doesn't exist makes the
// real CLI exit 1 with "File not found: <token>" — which is exactly how
// a trailing positional message after `--file` crashed extraction. The
// supported one-shot shape is bare `run` with the prompt on stdin.
await fsp.writeFile( await fsp.writeFile(
binPath, binPath,
`#!/usr/bin/env node `#!/usr/bin/env node
const fs = require('node:fs'); const fs = require('node:fs');
const args = process.argv.slice(2); const args = process.argv.slice(2);
const fileIndex = args.indexOf('--file');
const attachedFile = fileIndex >= 0 ? args[fileIndex + 1] : null;
const prompt = attachedFile ? fs.readFileSync(attachedFile, 'utf8') : '';
const stdin = fs.readFileSync(0, 'utf8'); const stdin = fs.readFileSync(0, 'utf8');
fs.writeFileSync(process.env.OD_MEMORY_OPENCODE_ARGS_OUT, JSON.stringify({ args, attachedFile, prompt, stdin })); const files = [];
const fileFlag = args.findIndex((a) => a === '--file' || a === '-f');
if (fileFlag >= 0) {
for (let i = fileFlag + 1; i < args.length; i += 1) {
if (args[i].startsWith('-')) break;
files.push(args[i]);
}
}
fs.writeFileSync(process.env.OD_MEMORY_OPENCODE_ARGS_OUT, JSON.stringify({ args, stdin, files }));
for (const f of files) {
if (!fs.existsSync(f)) {
process.stderr.write('Error: File not found: ' + f + '\\n');
process.exit(1);
}
}
process.stdout.write(JSON.stringify({ process.stdout.write(JSON.stringify({
type: 'text', type: 'text',
part: { part: {
@ -1048,9 +1065,9 @@ process.stdout.write(JSON.stringify({
text: JSON.stringify({ text: JSON.stringify({
entries: [{ entries: [{
type: 'project', type: 'project',
name: 'OpenCode prompt attachment', name: 'OpenCode stdin prompt',
description: 'OpenCode memory used a prompt file', description: 'OpenCode memory used stdin',
body: 'OpenDesign connector memory extraction should pass the compacted prompt to OpenCode as an attached file while sending a short message argument.' body: 'OpenDesign connector memory extraction should pass the compacted prompt to OpenCode on stdin and parse the JSON event stream response.'
}] }]
}) })
} }
@ -1077,7 +1094,7 @@ process.stdout.write(JSON.stringify({
expect(result.suggestions).toEqual([ expect(result.suggestions).toEqual([
expect.objectContaining({ expect.objectContaining({
type: 'project', type: 'project',
name: 'OpenCode prompt attachment', name: 'OpenCode stdin prompt',
}), }),
]); ]);
@ -1086,14 +1103,15 @@ process.stdout.write(JSON.stringify({
'run', 'run',
'--format', '--format',
'json', 'json',
'--file', 'openai/gpt-5',
'Read the attached OpenDesign memory extraction prompt and return strict JSON only.',
])); ]));
expect(captured.args).toContain('openai/gpt-5'); // The prompt rides on stdin like the chat-run path; no `--file`
expect(captured.prompt).toContain('You are a design-memory extractor'); // attachment (whose array option would swallow any trailing message).
expect(captured.prompt).toContain('OpenDesign connector memory should collect design preferences'); expect(captured.args).not.toContain('--file');
expect(captured.stdin).toBe(''); expect(captured.args).not.toContain('-f');
await expect(fsp.access(captured.attachedFile)).rejects.toThrow(); expect(captured.files).toEqual([]);
expect(captured.stdin).toContain('You are a design-memory extractor');
expect(captured.stdin).toContain('OpenDesign connector memory should collect design preferences');
} finally { } finally {
if (previousPath == null) { if (previousPath == null) {
delete process.env.PATH; delete process.env.PATH;