From a32d249abb786332b4c593f95b1a370f183985f7 Mon Sep 17 00:00:00 2001 From: jinhaodong <657091714@qq.com> Date: Mon, 11 May 2026 18:50:51 +0800 Subject: [PATCH 1/4] fix: script returns empty when model response lacks code block markers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题根因: readData 函数要求模型返回 ```lang\n 格式的代码块标记才会开始收集内容。 当模型(尤其非 OpenAI 模型)未使用代码块包裹命令时,dataStart 永远为 false, readScript 返回空字符串,导致后续 getExplanation 用空脚本调用模型,产生无关的幻觉输出。 同时 readScript 和 readInfo 共享同一个 async generator,readScript 消费完流后 readInfo 必然返回空,虽然 explainInSecondRequest=true 使其影响有限,但属于逻辑错误。 解决思路: 1. readData 增加 fallback:流结束时若未匹配到代码块标记,将 buffer 内容 去除 exclusion 模式后作为结果返回,兼容无代码块的模型响应 2. getScriptAndInfo 移除无效的 readInfo(共享流 + explainInSecondRequest=true 使其永远不会被用到) 3. prompt.ts 移除 readInfo 相关逻辑,直接走 getExplanation 第二次请求 Co-Authored-By: Claude Opus 4.6 --- src/helpers/completion.ts | 16 +++++++++++++++- src/prompt.ts | 29 +++++++++++++---------------- 2 files changed, 28 insertions(+), 17 deletions(-) diff --git a/src/helpers/completion.ts b/src/helpers/completion.ts index 33171961..228ac5c1 100644 --- a/src/helpers/completion.ts +++ b/src/helpers/completion.ts @@ -51,7 +51,6 @@ export async function getScriptAndInfo({ const iterableStream = streamToIterable(stream); return { readScript: readData(iterableStream, ...shellCodeExclusions), - readInfo: readData(iterableStream, ...shellCodeExclusions), }; } @@ -218,6 +217,13 @@ export const readData = for (const payload of payloads) { if (payload.includes('[DONE]') || stopTextStream) { dataStart = false; + if (!data && buffer) { + const cleaned = stripRegexPatterns(buffer, excluded); + if (cleaned) { + data = cleaned; + writer(cleaned); + } + } resolve(data); return; } @@ -259,6 +265,14 @@ export const readData = } } + if (!data && buffer) { + const cleaned = stripRegexPatterns(buffer, excluded); + if (cleaned) { + data = cleaned; + writer(cleaned); + } + } + resolve(data); }); diff --git a/src/prompt.ts b/src/prompt.ts index 145c07b3..d6542d54 100644 --- a/src/prompt.ts +++ b/src/prompt.ts @@ -115,7 +115,7 @@ export async function prompt({ const thePrompt = usePrompt || (await getPrompt()); const spin = p.spinner(); spin.start(i18n.t(`Loading...`)); - const { readInfo, readScript } = await getScriptAndInfo({ + const { readScript } = await getScriptAndInfo({ prompt: thePrompt, key, model, @@ -129,21 +129,18 @@ export async function prompt({ console.log(dim('•')); if (!skipCommandExplanation) { spin.start(i18n.t(`Getting explanation...`)); - const info = await readInfo(process.stdout.write.bind(process.stdout)); - if (!info) { - const { readExplanation } = await getExplanation({ - script, - key, - model, - apiEndpoint, - }); - spin.stop(`${i18n.t('Explanation')}:`); - console.log(''); - await readExplanation(process.stdout.write.bind(process.stdout)); - console.log(''); - console.log(''); - console.log(dim('•')); - } + const { readExplanation } = await getExplanation({ + script, + key, + model, + apiEndpoint, + }); + spin.stop(`${i18n.t('Explanation')}:`); + console.log(''); + await readExplanation(process.stdout.write.bind(process.stdout)); + console.log(''); + console.log(''); + console.log(dim('•')); } await runOrReviseFlow(script, key, model, apiEndpoint, silentMode); From 752bc36d0769963144e5816007ec8eb9e9a80c47 Mon Sep 17 00:00:00 2001 From: jinhaodong <657091714@qq.com> Date: Mon, 11 May 2026 19:22:55 +0800 Subject: [PATCH 2/4] fix: preserve command content when code block and response arrive in same chunk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 当模型将代码块标记和命令在同一 chunk 中返回时,buffer 被清空导致命令丢失。 改为在匹配到代码块开头后,提取标记之后的内容并加入 data。 Co-Authored-By: Claude Opus 4.6 --- src/helpers/completion.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/helpers/completion.ts b/src/helpers/completion.ts index 228ac5c1..20934b15 100644 --- a/src/helpers/completion.ts +++ b/src/helpers/completion.ts @@ -236,8 +236,18 @@ export const readData = buffer += content; if (buffer.match(excludedPrefix ?? '')) { dataStart = true; - // Clear the buffer once it has served its purpose + // Extract content after the code block start marker + const afterMatch = excludedPrefix + ? buffer.replace(excludedPrefix, '') + : ''; buffer = ''; + if (afterMatch) { + const cleaned = stripRegexPatterns(afterMatch, excluded); + if (cleaned) { + data += cleaned; + writer(cleaned); + } + } if (excludedPrefix) break; } } From 3e8c0663009ed5d1254b89b4e8d2affae1b0ff2b Mon Sep 17 00:00:00 2001 From: jinhaodong <657091714@qq.com> Date: Tue, 12 May 2026 11:50:33 +0800 Subject: [PATCH 3/4] fix: remove code block requirement from prompt to fix multi-model compatibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题根因: prompt 要求模型用三个反引号包裹命令,但不同模型(DeepSeek、Qwen-Plus)的 代码块格式不统一(有/无语言标识、有/无换行),导致解析逻辑反复出问题。 Qwen-Plus 返回 ```ipconfig...``` 无换行,正则 /```[a-zA-Z]*/ 会把 ipconfig 当作语言标识符一起吃掉,导致脚本为空或残缺。 解决思路: 1. 修改 prompt:不再要求代码块包裹,直接返回命令原文 2. 简化 readData:移除所有代码块检测/解析逻辑,只收集内容 + trim 3. 清理无用代码:删除 shellCodeExclusions、extractCommand、stripRegexPatterns 引用 Co-Authored-By: Claude Opus 4.6 --- src/helpers/completion.ts | 73 +++++++-------------------------------- 1 file changed, 12 insertions(+), 61 deletions(-) diff --git a/src/helpers/completion.ts b/src/helpers/completion.ts index 20934b15..a27d29c1 100644 --- a/src/helpers/completion.ts +++ b/src/helpers/completion.ts @@ -13,7 +13,6 @@ import type { AxiosError } from 'axios'; import { streamToString } from './stream-to-string'; import './replace-all-polyfill'; import i18n from './i18n'; -import { stripRegexPatterns } from './strip-regex-patterns'; import readline from 'readline'; const explainInSecondRequest = true; @@ -25,9 +24,6 @@ function getOpenAi(key: string, apiEndpoint: string) { return openAi; } -// Openai outputs markdown format for code blocks. It oftne uses -// a github style like: "```bash" -const shellCodeExclusions = [/```[a-zA-Z]*\n/gi, /```[a-zA-Z]*/gi, '\n']; export async function getScriptAndInfo({ prompt, @@ -50,7 +46,7 @@ export async function getScriptAndInfo({ }); const iterableStream = streamToIterable(stream); return { - readScript: readData(iterableStream, ...shellCodeExclusions), + readScript: readData(iterableStream), }; } @@ -181,10 +177,11 @@ export async function getRevision({ }); const iterableStream = streamToIterable(stream); return { - readScript: readData(iterableStream, ...shellCodeExclusions), + readScript: readData(iterableStream), }; } + export const readData = ( iterableStream: AsyncGenerator, @@ -193,13 +190,10 @@ export const readData = (writer: (data: string) => void): Promise => new Promise(async (resolve) => { let stopTextStream = false; - let data = ''; + let raw = ''; let content = ''; - let dataStart = false; - let buffer = ''; // This buffer will temporarily hold incoming data only for detecting the start - const [excludedPrefix] = excluded; - const stopTextStreamKeys = ['q', 'escape']; //Group of keys that stop the text stream + const stopTextStreamKeys = ['q', 'escape']; const rl = readline.createInterface({ input: process.stdin, @@ -216,50 +210,15 @@ export const readData = const payloads = chunk.toString().split('\n\n'); for (const payload of payloads) { if (payload.includes('[DONE]') || stopTextStream) { - dataStart = false; - if (!data && buffer) { - const cleaned = stripRegexPatterns(buffer, excluded); - if (cleaned) { - data = cleaned; - writer(cleaned); - } - } - resolve(data); + resolve(raw.trim()); return; } if (payload.startsWith('data:')) { content = parseContent(payload); - // Use buffer only for start detection - if (!dataStart) { - // Append content to the buffer - buffer += content; - if (buffer.match(excludedPrefix ?? '')) { - dataStart = true; - // Extract content after the code block start marker - const afterMatch = excludedPrefix - ? buffer.replace(excludedPrefix, '') - : ''; - buffer = ''; - if (afterMatch) { - const cleaned = stripRegexPatterns(afterMatch, excluded); - if (cleaned) { - data += cleaned; - writer(cleaned); - } - } - if (excludedPrefix) break; - } - } - - if (dataStart && content) { - const contentWithoutExcluded = stripRegexPatterns( - content, - excluded - ); - - data += contentWithoutExcluded; - writer(contentWithoutExcluded); + if (content) { + raw += content; + writer(content); } } } @@ -271,19 +230,11 @@ export const readData = const delta = JSON.parse(data.trim()); return delta.choices?.[0]?.delta?.content ?? ''; } catch (error) { - return `Error with JSON.parse and ${payload}.\n${error}`; - } - } - - if (!data && buffer) { - const cleaned = stripRegexPatterns(buffer, excluded); - if (cleaned) { - data = cleaned; - writer(cleaned); + return ''; } } - resolve(data); + resolve(raw.trim()); }); function getExplanationPrompt(script: string) { @@ -312,7 +263,7 @@ function getOperationSystemDetails() { return os.name(); } const generationDetails = dedent` - Only reply with the single line command surrounded by three backticks. It must be able to be directly run in the target shell. Do not include any other text. + Only reply with the single line command. It must be able to be directly run in the target shell. Do not include any other text, explanations, or markdown formatting. Make sure the command runs on ${getOperationSystemDetails()} operating system. `; From a6d2ca63806870dfea2defdae3e2f8fb7f1da718 Mon Sep 17 00:00:00 2001 From: jinhaodong <657091714@qq.com> Date: Thu, 14 May 2026 10:34:20 +0800 Subject: [PATCH 4/4] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=20extractCommand?= =?UTF-8?q?=20=E5=85=BC=E5=AE=B9=E5=B1=82=E5=B9=B6=E6=9B=B4=E5=90=8D?= =?UTF-8?q?=E4=B8=BA=20@jadenoliver/ai-shell?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 extractCommand() 函数,兼容非 OpenAI 模型返回的三种格式: 标准代码块、无换行代码块、纯文本 - 在主提示和修订流程中均应用 extractCommand 提取命令 - 包名从 @builder.io/ai-shell 更名为 @jadenoliver/ai-shell Co-Authored-By: Claude Opus 4.6 --- package-lock.json | 1 + package.json | 2 +- src/helpers/completion.ts | 15 ++++++++++++++- src/prompt.ts | 7 +++++-- 4 files changed, 21 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index 25be455a..aef79f70 100644 --- a/package-lock.json +++ b/package-lock.json @@ -76,6 +76,7 @@ }, "node_modules/@clack/prompts/node_modules/is-unicode-supported": { "version": "1.3.0", + "extraneous": true, "inBundle": true, "license": "MIT", "engines": { diff --git a/package.json b/package.json index a6abb27a..45c4eea5 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "@builder.io/ai-shell", + "name": "@jadenoliver/ai-shell", "description": "A CLI that converts natural language to shell commands.", "version": "1.0.12", "type": "module", diff --git a/src/helpers/completion.ts b/src/helpers/completion.ts index a27d29c1..05d21a61 100644 --- a/src/helpers/completion.ts +++ b/src/helpers/completion.ts @@ -180,7 +180,20 @@ export async function getRevision({ readScript: readData(iterableStream), }; } - +export function extractCommand(raw: string): string { + // Case 1: Standard code block with newline after language identifier + const match = raw.match(/```[a-zA-Z]*\n([\s\S]*?)```/); + if (match) { + return match[1].trim(); + } + // Case 2: Code block without newline (```command```) + const noNewlineMatch = raw.match(/```([\s\S]*?)```/); + if (noNewlineMatch) { + return noNewlineMatch[1].trim(); + } + // Case 3: No code blocks, return as-is + return raw.trim(); +} export const readData = ( diff --git a/src/prompt.ts b/src/prompt.ts index d6542d54..11595cad 100644 --- a/src/prompt.ts +++ b/src/prompt.ts @@ -5,6 +5,7 @@ import { getExplanation, getRevision, getScriptAndInfo, + extractCommand, } from './helpers/completion'; import { getConfig } from './helpers/config'; import { projectName } from './helpers/constants'; @@ -123,7 +124,8 @@ export async function prompt({ }); spin.stop(`${i18n.t('Your script')}:`); console.log(''); - const script = await readScript(process.stdout.write.bind(process.stdout)); + const rawScript = await readScript(process.stdout.write.bind(process.stdout)); + const script = extractCommand(rawScript); console.log(''); console.log(''); console.log(dim('•')); @@ -235,7 +237,8 @@ async function revisionFlow( spin.stop(`${i18n.t(`Your new script`)}:`); console.log(''); - const script = await readScript(process.stdout.write.bind(process.stdout)); + const rawScript = await readScript(process.stdout.write.bind(process.stdout)); + const script = extractCommand(rawScript); console.log(''); console.log(''); console.log(dim('•'));