minimal-agent 0.2.0 → 0.3.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 +54 -72
- package/package.json +18 -13
- package/plugins/ralph-wiggum/plugin.js +205 -0
- package/plugins/ralph-wiggum/src/goalState.js +260 -0
- package/plugins/ralph-wiggum/src/{sentinels.ts → sentinels.js} +4 -7
- package/plugins/ralph-wiggum/src/stopHookRunner.js +104 -0
- package/plugins/ralph-wiggum/src/verificationGate.js +202 -0
- package/plugins/workflow-runner/commands/workflow.md +13 -3
- package/plugins/workflow-runner/{plugin.ts → plugin.js} +20 -26
- package/plugins/workflow-runner/src/expressions.js +369 -0
- package/plugins/workflow-runner/src/index.js +216 -0
- package/plugins/workflow-runner/src/loader.js +183 -0
- package/plugins/workflow-runner/src/runner.js +290 -0
- package/plugins/workflow-runner/src/stepExecutors/assert.js +28 -0
- package/plugins/workflow-runner/src/stepExecutors/llm.js +44 -0
- package/plugins/workflow-runner/src/stepExecutors/skill.js +103 -0
- package/plugins/workflow-runner/src/stepExecutors/{tool.ts → tool.js} +19 -25
- package/plugins/workflow-runner/src/types.js +59 -0
- package/plugins/workflow-runner/src/{workflowState.ts → workflowState.js} +21 -40
- package/src/bootstrap/cwdArg.js +22 -0
- package/src/bootstrap/workingDir.js +31 -0
- package/src/cli/configWizard.js +272 -0
- package/src/cli/print.js +197 -0
- package/src/config/configFile.js +78 -0
- package/src/config.js +118 -0
- package/src/context/compact.js +357 -0
- package/src/context/microCompactLite.js +151 -0
- package/src/context/persistContext.js +109 -0
- package/src/context/reactiveCompact.js +121 -0
- package/src/context/sessionPath.js +58 -0
- package/src/context/snipCompact.js +112 -0
- package/src/context/tokenCounter.js +66 -0
- package/src/llm/client.js +182 -0
- package/src/loop.js +230 -0
- package/src/main.js +116 -0
- package/src/plugin-sdk.js +24 -0
- package/src/plugins/commandRouter.js +169 -0
- package/src/plugins/hookEngine.js +258 -0
- package/src/plugins/pluginApi.js +23 -0
- package/src/plugins/pluginLoader.js +71 -0
- package/src/plugins/pluginRunner.js +65 -0
- package/src/plugins/transcript.js +171 -0
- package/src/prompts/projectInstructions.js +48 -0
- package/src/prompts/skillList.js +126 -0
- package/src/prompts/system.js +155 -0
- package/src/session/runTurn.js +41 -0
- package/src/session/sessionState.js +19 -0
- package/src/tools/bash/bash.js +352 -0
- package/src/tools/bash/semantics.js +85 -0
- package/src/tools/bash/warnings.js +98 -0
- package/src/tools/edit/edit.js +253 -0
- package/src/tools/edit/multi-edit.js +155 -0
- package/src/tools/glob/glob.js +97 -0
- package/src/tools/grep/grep.js +185 -0
- package/src/tools/grep/rgPath.js +173 -0
- package/src/tools/index.js +94 -0
- package/src/tools/read/read.js +209 -0
- package/src/tools/shared/fileState.js +61 -0
- package/src/tools/shared/fileUtils.js +281 -0
- package/src/tools/shared/schemas.js +16 -0
- package/src/tools/types.js +21 -0
- package/src/tools/webbrowser/browser.js +55 -0
- package/src/tools/webbrowser/webbrowser.js +194 -0
- package/src/tools/webfetch/preapproved.js +267 -0
- package/src/tools/webfetch/webfetch.js +317 -0
- package/src/tools/websearch/websearch.js +161 -0
- package/src/tools/write/write.js +125 -0
- package/src/types/turndown.d.ts +23 -0
- package/src/types.js +16 -0
- package/src/ui/App.js +37 -0
- package/src/ui/InputBox.js +240 -0
- package/src/ui/MessageList.js +28 -0
- package/src/ui/Root.js +70 -0
- package/src/ui/StatusLine.js +41 -0
- package/src/ui/ToolStatus.js +11 -0
- package/src/ui/hooks/useChat.js +234 -0
- package/src/ui/hooks/usePasteHandler.js +137 -0
- package/src/ui/hooks/useTextBuffer.js +55 -0
- package/src/ui/hooks/useTokenUsage.js +30 -0
- package/src/ui/textBuffer.js +217 -0
- package/src/utils/packageRoot.js +37 -0
- package/src/utils/resourcePaths.js +49 -0
- package/src/utils/zodToJson.js +29 -0
- package/dist/main.js +0 -5315
- package/plugins/ralph-wiggum/plugin.ts +0 -275
- package/plugins/ralph-wiggum/scripts/setup-ralph-loop.sh +0 -203
- package/plugins/ralph-wiggum/src/goalState.ts +0 -310
- package/plugins/ralph-wiggum/src/stopHookRunner.ts +0 -136
- package/plugins/ralph-wiggum/src/verificationGate.ts +0 -252
- package/plugins/ralph-wiggum/test/goalState.test.ts +0 -410
- package/plugins/ralph-wiggum/test/verificationGate.test.ts +0 -122
- package/plugins/workflow-runner/src/expressions.ts +0 -371
- package/plugins/workflow-runner/src/index.ts +0 -194
- package/plugins/workflow-runner/src/loader.ts +0 -193
- package/plugins/workflow-runner/src/runner.ts +0 -313
- package/plugins/workflow-runner/src/stepExecutors/assert.ts +0 -30
- package/plugins/workflow-runner/src/stepExecutors/llm.ts +0 -54
- package/plugins/workflow-runner/src/stepExecutors/skill.ts +0 -115
- package/plugins/workflow-runner/src/types.ts +0 -183
- package/plugins/workflow-runner/test/cli.e2e.test.ts +0 -114
- package/plugins/workflow-runner/test/e2e.test.ts +0 -268
- package/plugins/workflow-runner/test/expressions.test.ts +0 -140
- package/plugins/workflow-runner/test/fixtures/cli-e2e.yaml +0 -27
- package/plugins/workflow-runner/test/fixtures/hello-workflow.yaml +0 -49
- package/plugins/workflow-runner/test/graceful.test.ts +0 -139
- package/plugins/workflow-runner/test/loader.test.ts +0 -216
- package/plugins/workflow-runner/test/pluginRunner.isolation.test.ts +0 -230
- package/plugins/workflow-runner/test/runner.test.ts +0 -511
package/src/loop.js
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ============================================================
|
|
3
|
+
* src/loop.ts —— Agent 主循环(query loop)
|
|
4
|
+
* ------------------------------------------------------------
|
|
5
|
+
* 这是整个 agent 的"心脏"。一句话描述:
|
|
6
|
+
*
|
|
7
|
+
* 用户说话 → 调 LLM → LLM 想调工具就执行工具 → 把结果喂回去 → 继续调 LLM →
|
|
8
|
+
* 直到 LLM 不再调工具,输出最终回答。
|
|
9
|
+
*
|
|
10
|
+
* 和 kakadeai 主仓库 query.ts 用同样的 AsyncGenerator 模式:
|
|
11
|
+
* - 函数是 async function*,可以 yield 各种事件给 UI
|
|
12
|
+
* - UI 通过 for await 拿到事件,把它们映射到 React state
|
|
13
|
+
*
|
|
14
|
+
* 循环每轮(turn)做的事:
|
|
15
|
+
* a. 自动压缩检查(防止 context 撑爆)
|
|
16
|
+
* b. 调 LLM,把流式响应组装成完整的 assistant message
|
|
17
|
+
* c. 如果 assistant 不调工具 → 结束,return
|
|
18
|
+
* d. 否则按顺序执行每个工具调用,把 tool 消息追加到历史
|
|
19
|
+
* e. 进入下一轮(让模型看到 tool_result 后继续推理)
|
|
20
|
+
*
|
|
21
|
+
* 失控保护:maxTurns(默认 50)。
|
|
22
|
+
* 中断支持:AbortSignal 透传到 chat() 和 tool.call()。
|
|
23
|
+
* ============================================================
|
|
24
|
+
*/
|
|
25
|
+
import { autoCompactIfNeeded } from './context/compact.js';
|
|
26
|
+
import { microCompact, incrementTurn, expireOldEntries } from './context/microCompactLite.js';
|
|
27
|
+
import { isPromptTooLongError, reactiveCompactIfApplicable, } from './context/reactiveCompact.js';
|
|
28
|
+
import { chat } from './llm/client.js';
|
|
29
|
+
import { ALL_TOOLS, executeTool } from './tools/index.js';
|
|
30
|
+
/**
|
|
31
|
+
* 执行一次"用户输入 → 模型回答完成"的完整流程。
|
|
32
|
+
*
|
|
33
|
+
* yield 出来的 LoopEvent 会被 UI 用来更新界面。
|
|
34
|
+
*/
|
|
35
|
+
export async function* runQuery(userInput, options) {
|
|
36
|
+
const { provider, history, signal, sessionState } = options;
|
|
37
|
+
const maxTurns = options.maxTurns ?? 50;
|
|
38
|
+
// 1. 用户消息入栈
|
|
39
|
+
history.push({ role: 'user', content: userInput });
|
|
40
|
+
// 反应式压缩自救:本 runQuery 只允许触发一次,避免压缩失败导致死循环。
|
|
41
|
+
// 配合 reactiveCompact.ts 的 attempted(session 级)双层防爆。
|
|
42
|
+
let reactiveAttempted = false;
|
|
43
|
+
let turn = 0;
|
|
44
|
+
while (turn < maxTurns) {
|
|
45
|
+
turn++;
|
|
46
|
+
incrementTurn(sessionState?.microCompact);
|
|
47
|
+
expireOldEntries(sessionState?.microCompact);
|
|
48
|
+
if (signal?.aborted) {
|
|
49
|
+
// 中断标记帮助模型理解上下文
|
|
50
|
+
history.push({
|
|
51
|
+
role: 'user',
|
|
52
|
+
content: '(用户按下了 ESC/Ctrl+C 中断了任务)',
|
|
53
|
+
});
|
|
54
|
+
yield { type: 'error', error: '已被用户中断' };
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
// 2. 自动压缩
|
|
58
|
+
try {
|
|
59
|
+
const compact = await autoCompactIfNeeded(history, provider);
|
|
60
|
+
if (compact.compacted) {
|
|
61
|
+
yield { type: 'compact_start' };
|
|
62
|
+
// in-place 替换 history(保持调用方持有的引用有效)
|
|
63
|
+
history.length = 0;
|
|
64
|
+
history.push(...compact.messages);
|
|
65
|
+
yield { type: 'compact_done', before: compact.before, after: compact.after };
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
catch (e) {
|
|
69
|
+
// 压缩失败不要让整个对话挂掉
|
|
70
|
+
yield {
|
|
71
|
+
type: 'error',
|
|
72
|
+
error: `自动压缩失败(继续不压缩):${e.message}`,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
// 3. 调 LLM 并组装 assistant 消息
|
|
76
|
+
let assistantText = '';
|
|
77
|
+
/** 按 index 累积 tool_calls(OpenAI 协议下 tool_calls 是分片返回的) */
|
|
78
|
+
const toolCallsByIndex = [];
|
|
79
|
+
/**
|
|
80
|
+
* 思维链累积器。三个 provider 字段名各管一份;最后哪个非空就挂哪个,
|
|
81
|
+
* 多个非空就都挂上(不会冲突,下一轮请求时整个 message 透传给 API)。
|
|
82
|
+
*/
|
|
83
|
+
let reasoningContent = '';
|
|
84
|
+
let reasoningString = '';
|
|
85
|
+
const reasoningDetails = [];
|
|
86
|
+
try {
|
|
87
|
+
for await (const ev of chat({
|
|
88
|
+
provider,
|
|
89
|
+
messages: history,
|
|
90
|
+
tools: ALL_TOOLS,
|
|
91
|
+
signal,
|
|
92
|
+
})) {
|
|
93
|
+
if (ev.type === 'text_delta') {
|
|
94
|
+
assistantText += ev.delta;
|
|
95
|
+
yield { type: 'text', delta: ev.delta };
|
|
96
|
+
}
|
|
97
|
+
else if (ev.type === 'tool_call_delta') {
|
|
98
|
+
mergeToolCallDelta(toolCallsByIndex, ev);
|
|
99
|
+
}
|
|
100
|
+
else if (ev.type === 'reasoning_delta') {
|
|
101
|
+
if (ev.field === 'reasoning_content' && ev.delta) {
|
|
102
|
+
reasoningContent += ev.delta;
|
|
103
|
+
}
|
|
104
|
+
else if (ev.field === 'reasoning' && ev.delta) {
|
|
105
|
+
reasoningString += ev.delta;
|
|
106
|
+
}
|
|
107
|
+
else if (ev.field === 'reasoning_details' && ev.items) {
|
|
108
|
+
reasoningDetails.push(...ev.items);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
// ev.type === 'done' 时无需处理:循环自然结束
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
catch (e) {
|
|
115
|
+
if (e.name === 'AbortError') {
|
|
116
|
+
// 用户主动中断:记录标记消息,不报红色错误
|
|
117
|
+
history.push({
|
|
118
|
+
role: 'user',
|
|
119
|
+
content: '(用户按下了 ESC/Ctrl+C 中断了任务)',
|
|
120
|
+
});
|
|
121
|
+
yield { type: 'interrupted' };
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
// prompt_too_long 反应式自救:压缩历史 → 同 turn 重发 LLM
|
|
125
|
+
// LLM 看到压缩后的 9 段摘要 + 近期 verbatim 消息,能接着干活,
|
|
126
|
+
// 不会丢失中途的工具调用上下文。
|
|
127
|
+
if (isPromptTooLongError(e) && !reactiveAttempted) {
|
|
128
|
+
reactiveAttempted = true;
|
|
129
|
+
yield { type: 'compact_start' };
|
|
130
|
+
const result = await reactiveCompactIfApplicable(history, provider, e, sessionState?.reactive);
|
|
131
|
+
if (result.recovered) {
|
|
132
|
+
history.length = 0;
|
|
133
|
+
history.push(...result.messages);
|
|
134
|
+
yield {
|
|
135
|
+
type: 'compact_done',
|
|
136
|
+
before: result.before ?? 0,
|
|
137
|
+
after: result.after ?? 0,
|
|
138
|
+
};
|
|
139
|
+
turn--; // 不消耗 turn 配额,本轮重新走
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
// reactive 也失败 → 退回正常错误路径
|
|
143
|
+
}
|
|
144
|
+
yield { type: 'error', error: `LLM 调用失败:${e.message}` };
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
const assistantMsg = {
|
|
148
|
+
role: 'assistant',
|
|
149
|
+
content: assistantText.length > 0 ? assistantText : null,
|
|
150
|
+
...(toolCallsByIndex.length > 0 ? { tool_calls: toolCallsByIndex } : {}),
|
|
151
|
+
...(reasoningContent ? { reasoning_content: reasoningContent } : {}),
|
|
152
|
+
...(reasoningString ? { reasoning: reasoningString } : {}),
|
|
153
|
+
...(reasoningDetails.length > 0 ? { reasoning_details: reasoningDetails } : {}),
|
|
154
|
+
};
|
|
155
|
+
history.push(assistantMsg);
|
|
156
|
+
yield { type: 'assistant_message', message: assistantMsg };
|
|
157
|
+
// 4. 没有工具调用 → 整轮交互结束
|
|
158
|
+
if (toolCallsByIndex.length === 0) {
|
|
159
|
+
yield { type: 'turn_done' };
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
// 5. 顺序执行每个工具
|
|
163
|
+
for (const tc of toolCallsByIndex) {
|
|
164
|
+
if (signal?.aborted) {
|
|
165
|
+
// 中断标记帮助模型理解上下文:为什么输出被截断,用户可以重新输入
|
|
166
|
+
history.push({
|
|
167
|
+
role: 'user',
|
|
168
|
+
content: '(用户按下了 ESC/Ctrl+C 中断了任务)',
|
|
169
|
+
});
|
|
170
|
+
yield { type: 'error', error: '已被用户中断' };
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
const argsPreview = previewArgs(tc.function.arguments);
|
|
174
|
+
yield {
|
|
175
|
+
type: 'tool_start',
|
|
176
|
+
toolName: tc.function.name,
|
|
177
|
+
toolCallId: tc.id,
|
|
178
|
+
argsPreview,
|
|
179
|
+
};
|
|
180
|
+
const result = await executeTool(tc.function.name, tc.function.arguments, signal);
|
|
181
|
+
// 工具结果:失败保留错误信息,成功经微压缩后保留
|
|
182
|
+
const rawContent = result.ok ? result.content : `Error: ${result.error}`;
|
|
183
|
+
const content = microCompact(tc.function.name, rawContent, sessionState?.microCompact);
|
|
184
|
+
// 工具结果作为 tool 消息回填
|
|
185
|
+
history.push({
|
|
186
|
+
role: 'tool',
|
|
187
|
+
content,
|
|
188
|
+
tool_call_id: tc.id,
|
|
189
|
+
});
|
|
190
|
+
yield {
|
|
191
|
+
type: 'tool_end',
|
|
192
|
+
toolName: tc.function.name,
|
|
193
|
+
toolCallId: tc.id,
|
|
194
|
+
ok: result.ok,
|
|
195
|
+
content,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
// 6. 继续 while 让模型看到 tool_result 后继续推理
|
|
199
|
+
}
|
|
200
|
+
yield {
|
|
201
|
+
type: 'error',
|
|
202
|
+
error: `达到最大轮数 ${maxTurns},提前结束(防止失控)。如果合理可以提高 maxTurns。`,
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
// ---------------- 辅助函数 ----------------
|
|
206
|
+
/** 把一条 tool_call_delta 合并到累积数组上 */
|
|
207
|
+
function mergeToolCallDelta(acc, ev) {
|
|
208
|
+
let slot = acc[ev.index];
|
|
209
|
+
if (!slot) {
|
|
210
|
+
slot = {
|
|
211
|
+
id: '',
|
|
212
|
+
type: 'function',
|
|
213
|
+
function: { name: '', arguments: '' },
|
|
214
|
+
};
|
|
215
|
+
acc[ev.index] = slot;
|
|
216
|
+
}
|
|
217
|
+
if (ev.id)
|
|
218
|
+
slot.id = ev.id;
|
|
219
|
+
if (ev.name)
|
|
220
|
+
slot.function.name += ev.name;
|
|
221
|
+
if (ev.argumentsDelta)
|
|
222
|
+
slot.function.arguments += ev.argumentsDelta;
|
|
223
|
+
}
|
|
224
|
+
/** 给 UI 展示用的参数预览(只取前 60 字符) */
|
|
225
|
+
function previewArgs(rawJson) {
|
|
226
|
+
const oneLine = rawJson.replace(/\s+/g, ' ').trim();
|
|
227
|
+
if (oneLine.length <= 60)
|
|
228
|
+
return oneLine;
|
|
229
|
+
return oneLine.slice(0, 60) + '...';
|
|
230
|
+
}
|
package/src/main.js
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
import { render } from 'ink';
|
|
4
|
+
import { existsSync, mkdirSync } from 'node:fs';
|
|
5
|
+
import { createRequire } from 'node:module';
|
|
6
|
+
import { resolve } from 'node:path';
|
|
7
|
+
const require = createRequire(import.meta.url);
|
|
8
|
+
const pkg = require('../package.json');
|
|
9
|
+
import { extractCwdArg } from './bootstrap/cwdArg.js';
|
|
10
|
+
import { initWorkingDir, getWorkingDir } from './bootstrap/workingDir.js';
|
|
11
|
+
import { applyToolKeysToEnv, loadProviderLayered } from './config.js';
|
|
12
|
+
import { loadContext } from './context/persistContext.js';
|
|
13
|
+
import { migrateLegacyContext } from './context/sessionPath.js';
|
|
14
|
+
import { buildFullSystemPrompt } from './prompts/system.js';
|
|
15
|
+
import { ALL_TOOLS } from './tools/index.js';
|
|
16
|
+
import { Root } from './ui/Root.js';
|
|
17
|
+
import { runPrintMode } from './cli/print.js';
|
|
18
|
+
async function main() {
|
|
19
|
+
// ★ -d/--cwd:在锁定工作目录之前先建目录 + chdir,让后续逻辑全在 -d 下运行
|
|
20
|
+
// 不存在则自动 mkdir -p;后续 initWorkingDir() 自然拿到新 cwd
|
|
21
|
+
const args = process.argv.slice(2);
|
|
22
|
+
const dirArg = extractCwdArg(args);
|
|
23
|
+
if (dirArg) {
|
|
24
|
+
const abs = resolve(dirArg);
|
|
25
|
+
if (!existsSync(abs)) {
|
|
26
|
+
mkdirSync(abs, { recursive: true });
|
|
27
|
+
}
|
|
28
|
+
process.chdir(abs);
|
|
29
|
+
}
|
|
30
|
+
// ★ 必须在所有其他逻辑之前——锁定工作目录,并迁移旧版 last-context.json
|
|
31
|
+
initWorkingDir();
|
|
32
|
+
await migrateLegacyContext(getWorkingDir());
|
|
33
|
+
// ★ 把 saved config 里的工具 key(如 TAVILY_API_KEY)注入 process.env。
|
|
34
|
+
// npm 全局安装跑 `node dist/main.js` 不会自动加载 .env,靠这一步把向导
|
|
35
|
+
// 收集到的 key 写回 env,否则 WebSearch 等工具 process.env.* 读不到。
|
|
36
|
+
await applyToolKeysToEnv();
|
|
37
|
+
// --help 优先显示
|
|
38
|
+
if (args.includes('-h') || args.includes('--help')) {
|
|
39
|
+
printHelp();
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
// --version: 零依赖快速返回(对齐 kakadeai fast-path 设计)
|
|
43
|
+
if (args.includes('--version') || args.includes('-V')) {
|
|
44
|
+
console.log(`minimal-agent v${pkg.version}`);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
const isPrintMode = args.includes('-p') || args.includes('--print');
|
|
48
|
+
if (isPrintMode) {
|
|
49
|
+
// CLI 非交互模式:先 env,再 ~/.minimal-agent/config.json fallback;都没就退出
|
|
50
|
+
// (宿主 spawn 子进程时通常没 export env,靠 TUI 向导写出的 config.json 持久化)
|
|
51
|
+
const provider = await loadProviderLayered();
|
|
52
|
+
if (!provider) {
|
|
53
|
+
process.stderr.write(`\n未找到 provider 配置。\n\n` +
|
|
54
|
+
`请二选一:\n` +
|
|
55
|
+
` 1. 设置环境变量 MINIMAL_AGENT_BASE_URL / MINIMAL_AGENT_API_KEY / MINIMAL_AGENT_MODEL\n` +
|
|
56
|
+
` 2. 先直接运行 \`minimal-agent\`(TUI)完成首次配置向导;向导会写出 ~/.minimal-agent/config.json,之后 -p 模式自动复用\n\n`);
|
|
57
|
+
process.exit(1);
|
|
58
|
+
}
|
|
59
|
+
const initialHistory = await buildInitialHistory();
|
|
60
|
+
const verbose = args.includes('--verbose') || args.includes('-v');
|
|
61
|
+
await runPrintMode(provider, args, initialHistory, { verbose });
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
// TUI 交互模式:env 不全则 fallback 到 ~/.minimal-agent/config.json;
|
|
65
|
+
// 都没有则 Root 渲染配置向导。
|
|
66
|
+
const initialProvider = await loadProviderLayered();
|
|
67
|
+
const { waitUntilExit } = render(_jsx(Root, { initialProvider: initialProvider }));
|
|
68
|
+
await waitUntilExit();
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* 启动时构造 history:用当前 cwd / 工具的最新 system prompt,
|
|
72
|
+
* 后面接上 last-context.json 里上次保存的对话(如果有)。
|
|
73
|
+
*
|
|
74
|
+
* 总是用 fresh system prompt(即便 last-context.json 里也存了一份),
|
|
75
|
+
* 这样换 cwd 或工具后也不会沿用错的环境上下文。
|
|
76
|
+
*/
|
|
77
|
+
async function buildInitialHistory() {
|
|
78
|
+
const content = await buildFullSystemPrompt(getWorkingDir(), ALL_TOOLS);
|
|
79
|
+
const fresh = { role: 'system', content };
|
|
80
|
+
const persisted = await loadContext();
|
|
81
|
+
if (!persisted || persisted.length === 0)
|
|
82
|
+
return [fresh];
|
|
83
|
+
const rest = persisted[0]?.role === 'system' ? persisted.slice(1) : persisted;
|
|
84
|
+
return [fresh, ...rest];
|
|
85
|
+
}
|
|
86
|
+
function printHelp() {
|
|
87
|
+
console.log(`
|
|
88
|
+
minimal-agent - 轻量级 AI 编程助手
|
|
89
|
+
|
|
90
|
+
用法:
|
|
91
|
+
minimal-agent [选项] [提示...]
|
|
92
|
+
echo "提示" | minimal-agent [选项]
|
|
93
|
+
|
|
94
|
+
选项:
|
|
95
|
+
-p, --print 非交互模式,直接执行单次问答
|
|
96
|
+
-v, --verbose 显示详细输出(工具调用、压缩信息)
|
|
97
|
+
-d, --cwd <dir> 指定工作目录(不存在自动创建);启动时 chdir 到这里,
|
|
98
|
+
上下文文件、工具相对路径、.env 加载都以此为基准
|
|
99
|
+
-h, --help 显示帮助信息
|
|
100
|
+
|
|
101
|
+
会话记忆:
|
|
102
|
+
按当前工作目录隔离,自动加载 / 保存到
|
|
103
|
+
~/.minimal-agent/sessions/<目录哈希>.json
|
|
104
|
+
TUI 与 -p 共享同一目录的上下文;TUI 中输入 /new 仅清当前目录的会话
|
|
105
|
+
|
|
106
|
+
示例:
|
|
107
|
+
minimal-agent -p "帮我写一个 hello world"
|
|
108
|
+
echo "解释代码" | minimal-agent -p
|
|
109
|
+
minimal-agent -p --verbose "运行测试并报告结果"
|
|
110
|
+
minimal-agent -p "处理资料" -d /tmp/job-123 # 工作目录隔离
|
|
111
|
+
`);
|
|
112
|
+
}
|
|
113
|
+
main().catch((e) => {
|
|
114
|
+
process.stderr.write(`\n未捕获异常:${e.message}\n${e.stack ?? ''}\n`);
|
|
115
|
+
process.exit(1);
|
|
116
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ============================================================
|
|
3
|
+
* src/plugin-sdk.ts —— 插件作者的稳定 import 门面
|
|
4
|
+
* ------------------------------------------------------------
|
|
5
|
+
* Drop-in 插件契约的"窄门":插件根目录下的 plugin.ts 只允许从
|
|
6
|
+
* '../../src/plugin-sdk.js' 取运行时(NodeNext 标准,Bun 自动回退到 .ts
|
|
7
|
+
* 源文件,Node 跑 install 后编译产物),不直接访问 src/ 内部路径。
|
|
8
|
+
*
|
|
9
|
+
* 好处:
|
|
10
|
+
* - 框架内部重构(loop.ts / llm/client.ts / tools/* 移动)不破坏插件
|
|
11
|
+
* - 插件作者只需记一个 import 源
|
|
12
|
+
* - 暴露面收口(这里没的 API 不开放给插件)
|
|
13
|
+
*
|
|
14
|
+
* 零运行时开销:纯 re-export,TS 编译后等价于直接 import。
|
|
15
|
+
* ============================================================
|
|
16
|
+
*/
|
|
17
|
+
// --- 运行时入口 ---
|
|
18
|
+
export { runQuery } from './loop.js';
|
|
19
|
+
export { chat } from './llm/client.js';
|
|
20
|
+
export { ALL_TOOLS, executeTool, getToolByName } from './tools/index.js';
|
|
21
|
+
export { getWorkingDir } from './bootstrap/workingDir.js';
|
|
22
|
+
export { getResourceSearchPaths } from './utils/resourcePaths.js';
|
|
23
|
+
export { triggerHook } from './plugins/hookEngine.js';
|
|
24
|
+
export { createSessionState } from './session/sessionState.js';
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { readFile, readdir } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { getResourceSearchPaths } from '../utils/resourcePaths.js';
|
|
4
|
+
const pluginCache = new Map();
|
|
5
|
+
let discoveryDone = false;
|
|
6
|
+
function stripQuotes(s) {
|
|
7
|
+
const trimmed = s.trim();
|
|
8
|
+
if ((trimmed.startsWith('"') && trimmed.endsWith('"')) ||
|
|
9
|
+
(trimmed.startsWith("'") && trimmed.endsWith("'"))) {
|
|
10
|
+
return trimmed.slice(1, -1);
|
|
11
|
+
}
|
|
12
|
+
return trimmed;
|
|
13
|
+
}
|
|
14
|
+
function parseMarkdownFrontmatter(content) {
|
|
15
|
+
const match = content.match(/^---\n([\s\S]*?)\n---/);
|
|
16
|
+
if (!match)
|
|
17
|
+
return {};
|
|
18
|
+
const frontmatter = {};
|
|
19
|
+
for (const line of match[1].split('\n')) {
|
|
20
|
+
const colon = line.indexOf(':');
|
|
21
|
+
if (colon < 0)
|
|
22
|
+
continue;
|
|
23
|
+
const key = line.slice(0, colon).trim();
|
|
24
|
+
const value = line.slice(colon + 1).trim();
|
|
25
|
+
if (key)
|
|
26
|
+
frontmatter[key] = stripQuotes(value);
|
|
27
|
+
}
|
|
28
|
+
return frontmatter;
|
|
29
|
+
}
|
|
30
|
+
async function loadPlugin(pluginDirPath) {
|
|
31
|
+
const dirName = pluginDirPath.split('/').pop() ?? pluginDirPath;
|
|
32
|
+
const manifestPath = join(pluginDirPath, '.claude-plugin', 'plugin.json');
|
|
33
|
+
let manifestName = dirName;
|
|
34
|
+
let manifestVersion;
|
|
35
|
+
let manifestDesc;
|
|
36
|
+
try {
|
|
37
|
+
const raw = await readFile(manifestPath, 'utf8');
|
|
38
|
+
const parsed = JSON.parse(raw);
|
|
39
|
+
manifestName = parsed.name ?? dirName;
|
|
40
|
+
manifestVersion = parsed.version;
|
|
41
|
+
manifestDesc = parsed.description;
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
}
|
|
45
|
+
const commandsDir = join(pluginDirPath, 'commands');
|
|
46
|
+
const commands = [];
|
|
47
|
+
try {
|
|
48
|
+
const entries = await readdir(commandsDir, { withFileTypes: true });
|
|
49
|
+
for (const entry of entries) {
|
|
50
|
+
if (!entry.name.endsWith('.md'))
|
|
51
|
+
continue;
|
|
52
|
+
const cmdPath = join(commandsDir, entry.name);
|
|
53
|
+
try {
|
|
54
|
+
const content = await readFile(cmdPath, 'utf8');
|
|
55
|
+
const fm = parseMarkdownFrontmatter(content);
|
|
56
|
+
const sep = content.indexOf('\n---', 4);
|
|
57
|
+
const body = sep >= 0 ? content.slice(sep + 4).trim() : content.trim();
|
|
58
|
+
commands.push({
|
|
59
|
+
name: entry.name.replace(/\.md$/, ''),
|
|
60
|
+
description: fm.description ?? '(no description)',
|
|
61
|
+
argumentHint: fm['argument-hint'],
|
|
62
|
+
pluginName: manifestName,
|
|
63
|
+
pluginRoot: pluginDirPath,
|
|
64
|
+
promptBody: body,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
}
|
|
73
|
+
const hooksJsonPath = join(pluginDirPath, 'hooks', 'hooks.json');
|
|
74
|
+
let hasStopHook = false;
|
|
75
|
+
try {
|
|
76
|
+
const hooksRaw = await readFile(hooksJsonPath, 'utf8');
|
|
77
|
+
const hooksParsed = JSON.parse(hooksRaw);
|
|
78
|
+
const stopHooks = hooksParsed?.hooks?.Stop;
|
|
79
|
+
if (Array.isArray(stopHooks) && stopHooks.length > 0) {
|
|
80
|
+
hasStopHook = true;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
}
|
|
85
|
+
if (commands.length === 0 && !hasStopHook)
|
|
86
|
+
return null;
|
|
87
|
+
return {
|
|
88
|
+
name: manifestName,
|
|
89
|
+
version: manifestVersion,
|
|
90
|
+
description: manifestDesc,
|
|
91
|
+
root: pluginDirPath,
|
|
92
|
+
commands,
|
|
93
|
+
hasStopHook,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
export async function discoverPlugins() {
|
|
97
|
+
if (discoveryDone && pluginCache.size > 0) {
|
|
98
|
+
return Array.from(pluginCache.values());
|
|
99
|
+
}
|
|
100
|
+
pluginCache.clear();
|
|
101
|
+
discoveryDone = true;
|
|
102
|
+
// 双源扫描:cwd 优先 + packageRoot fallback;按数组顺序处理,已有 manifestName 跳过
|
|
103
|
+
const searchPaths = getResourceSearchPaths('plugins', import.meta.url);
|
|
104
|
+
for (const root of searchPaths) {
|
|
105
|
+
try {
|
|
106
|
+
const entries = await readdir(root, { withFileTypes: true });
|
|
107
|
+
for (const entry of entries) {
|
|
108
|
+
if (!entry.isDirectory())
|
|
109
|
+
continue;
|
|
110
|
+
if (entry.name.startsWith('.'))
|
|
111
|
+
continue;
|
|
112
|
+
const plugin = await loadPlugin(join(root, entry.name));
|
|
113
|
+
if (!plugin)
|
|
114
|
+
continue;
|
|
115
|
+
if (pluginCache.has(plugin.name))
|
|
116
|
+
continue; // cwd 已注册的 manifestName,packageRoot 同名跳过
|
|
117
|
+
pluginCache.set(plugin.name, plugin);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return Array.from(pluginCache.values());
|
|
124
|
+
}
|
|
125
|
+
/** 仅供测试:重置 discovery cache。 */
|
|
126
|
+
export function _resetPluginCache() {
|
|
127
|
+
pluginCache.clear();
|
|
128
|
+
discoveryDone = false;
|
|
129
|
+
}
|
|
130
|
+
export function resolveCommand(input) {
|
|
131
|
+
const trimmed = input.trimStart();
|
|
132
|
+
if (!trimmed.startsWith('/'))
|
|
133
|
+
return null;
|
|
134
|
+
const spaceIdx = trimmed.indexOf(' ', 1);
|
|
135
|
+
const cmdName = spaceIdx >= 0 ? trimmed.slice(1, spaceIdx) : trimmed.slice(1);
|
|
136
|
+
const args = spaceIdx >= 0 ? trimmed.slice(spaceIdx + 1) : '';
|
|
137
|
+
for (const plugin of pluginCache.values()) {
|
|
138
|
+
for (const cmd of plugin.commands) {
|
|
139
|
+
if (cmd.name === cmdName) {
|
|
140
|
+
return { cmd, arguments: args };
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
export function buildCommandInput(resolved) {
|
|
147
|
+
const { cmd, arguments: args } = resolved;
|
|
148
|
+
let input = cmd.promptBody;
|
|
149
|
+
input = input.replaceAll('${CLAUDE_PLUGIN_ROOT}', cmd.pluginRoot);
|
|
150
|
+
input = input.replaceAll('$ARGUMENTS', args);
|
|
151
|
+
input = input.replaceAll('${ARGUMENTS}', args);
|
|
152
|
+
if (args.trim()) {
|
|
153
|
+
input += `\n\n用户参数: ${args.trim()}`;
|
|
154
|
+
}
|
|
155
|
+
return input;
|
|
156
|
+
}
|
|
157
|
+
export function listAvailableCommands() {
|
|
158
|
+
const result = [];
|
|
159
|
+
for (const plugin of pluginCache.values()) {
|
|
160
|
+
for (const cmd of plugin.commands) {
|
|
161
|
+
result.push({
|
|
162
|
+
command: `/${cmd.name}`,
|
|
163
|
+
plugin: plugin.name,
|
|
164
|
+
description: cmd.description,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return result;
|
|
169
|
+
}
|