minimal-agent 0.6.1 → 0.6.3

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/src/main.js CHANGED
@@ -5,16 +5,28 @@ import { existsSync, mkdirSync } from 'node:fs';
5
5
  import { createRequire } from 'node:module';
6
6
  import { resolve } from 'node:path';
7
7
  const require = createRequire(import.meta.url);
8
- const pkg = require('../package.json');
8
+ const pkg = (() => {
9
+ try {
10
+ return require('../package.json');
11
+ }
12
+ catch {
13
+ return {
14
+ version: typeof __GREEN_BUILD_VERSION__ === 'string'
15
+ ? __GREEN_BUILD_VERSION__
16
+ : '0.0.0',
17
+ };
18
+ }
19
+ })();
9
20
  import { extractCwdArg } from './bootstrap/cwdArg.js';
10
21
  import { initWorkingDir, getWorkingDir } from './bootstrap/workingDir.js';
11
22
  import { applyToolKeysToEnv, loadProviderLayered } from './config.js';
12
- import { loadContext } from './context/persistContext.js';
23
+ import { clearContext, loadContext } from './context/persistContext.js';
13
24
  import { migrateLegacyContext } from './context/sessionPath.js';
14
25
  import { buildFullSystemPrompt } from './prompts/system.js';
15
26
  import { ALL_TOOLS } from './tools/index.js';
16
27
  import { Root } from './ui/Root.js';
17
28
  import { runPrintMode } from './cli/print.js';
29
+ import { runStreamJsonMode } from './cli/streamJson.js';
18
30
  import { extractFlagValue } from './cli/args.js';
19
31
  // re-export:对外保持「main.tsx 提供 extractFlagValue」的语义;实现住在 cli/args.ts,
20
32
  // 这样单测可零副作用 import(main.tsx 顶层 main() 自调用会启动整个 app,不可被测试 import)。
@@ -48,12 +60,23 @@ async function main() {
48
60
  console.log(`minimal-agent v${pkg.version}`);
49
61
  return;
50
62
  }
63
+ // --clear:清空当前工作目录的会话(归档 + 清空)后退出。供 webchat 的 /new 调用。
64
+ // 无需 provider;上面 -d/--cwd 已 chdir + initWorkingDir,clearContext() 作用于该目录。
65
+ if (args.includes('--clear')) {
66
+ await clearContext();
67
+ return;
68
+ }
51
69
  const isPrintMode = args.includes('-p') || args.includes('--print');
52
70
  if (isPrintMode) {
53
71
  // 先把输出格式解析出来——provider 缺失分支也要按 json 契约给信号。
54
72
  // 只接受 'text' | 'json',其它值(含拼错)一律回落 'text'(向后兼容)。
55
73
  const rawFormat = extractFlagValue(args, ['--output-format']);
56
- const outputFormat = rawFormat === 'json' ? 'json' : 'text';
74
+ // text(默认,逐字节向后兼容)/ json(一行结局契约)/ stream-json(逐行 LoopEvent,webchat broker 用)
75
+ const outputFormat = rawFormat === 'json'
76
+ ? 'json'
77
+ : rawFormat === 'stream-json'
78
+ ? 'stream-json'
79
+ : 'text';
57
80
  // --max-turns:parseInt,非法(NaN)→ undefined(走 loop 默认 50)
58
81
  const rawMaxTurns = extractFlagValue(args, ['--max-turns']);
59
82
  const parsedMaxTurns = rawMaxTurns !== undefined ? Number.parseInt(rawMaxTurns, 10) : Number.NaN;
@@ -74,6 +97,10 @@ async function main() {
74
97
  error: 'provider config not found',
75
98
  }));
76
99
  }
100
+ else if (outputFormat === 'stream-json') {
101
+ // stream-json:吐一行 error 事件(webchat 前端识别为致命错误)
102
+ console.log(JSON.stringify({ type: 'error', error: 'provider config not found', code: 'llm_error' }));
103
+ }
77
104
  process.stderr.write(`\n未找到 provider 配置。\n\n` +
78
105
  `请二选一:\n` +
79
106
  ` 1. 设置环境变量 MINIMAL_AGENT_BASE_URL / MINIMAL_AGENT_API_KEY / MINIMAL_AGENT_MODEL\n` +
@@ -82,6 +109,14 @@ async function main() {
82
109
  }
83
110
  const initialHistory = await buildInitialHistory();
84
111
  const verbose = args.includes('--verbose') || args.includes('-v');
112
+ // stream-json 走独立流式模式(webchat broker / 网关用);text/json 仍走原 runPrintMode(契约不变)。
113
+ if (outputFormat === 'stream-json') {
114
+ await runStreamJsonMode(provider, args, initialHistory, {
115
+ maxTurns,
116
+ compact: args.includes('--compact'),
117
+ });
118
+ return;
119
+ }
85
120
  await runPrintMode(provider, args, initialHistory, { verbose, outputFormat, maxTurns });
86
121
  return;
87
122
  }
@@ -120,9 +155,12 @@ minimal-agent - 轻量级 AI 编程助手
120
155
  -v, --verbose 显示详细输出(工具调用、压缩信息)
121
156
  -d, --cwd <dir> 指定工作目录(不存在自动创建);启动时 chdir 到这里,
122
157
  上下文文件、工具相对路径、.env 加载都以此为基准
123
- --output-format <fmt> text(默认)= 纯文本答案;json = stdout 输出一行结构化
124
- 结局契约(ok / result / stop_reason / num_turns / error
158
+ --output-format <fmt> text(默认)= 纯文本答案;json = 一行结局契约
159
+ ok / result / stop_reason / num_turns / error);
160
+ stream-json = 逐行 LoopEvent(webchat / 网关 broker 用)
125
161
  --max-turns <n> 最大工具循环轮数(防失控;缺省 50)
162
+ --clear 清空当前工作目录的会话(归档 + 清空)后退出
163
+ --compact 与 -p --output-format stream-json 连用:压缩当前会话
126
164
  -h, --help 显示帮助信息
127
165
 
128
166
  会话记忆:
@@ -24,9 +24,28 @@
24
24
  * ============================================================
25
25
  */
26
26
  import { existsSync, statSync } from 'node:fs';
27
- import { join } from 'node:path';
27
+ import { basename, join } from 'node:path';
28
28
  import { pathToFileURL } from 'node:url';
29
29
  const loaderCache = new Map();
30
+ /**
31
+ * 内置插件静态注册表(仅绿色版 compile 入口 entry.compiled.tsx 会填充)。
32
+ *
33
+ * 背景:`bun --compile` 后,rich 插件的 plugin.ts 顺链 import 的框架 SDK
34
+ * (`../../../src/plugin-sdk.js`)已被焊进 exe、磁盘上不存在 → 动态 import
35
+ * 外部 plugin.ts 必抛错。绿色版改为在 compile 入口**静态 import** 内置插件
36
+ * (`src/plugins/builtinRegistry.ts`),让 bun 把执行器整树打进 exe,再经
37
+ * setBuiltinPluginApi 注册到这里;loadPluginApi 在磁盘加载缺失/失败时回退到此表。
38
+ *
39
+ * dev / npm 模式:entry.compiled 永不被 import → 此表恒为空 → 回退分支不触发
40
+ * → loadPluginApi 行为与改动前逐字节一致。
41
+ *
42
+ * key = 插件目录名(`plugins/<id>/` 的 `<id>`,等于 `basename(pluginRoot)`)。
43
+ */
44
+ const builtinApis = new Map();
45
+ /** 由绿色版 compile 入口调用,注册内置插件执行器。dev/npm 不会调用。 */
46
+ export function setBuiltinPluginApi(key, api) {
47
+ builtinApis.set(key, api);
48
+ }
30
49
  function isPluginApi(value) {
31
50
  if (!value || typeof value !== 'object')
32
51
  return false;
@@ -62,8 +81,24 @@ export async function loadPluginApi(pluginRoot) {
62
81
  // stat 失败静默,不影响加载
63
82
  }
64
83
  }
84
+ // 绿色版回退:磁盘加载缺失/失败时,按目录名查 exe 内静态注册表。
85
+ // dev/npm 注册表恒空 → 永远返回 null → 不改变下方任何分支的最终结果。
86
+ const builtinKey = basename(pluginRoot);
87
+ const builtinFallback = () => {
88
+ const api = builtinApis.get(builtinKey);
89
+ if (api) {
90
+ loaderCache.set(pluginRoot, api);
91
+ return api;
92
+ }
93
+ return null;
94
+ };
65
95
  const pluginEntry = jsExists ? jsPath : tsExists ? tsPath : undefined;
66
96
  if (!pluginEntry) {
97
+ // 磁盘无 plugin.js/.ts:绿色目录只铺声明式部分(plugin.json + commands/),
98
+ // 执行器在 exe 内注册表 → 这里命中。dev/npm 注册表空 → 维持原 null。
99
+ const fb = builtinFallback();
100
+ if (fb)
101
+ return fb;
67
102
  loaderCache.set(pluginRoot, null);
68
103
  return null;
69
104
  }
@@ -79,6 +114,11 @@ export async function loadPluginApi(pluginRoot) {
79
114
  return candidate;
80
115
  }
81
116
  catch (err) {
117
+ // 绿色版:磁盘铺了 plugin.js 但它顺链 import 的框架 SDK 在 exe 内、磁盘没有
118
+ // → import 抛错 → 回退到 exe 内注册表。dev/npm 注册表空 → 维持原 warn + null。
119
+ const fb = builtinFallback();
120
+ if (fb)
121
+ return fb;
82
122
  console.warn(`[minimal-agent] failed to load plugin entry at ${pluginRoot}: ${err instanceof Error ? err.message : String(err)}`);
83
123
  loaderCache.set(pluginRoot, null);
84
124
  return null;
@@ -26,7 +26,9 @@ import { randomUUID } from 'node:crypto';
26
26
  // 模块级状态:当前会话的 handle(lazy 初始化)
27
27
  let current = null;
28
28
  function transcriptDir() {
29
- return join(homedir(), '.minimal-agent', 'transcripts');
29
+ // 优先使用环境变量(测试可覆盖;Docker/K8s 也常靠 ENV 定位 home),
30
+ // fallback 到 os.homedir()(生产环境缺 ENV 时用系统默认值)。
31
+ return join(process.env.HOME ?? process.env.USERPROFILE ?? homedir(), '.minimal-agent', 'transcripts');
30
32
  }
31
33
  function generateSessionId() {
32
34
  return `${Date.now().toString(36)}-${randomUUID().slice(0, 8)}`;
@@ -50,6 +50,12 @@ import { scanDestructiveCommand } from './warnings.js';
50
50
  // ---------------- 0. 常量 ----------------
51
51
  const DEFAULT_TIMEOUT_MS = 120_000; // 2 min
52
52
  const MAX_TIMEOUT_MS = 600_000; // 10 min
53
+ /**
54
+ * G1:stdout / stderr 即时缓冲上限(各自)。命令狂输出(如 cat 大文件 / yes)时,
55
+ * 在最终统一截断之前先把内存封顶,防 OOM。取最终展示截断阈值的 2 倍 —— 正常输出不受影响,
56
+ * 超大输出也只占有限内存(最终仍按 DEFAULT_MAX_RESULT_SIZE_CHARS 截断展示)。
57
+ */
58
+ const MAX_STREAM_BUFFER_CHARS = DEFAULT_MAX_RESULT_SIZE_CHARS * 2;
53
59
  /**
54
60
  * 灾难性命令黑名单。
55
61
  * 命中任意一条就直接拒绝执行 —— 这层是"防猪队友"而不是真沙盒。
@@ -142,6 +148,27 @@ export function checkForbidden(command) {
142
148
  }
143
149
  return null;
144
150
  }
151
+ /**
152
+ * 子进程环境变量:从 process.env 复制后**剥离 agent 自己的密钥**
153
+ * (`MINIMAL_AGENT_*` / `TAVILY_*`)。
154
+ *
155
+ * 动机(安全):main.tsx 启动时会把 saved config 里的 key 注入 process.env。
156
+ * 若原样把 process.env 透传给 Bash 子进程,模型只要跑 `echo $MINIMAL_AGENT_API_KEY`
157
+ * 就能把 key(绿色版里是卖家预置的)读进 tool result、回传 LLM、甚至打到 -p stdout。
158
+ * 这些 env 是 agent 自身配置,子进程命令并不需要 —— 直接删掉,最小化泄露面。
159
+ * 其余变量(PATH / HOME / 用户自己 export 的)照常透传。
160
+ *
161
+ * 导出供测试断言。
162
+ */
163
+ export function sanitizedChildEnv(base = process.env) {
164
+ const env = { ...base };
165
+ for (const key of Object.keys(env)) {
166
+ if (key.startsWith('MINIMAL_AGENT_') || key.startsWith('TAVILY_')) {
167
+ delete env[key];
168
+ }
169
+ }
170
+ return env;
171
+ }
145
172
  // ---------------- 1. Zod 输入 schema ----------------
146
173
  const inputSchema = z.object({
147
174
  command: z
@@ -256,17 +283,20 @@ async function call(input, signal) {
256
283
  shell: true,
257
284
  cwd: getWorkingDir(),
258
285
  signal: ac.signal,
259
- env: process.env,
286
+ env: sanitizedChildEnv(),
260
287
  windowsHide: true,
261
288
  });
262
289
  child.stdout?.setEncoding('utf8');
263
290
  child.stderr?.setEncoding('utf8');
264
291
  child.stdout?.on('data', (chunk) => {
265
- stdout += chunk;
266
- // 读到一半就超额时不强行 kill;最后再统一截断,避免日志噪声
292
+ // G1:即时封顶,防止狂输出命令在最终截断前撑爆内存(OOM)。不强行 kill,
293
+ // 让命令自己跑完或被 timeout 收走;超出上限的部分直接丢弃,最终仍按上限截断展示。
294
+ if (stdout.length < MAX_STREAM_BUFFER_CHARS)
295
+ stdout += chunk;
267
296
  });
268
297
  child.stderr?.on('data', (chunk) => {
269
- stderr += chunk;
298
+ if (stderr.length < MAX_STREAM_BUFFER_CHARS)
299
+ stderr += chunk;
270
300
  });
271
301
  child.on('error', (e) => {
272
302
  // signal abort 会先触发 error('AbortError');把这种 case 转成正常分支
@@ -26,6 +26,7 @@
26
26
  import { spawn } from 'node:child_process';
27
27
  import { chmodSync, existsSync } from 'node:fs';
28
28
  import { resolve } from 'node:path';
29
+ import { execSiblingDir } from '../../utils/greenRoot.js';
29
30
  import { findPackageRoot } from '../../utils/packageRoot.js';
30
31
  let cached;
31
32
  /**
@@ -53,6 +54,15 @@ async function detect() {
53
54
  ensureExecutable(vendored);
54
55
  return vendored;
55
56
  }
57
+ // ②.5 绿色版(bun --compile 单文件):exe 同级 vendor/ripgrep/<arch>-<platform>/rg
58
+ // compile 后 import.meta.url 是虚拟路径,findPackageRoot 失效(② 命不中),
59
+ // 绿色目录把 vendor/ 摆在可执行文件旁边,从这里捞回来。dev/npm 下 exe 同级
60
+ // 没有 vendor/ → existsSync 过滤掉 → 不影响 ②/③/④ 原有优先级。
61
+ const sibling = resolve(execSiblingDir(), 'vendor', 'ripgrep', subdir(), exeName());
62
+ if (existsSync(sibling)) {
63
+ ensureExecutable(sibling);
64
+ return sibling;
65
+ }
56
66
  // ③ PATH 上的 rg —— 直接试 spawn rg --version
57
67
  if (await trySpawn('rg'))
58
68
  return 'rg';
@@ -15,11 +15,12 @@
15
15
  * 再额外传入版本号 prop 配合依赖即可。
16
16
  * ============================================================
17
17
  */
18
- import { AUTOCOMPACT_BUFFER_TOKENS } from '../../context/compact.js';
18
+ import { getCompactThreshold } from '../../context/compact.js';
19
19
  import { countMessagesTokens } from '../../context/tokenCounter.js';
20
20
  export function useTokenUsage(messages, provider) {
21
21
  const tokens = countMessagesTokens(messages);
22
- const threshold = Math.max(1000, provider.contextWindow - AUTOCOMPACT_BUFFER_TOKENS);
22
+ // loop 实际触发同源(getCompactThreshold:按 contextWindow × compactRatio 比例 + 8K 下限)
23
+ const threshold = getCompactThreshold(provider);
23
24
  return {
24
25
  tokens,
25
26
  threshold,
@@ -0,0 +1,33 @@
1
+ /**
2
+ * ============================================================
3
+ * src/utils/greenRoot.ts —— 绿色版(bun --compile 单文件)资源探测锚点
4
+ * ------------------------------------------------------------
5
+ * 问题:`bun build --compile` 把运行时 + 全部 JS 焊进单个可执行文件,
6
+ * 此时 `import.meta.url` 指向嵌入式虚拟路径(`/$bunfs/...`),磁盘上
7
+ * 既没有 package.json 也没有源码树 —— `findPackageRoot` 会抛错,靠它
8
+ * 定位的 `vendor/ripgrep/`、`skills/`、`plugins/`、`workflows/` 全失效。
9
+ *
10
+ * 方案:绿色版把这些外部资产与可执行文件**放在同一目录**,运行时用
11
+ * `dirname(process.execPath)` 找回来(exe 同级)。
12
+ *
13
+ * 为什么 dev / npm 模式零影响:
14
+ * - dev(`bun run src/main.tsx`):execPath = bun 二进制,其同级目录
15
+ * (如 `~/.bun/bin/`)没有 vendor/skills/plugins。
16
+ * - npm 全局安装(`node dist/main.js`):execPath = node 二进制,其
17
+ * 同级目录同样没有这些资源目录。
18
+ * - 故调用方一律对返回值做 `existsSync` 过滤:dev/npm 下命不中 → 被
19
+ * 丢弃 → 行为与现状逐字节一致。只有真·绿色版(资产摆在 exe 旁边)
20
+ * 才会命中。因此这里**无条件返回** execPath 目录即可,无需判别
21
+ * "是否 compiled"。
22
+ * ============================================================
23
+ */
24
+ import { dirname } from 'node:path';
25
+ /**
26
+ * 返回可执行文件所在目录(绿色版资产的同级锚点)。
27
+ *
28
+ * 调用方必须用 existsSync 过滤返回路径下的目标子目录 —— dev/npm 模式
29
+ * execPath 指向 node/bun 二进制,其同级没有资源目录,过滤后无副作用。
30
+ */
31
+ export function execSiblingDir() {
32
+ return dirname(process.execPath);
33
+ }
@@ -21,6 +21,7 @@
21
21
  import { existsSync } from 'node:fs';
22
22
  import { join, resolve } from 'node:path';
23
23
  import { getWorkingDir } from '../bootstrap/workingDir.js';
24
+ import { execSiblingDir } from './greenRoot.js';
24
25
  import { findPackageRoot } from './packageRoot.js';
25
26
  /**
26
27
  * 返回资源目录的搜索顺序数组(cwd 优先 + packageRoot fallback)。
@@ -35,6 +36,13 @@ export function getResourceSearchPaths(name, metaUrl) {
35
36
  if (existsSync(cwdPath)) {
36
37
  paths.push(cwdPath);
37
38
  }
39
+ // 绿色版(bun --compile 单文件):exe 同级 <name>/ —— compile 后 findPackageRoot
40
+ // 失效,资源目录摆在可执行文件旁边从这里命中。dev/npm 下 exe 同级(node/bun
41
+ // 二进制所在目录)没有资源目录 → existsSync 过滤 → 数组与现状一致。
42
+ const sibPath = resolve(join(execSiblingDir(), name));
43
+ if (!paths.includes(sibPath) && existsSync(sibPath)) {
44
+ paths.push(sibPath);
45
+ }
38
46
  let pkgPath;
39
47
  try {
40
48
  pkgPath = resolve(join(findPackageRoot(metaUrl), name));
@@ -42,7 +50,7 @@ export function getResourceSearchPaths(name, metaUrl) {
42
50
  catch {
43
51
  return paths;
44
52
  }
45
- if (pkgPath !== cwdPath && existsSync(pkgPath)) {
53
+ if (!paths.includes(pkgPath) && existsSync(pkgPath)) {
46
54
  paths.push(pkgPath);
47
55
  }
48
56
  return paths;