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.
Files changed (108) hide show
  1. package/README.md +54 -72
  2. package/package.json +18 -13
  3. package/plugins/ralph-wiggum/plugin.js +205 -0
  4. package/plugins/ralph-wiggum/src/goalState.js +260 -0
  5. package/plugins/ralph-wiggum/src/{sentinels.ts → sentinels.js} +4 -7
  6. package/plugins/ralph-wiggum/src/stopHookRunner.js +104 -0
  7. package/plugins/ralph-wiggum/src/verificationGate.js +202 -0
  8. package/plugins/workflow-runner/commands/workflow.md +13 -3
  9. package/plugins/workflow-runner/{plugin.ts → plugin.js} +20 -26
  10. package/plugins/workflow-runner/src/expressions.js +369 -0
  11. package/plugins/workflow-runner/src/index.js +216 -0
  12. package/plugins/workflow-runner/src/loader.js +183 -0
  13. package/plugins/workflow-runner/src/runner.js +290 -0
  14. package/plugins/workflow-runner/src/stepExecutors/assert.js +28 -0
  15. package/plugins/workflow-runner/src/stepExecutors/llm.js +44 -0
  16. package/plugins/workflow-runner/src/stepExecutors/skill.js +103 -0
  17. package/plugins/workflow-runner/src/stepExecutors/{tool.ts → tool.js} +19 -25
  18. package/plugins/workflow-runner/src/types.js +59 -0
  19. package/plugins/workflow-runner/src/{workflowState.ts → workflowState.js} +21 -40
  20. package/src/bootstrap/cwdArg.js +22 -0
  21. package/src/bootstrap/workingDir.js +31 -0
  22. package/src/cli/configWizard.js +272 -0
  23. package/src/cli/print.js +197 -0
  24. package/src/config/configFile.js +78 -0
  25. package/src/config.js +118 -0
  26. package/src/context/compact.js +357 -0
  27. package/src/context/microCompactLite.js +151 -0
  28. package/src/context/persistContext.js +109 -0
  29. package/src/context/reactiveCompact.js +121 -0
  30. package/src/context/sessionPath.js +58 -0
  31. package/src/context/snipCompact.js +112 -0
  32. package/src/context/tokenCounter.js +66 -0
  33. package/src/llm/client.js +182 -0
  34. package/src/loop.js +230 -0
  35. package/src/main.js +116 -0
  36. package/src/plugin-sdk.js +24 -0
  37. package/src/plugins/commandRouter.js +169 -0
  38. package/src/plugins/hookEngine.js +258 -0
  39. package/src/plugins/pluginApi.js +23 -0
  40. package/src/plugins/pluginLoader.js +71 -0
  41. package/src/plugins/pluginRunner.js +65 -0
  42. package/src/plugins/transcript.js +171 -0
  43. package/src/prompts/projectInstructions.js +48 -0
  44. package/src/prompts/skillList.js +126 -0
  45. package/src/prompts/system.js +155 -0
  46. package/src/session/runTurn.js +41 -0
  47. package/src/session/sessionState.js +19 -0
  48. package/src/tools/bash/bash.js +352 -0
  49. package/src/tools/bash/semantics.js +85 -0
  50. package/src/tools/bash/warnings.js +98 -0
  51. package/src/tools/edit/edit.js +253 -0
  52. package/src/tools/edit/multi-edit.js +155 -0
  53. package/src/tools/glob/glob.js +97 -0
  54. package/src/tools/grep/grep.js +185 -0
  55. package/src/tools/grep/rgPath.js +173 -0
  56. package/src/tools/index.js +94 -0
  57. package/src/tools/read/read.js +209 -0
  58. package/src/tools/shared/fileState.js +61 -0
  59. package/src/tools/shared/fileUtils.js +281 -0
  60. package/src/tools/shared/schemas.js +16 -0
  61. package/src/tools/types.js +21 -0
  62. package/src/tools/webbrowser/browser.js +55 -0
  63. package/src/tools/webbrowser/webbrowser.js +194 -0
  64. package/src/tools/webfetch/preapproved.js +267 -0
  65. package/src/tools/webfetch/webfetch.js +317 -0
  66. package/src/tools/websearch/websearch.js +161 -0
  67. package/src/tools/write/write.js +125 -0
  68. package/src/types/turndown.d.ts +23 -0
  69. package/src/types.js +16 -0
  70. package/src/ui/App.js +37 -0
  71. package/src/ui/InputBox.js +240 -0
  72. package/src/ui/MessageList.js +28 -0
  73. package/src/ui/Root.js +70 -0
  74. package/src/ui/StatusLine.js +41 -0
  75. package/src/ui/ToolStatus.js +11 -0
  76. package/src/ui/hooks/useChat.js +234 -0
  77. package/src/ui/hooks/usePasteHandler.js +137 -0
  78. package/src/ui/hooks/useTextBuffer.js +55 -0
  79. package/src/ui/hooks/useTokenUsage.js +30 -0
  80. package/src/ui/textBuffer.js +217 -0
  81. package/src/utils/packageRoot.js +37 -0
  82. package/src/utils/resourcePaths.js +49 -0
  83. package/src/utils/zodToJson.js +29 -0
  84. package/dist/main.js +0 -5315
  85. package/plugins/ralph-wiggum/plugin.ts +0 -275
  86. package/plugins/ralph-wiggum/scripts/setup-ralph-loop.sh +0 -203
  87. package/plugins/ralph-wiggum/src/goalState.ts +0 -310
  88. package/plugins/ralph-wiggum/src/stopHookRunner.ts +0 -136
  89. package/plugins/ralph-wiggum/src/verificationGate.ts +0 -252
  90. package/plugins/ralph-wiggum/test/goalState.test.ts +0 -410
  91. package/plugins/ralph-wiggum/test/verificationGate.test.ts +0 -122
  92. package/plugins/workflow-runner/src/expressions.ts +0 -371
  93. package/plugins/workflow-runner/src/index.ts +0 -194
  94. package/plugins/workflow-runner/src/loader.ts +0 -193
  95. package/plugins/workflow-runner/src/runner.ts +0 -313
  96. package/plugins/workflow-runner/src/stepExecutors/assert.ts +0 -30
  97. package/plugins/workflow-runner/src/stepExecutors/llm.ts +0 -54
  98. package/plugins/workflow-runner/src/stepExecutors/skill.ts +0 -115
  99. package/plugins/workflow-runner/src/types.ts +0 -183
  100. package/plugins/workflow-runner/test/cli.e2e.test.ts +0 -114
  101. package/plugins/workflow-runner/test/e2e.test.ts +0 -268
  102. package/plugins/workflow-runner/test/expressions.test.ts +0 -140
  103. package/plugins/workflow-runner/test/fixtures/cli-e2e.yaml +0 -27
  104. package/plugins/workflow-runner/test/fixtures/hello-workflow.yaml +0 -49
  105. package/plugins/workflow-runner/test/graceful.test.ts +0 -139
  106. package/plugins/workflow-runner/test/loader.test.ts +0 -216
  107. package/plugins/workflow-runner/test/pluginRunner.isolation.test.ts +0 -230
  108. package/plugins/workflow-runner/test/runner.test.ts +0 -511
@@ -0,0 +1,258 @@
1
+ /**
2
+ * ============================================================
3
+ * src/plugins/hookEngine.ts —— Claude Code 风格 hook 派发引擎
4
+ * ------------------------------------------------------------
5
+ * 统一加载所有插件目录下的 hooks/hooks.json,按事件名建表,
6
+ * 在 minimal-agent 的关键节点(Stop / PreToolUse / PostToolUse /
7
+ * UserPromptSubmit / SessionStart / SessionEnd / PreCompact /
8
+ * Notification / SubagentStop)触发对应的 shell 命令。
9
+ *
10
+ * 契约(与 Claude Code 对齐):
11
+ * - hook 命令通过 stdin 收到 JSON payload(包含 session_id、
12
+ * transcript_path、cwd、hook_event_name 等基础字段 + 事件特定字段)
13
+ * - 命令在 stdout 输出 JSON:
14
+ * { decision: "block" | "approve" | "pass",
15
+ * reason?, systemMessage?, output?, suppressOutput? }
16
+ * - 解析失败 / 非 0 退出 / 空输出 → 视为 pass
17
+ * - 多 hook 串行执行,遇到第一个 block 立即短路返回
18
+ *
19
+ * Windows 兼容:
20
+ * - 以 .sh 结尾的命令直接跳过,并 warn 一次(bash 通常不可用)
21
+ * - 其他命令(.ps1 / .cmd / .bat / 可执行文件)走 shell:true
22
+ * - POSIX 上一律 `bash -c <command>`,避免依赖文件可执行位
23
+ *
24
+ * Matcher 支持(PreToolUse / PostToolUse):
25
+ * - 在 matcher 组级别声明 `matcher: 'Bash|Edit'` 等正则
26
+ * - payload.tool_name 不匹配则跳过这组所有命令
27
+ * - 缺省 matcher 视为通配
28
+ * ============================================================
29
+ */
30
+ import { spawn } from 'node:child_process';
31
+ import { readFile } from 'node:fs/promises';
32
+ import { join } from 'node:path';
33
+ const HOOK_EVENT_NAMES = new Set([
34
+ 'PreToolUse',
35
+ 'PostToolUse',
36
+ 'Stop',
37
+ 'SubagentStop',
38
+ 'UserPromptSubmit',
39
+ 'SessionStart',
40
+ 'SessionEnd',
41
+ 'PreCompact',
42
+ 'Notification',
43
+ ]);
44
+ const HOOK_TIMEOUT_MS = 30_000;
45
+ const hookTable = new Map();
46
+ let warnedWindowsShellSkip = false;
47
+ function pluginNameFromRoot(pluginRoot) {
48
+ const norm = pluginRoot.replace(/\\/g, '/');
49
+ const parts = norm.split('/').filter(Boolean);
50
+ return parts[parts.length - 1] ?? pluginRoot;
51
+ }
52
+ async function loadOnePluginHooks(pluginRoot) {
53
+ const hooksJsonPath = join(pluginRoot, 'hooks', 'hooks.json');
54
+ let parsed;
55
+ try {
56
+ const raw = await readFile(hooksJsonPath, 'utf8');
57
+ parsed = JSON.parse(raw);
58
+ }
59
+ catch {
60
+ return;
61
+ }
62
+ if (!parsed || typeof parsed !== 'object')
63
+ return;
64
+ const hooksObj = parsed.hooks;
65
+ if (!hooksObj || typeof hooksObj !== 'object')
66
+ return;
67
+ const pluginName = pluginNameFromRoot(pluginRoot);
68
+ for (const [eventKey, groups] of Object.entries(hooksObj)) {
69
+ if (!HOOK_EVENT_NAMES.has(eventKey))
70
+ continue;
71
+ if (!Array.isArray(groups))
72
+ continue;
73
+ const eventName = eventKey;
74
+ for (const group of groups) {
75
+ if (!group || typeof group !== 'object')
76
+ continue;
77
+ const matcher = typeof group.matcher === 'string'
78
+ ? (group.matcher)
79
+ : undefined;
80
+ const innerHooks = group.hooks;
81
+ if (!Array.isArray(innerHooks))
82
+ continue;
83
+ for (const h of innerHooks) {
84
+ if (!h || typeof h !== 'object')
85
+ continue;
86
+ const type = h.type;
87
+ const command = h.command;
88
+ if (type !== 'command' || typeof command !== 'string' || !command)
89
+ continue;
90
+ const resolved = command.replaceAll('${CLAUDE_PLUGIN_ROOT}', pluginRoot);
91
+ const entry = {
92
+ command: resolved,
93
+ pluginRoot,
94
+ pluginName,
95
+ matcher,
96
+ };
97
+ const list = hookTable.get(eventName) ?? [];
98
+ list.push(entry);
99
+ hookTable.set(eventName, list);
100
+ }
101
+ }
102
+ }
103
+ }
104
+ /** 加载所有插件目录的 hooks.json,按事件名建表(模块级缓存)。 */
105
+ export async function loadHooks(pluginRoots) {
106
+ hookTable.clear();
107
+ for (const root of pluginRoots) {
108
+ await loadOnePluginHooks(root);
109
+ }
110
+ }
111
+ /** 重置 hook 缓存(测试用) */
112
+ export function _resetHookEngine() {
113
+ hookTable.clear();
114
+ warnedWindowsShellSkip = false;
115
+ }
116
+ function matcherAllows(matcher, toolName) {
117
+ if (!matcher)
118
+ return true;
119
+ if (typeof toolName !== 'string' || !toolName)
120
+ return true;
121
+ try {
122
+ const re = new RegExp(`^(${matcher})$`);
123
+ return re.test(toolName);
124
+ }
125
+ catch {
126
+ return true;
127
+ }
128
+ }
129
+ function isPosixShellScript(command) {
130
+ // 仅按文件后缀判断,命令字符串可能带参数,截取首段
131
+ const head = command.trim().split(/\s+/)[0] ?? '';
132
+ return head.toLowerCase().endsWith('.sh');
133
+ }
134
+ function runOneHook(hook, payload) {
135
+ return new Promise((resolve) => {
136
+ const isWin = process.platform === 'win32';
137
+ if (isWin && isPosixShellScript(hook.command)) {
138
+ if (!warnedWindowsShellSkip) {
139
+ warnedWindowsShellSkip = true;
140
+ console.warn(`[minimal-agent] Skipping POSIX shell hook on Windows: ${hook.command}`);
141
+ }
142
+ resolve({ decision: 'pass' });
143
+ return;
144
+ }
145
+ const env = {
146
+ ...process.env,
147
+ CLAUDE_PLUGIN_ROOT: hook.pluginRoot,
148
+ };
149
+ let child;
150
+ try {
151
+ if (isWin) {
152
+ // Windows 上其他类型脚本(.ps1 / .cmd / .bat / 可执行文件)走 shell
153
+ child = spawn(hook.command, { env, shell: true });
154
+ }
155
+ else {
156
+ // POSIX 一律 bash -c,避开文件可执行位 / shebang 问题
157
+ child = spawn('bash', ['-c', hook.command], { env });
158
+ }
159
+ }
160
+ catch {
161
+ resolve({ decision: 'pass' });
162
+ return;
163
+ }
164
+ let stdout = '';
165
+ let settled = false;
166
+ const finalize = (decision) => {
167
+ if (settled)
168
+ return;
169
+ settled = true;
170
+ clearTimeout(timer);
171
+ resolve(decision);
172
+ };
173
+ const timer = setTimeout(() => {
174
+ try {
175
+ child.kill('SIGTERM');
176
+ }
177
+ catch {
178
+ // ignore
179
+ }
180
+ console.warn(`[minimal-agent] Hook timeout (>${HOOK_TIMEOUT_MS}ms): ${hook.command}`);
181
+ finalize({ decision: 'pass' });
182
+ }, HOOK_TIMEOUT_MS);
183
+ child.stdout?.on('data', (data) => {
184
+ stdout += data.toString();
185
+ });
186
+ child.on('error', () => {
187
+ finalize({ decision: 'pass' });
188
+ });
189
+ child.on('close', (code) => {
190
+ if (code !== 0) {
191
+ finalize({ decision: 'pass' });
192
+ return;
193
+ }
194
+ const trimmed = stdout.trim();
195
+ if (!trimmed) {
196
+ finalize({ decision: 'pass' });
197
+ return;
198
+ }
199
+ try {
200
+ const parsed = JSON.parse(trimmed);
201
+ const decision = parsed.decision;
202
+ if (decision === 'block' || decision === 'approve') {
203
+ const result = {
204
+ decision,
205
+ reason: typeof parsed.reason === 'string' ? parsed.reason : undefined,
206
+ systemMessage: typeof parsed.systemMessage === 'string' ? parsed.systemMessage : undefined,
207
+ output: typeof parsed.output === 'string' ? parsed.output : undefined,
208
+ suppressOutput: typeof parsed.suppressOutput === 'boolean' ? parsed.suppressOutput : undefined,
209
+ };
210
+ if (decision === 'block') {
211
+ result.blockedBy = hook.pluginName;
212
+ }
213
+ finalize(result);
214
+ return;
215
+ }
216
+ finalize({ decision: 'pass' });
217
+ }
218
+ catch {
219
+ finalize({ decision: 'pass' });
220
+ }
221
+ });
222
+ try {
223
+ child.stdin?.write(JSON.stringify(payload));
224
+ child.stdin?.end();
225
+ }
226
+ catch {
227
+ // 子进程已挂掉等情况,等 'close' / 'error' 处理
228
+ }
229
+ });
230
+ }
231
+ /**
232
+ * 触发某事件。payload 会与 base 字段合并写入 stdin。
233
+ * 多 hook 同事件 → 按声明顺序串行执行,遇到 block 立即短路返回。
234
+ * Windows 上 .sh 命令直接跳过 + 一次性 warn。
235
+ */
236
+ export async function triggerHook(eventName, payload) {
237
+ const hooks = hookTable.get(eventName);
238
+ if (!hooks || hooks.length === 0) {
239
+ return { decision: 'pass' };
240
+ }
241
+ const fullPayload = {
242
+ ...payload,
243
+ hook_event_name: eventName,
244
+ };
245
+ let lastDecision = { decision: 'pass' };
246
+ for (const hook of hooks) {
247
+ if (eventName === 'PreToolUse' || eventName === 'PostToolUse') {
248
+ if (!matcherAllows(hook.matcher, fullPayload.tool_name))
249
+ continue;
250
+ }
251
+ const result = await runOneHook(hook, fullPayload);
252
+ if (result.decision === 'block') {
253
+ return result;
254
+ }
255
+ lastDecision = result;
256
+ }
257
+ return lastDecision;
258
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * ============================================================
3
+ * src/plugins/pluginApi.ts —— 插件 plugin.ts 的契约
4
+ * ------------------------------------------------------------
5
+ * 双层插件契约的"代码层"接口:
6
+ *
7
+ * plugins/<id>/.claude-plugin/plugin.json → 元数据
8
+ * plugins/<id>/commands/*.md → 声明式 slash 命令(默认走 LLM)
9
+ * plugins/<id>/hooks/hooks.json → 声明式 hook
10
+ * plugins/<id>/plugin.ts → 可选!要接管命令分发时才写
11
+ *
12
+ * 纯 markdown 插件(无 plugin.ts)零修改 drop-in,覆盖 99% Anthropic 上游用例;
13
+ * 富插件写 plugin.ts 接管 runCommand,把全部逻辑放在自己目录里,外部 src/ 一行不改。
14
+ *
15
+ * PluginContext 是框架"愿意暴露给插件"的最小运行时上下文:
16
+ * - provider / history / signal / sessionState 都是 runQuery 本来就接受的
17
+ * - 插件作者通过 src/plugin-sdk.ts 取 runQuery、chat、executeTool 等
18
+ *
19
+ * PluginEvent 是事件层的"开放契约":插件可以 yield 任意带 type 字段的自定义事件
20
+ * ——框架原样透传,UI 不识别就静默忽略。让插件不必把私有事件挤进 LoopEvent。
21
+ * ============================================================
22
+ */
23
+ export {};
@@ -0,0 +1,71 @@
1
+ /**
2
+ * ============================================================
3
+ * src/plugins/pluginLoader.ts —— plugin entry dynamic import 缓存层
4
+ * ------------------------------------------------------------
5
+ * 按 pluginRoot 路径 lazy import 插件目录下的 plugin.js(优先)
6
+ * 或 plugin.ts(回退)。
7
+ *
8
+ * 双候选探测的原因:
9
+ * - install 模式(npm 包内):tsc 编译后只剩 plugin.js,Node + Bun
10
+ * 都能加载
11
+ * - dev 模式(仓库源码):物理只有 plugin.ts,Bun 直接吃
12
+ * - 用户 cwd 自带插件:可能写 .ts(须 Bun 跑)或 .js(Node + Bun 都行)
13
+ *
14
+ * 契约:
15
+ * - 两个文件都不存在 → 缓存 null(纯 markdown 声明式插件)
16
+ * - import 失败(语法错 / 运行时抛错)→ console.warn + 缓存 null
17
+ * - import 成功但 default 不是对象 → 缓存 null
18
+ * - 缓存 key = pluginRoot 绝对路径
19
+ *
20
+ * 框架 hot-reload 由测试调用 _resetPluginLoader() 实现。
21
+ * ============================================================
22
+ */
23
+ import { existsSync } from 'node:fs';
24
+ import { join } from 'node:path';
25
+ import { pathToFileURL } from 'node:url';
26
+ const loaderCache = new Map();
27
+ function isPluginApi(value) {
28
+ if (!value || typeof value !== 'object')
29
+ return false;
30
+ const { runCommand } = value;
31
+ if (runCommand !== undefined && typeof runCommand !== 'function')
32
+ return false;
33
+ return true;
34
+ }
35
+ /**
36
+ * 加载插件目录下的 plugin.ts;不存在或失败返回 null。
37
+ * 同一 pluginRoot 多次调用走缓存,避免重复 import。
38
+ */
39
+ export async function loadPluginApi(pluginRoot) {
40
+ if (loaderCache.has(pluginRoot)) {
41
+ return loaderCache.get(pluginRoot) ?? null;
42
+ }
43
+ // prefer plugin.js(install / Node 兼容),fallback plugin.ts(dev / 用户 Bun 插件)
44
+ const pluginEntry = ['plugin.js', 'plugin.ts']
45
+ .map((f) => join(pluginRoot, f))
46
+ .find((p) => existsSync(p));
47
+ if (!pluginEntry) {
48
+ loaderCache.set(pluginRoot, null);
49
+ return null;
50
+ }
51
+ try {
52
+ const mod = await import(pathToFileURL(pluginEntry).href);
53
+ const candidate = mod.default;
54
+ if (!isPluginApi(candidate)) {
55
+ console.warn(`[minimal-agent] plugin.ts default export is not a valid PluginApi: ${pluginRoot}`);
56
+ loaderCache.set(pluginRoot, null);
57
+ return null;
58
+ }
59
+ loaderCache.set(pluginRoot, candidate);
60
+ return candidate;
61
+ }
62
+ catch (err) {
63
+ console.warn(`[minimal-agent] failed to load plugin entry at ${pluginRoot}: ${err instanceof Error ? err.message : String(err)}`);
64
+ loaderCache.set(pluginRoot, null);
65
+ return null;
66
+ }
67
+ }
68
+ /** 测试用:重置加载器缓存。 */
69
+ export function _resetPluginLoader() {
70
+ loaderCache.clear();
71
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * ============================================================
3
+ * src/plugins/pluginRunner.ts —— 框架级 slash-命令分发器
4
+ * ------------------------------------------------------------
5
+ * 通用分发器,**不含任何插件特定逻辑**。
6
+ *
7
+ * 执行路径:
8
+ * 1) 非 / 输入 → 直接透传 runQuery(T-A-O-R 红线,零插件副作用)
9
+ * 2) / 输入 → discoverPlugins → resolveCommand
10
+ * a) 命中插件且其 plugin.ts 暴露 runCommand → 让 plugin.ts 接管
11
+ * b) 命中插件但只有声明式 commands/*.md → 把 prompt 拼好喂给 runQuery
12
+ * c) 未命中 → 当普通问句喂给 runQuery(让 LLM 自己解释 / 未知命令)
13
+ * ============================================================
14
+ */
15
+ import { discoverPlugins, resolveCommand, buildCommandInput, } from './commandRouter.js';
16
+ import { loadPluginApi } from './pluginLoader.js';
17
+ import { runQuery } from '../loop.js';
18
+ export async function* runWithPlugins(userInput, options) {
19
+ const { provider, history, signal } = options;
20
+ const isSlashCommand = userInput.trimStart().startsWith('/');
21
+ if (!isSlashCommand) {
22
+ yield* runQuery(userInput, {
23
+ provider,
24
+ history,
25
+ signal,
26
+ maxTurns: options.maxTurns,
27
+ sessionState: options.sessionState,
28
+ });
29
+ return;
30
+ }
31
+ await discoverPlugins();
32
+ const matched = resolveCommand(userInput);
33
+ if (!matched) {
34
+ // 未知 / 命令直接喂 LLM,让模型自然回复(等同于普通文本)
35
+ yield* runQuery(userInput, {
36
+ provider,
37
+ history,
38
+ signal,
39
+ maxTurns: options.maxTurns,
40
+ sessionState: options.sessionState,
41
+ });
42
+ return;
43
+ }
44
+ // 优先 plugin.ts.runCommand(富插件接管)
45
+ const api = await loadPluginApi(matched.cmd.pluginRoot);
46
+ if (api?.runCommand) {
47
+ yield* api.runCommand(matched.cmd.name, matched.arguments, {
48
+ provider,
49
+ history,
50
+ signal,
51
+ sessionState: options.sessionState,
52
+ maxTurns: options.maxTurns,
53
+ });
54
+ return;
55
+ }
56
+ // 声明式 fallback:把 markdown 命令体拼成 prompt 喂 LLM
57
+ const promptInput = buildCommandInput(matched);
58
+ yield* runQuery(promptInput, {
59
+ provider,
60
+ history,
61
+ signal,
62
+ maxTurns: options.maxTurns,
63
+ sessionState: options.sessionState,
64
+ });
65
+ }
@@ -0,0 +1,171 @@
1
+ /**
2
+ * ============================================================
3
+ * src/plugins/transcript.ts —— Claude Code 风格的 JSONL transcript 写入
4
+ * ------------------------------------------------------------
5
+ * 插件 hook(如 stop-hook.sh)通过 stdin 收到 `transcript_path` 后
6
+ * 会 grep / jq 这份 JSONL 文件来还原对话。schema 必须与 Anthropic
7
+ * Claude Code 真实 transcript 对齐:每行一个 JSON 对象,形如
8
+ * { type, uuid, session_id, timestamp, message: { role, content: [...parts] } }
9
+ * content 为 typed parts 数组:text / tool_use / tool_result。
10
+ *
11
+ * 设计要点:
12
+ * - OpenAI 风格的 assistant.tool_calls 在这里被拍平成 content 数组
13
+ * 里的 tool_use parts(Claude Code 的真实格式)
14
+ * - tool role 消息被包装成 type:'user' + tool_result part(上游惯例)
15
+ * - 同步 appendFileSync —— spawn 的 hook 子进程要立刻看到最新内容
16
+ * - 任何写入异常都被吞掉只 warn —— transcript 失败绝不能拖垮 agent
17
+ * - 零依赖(只用 node 内置)
18
+ *
19
+ * 存储位置:~/.minimal-agent/transcripts/<sessionId>.jsonl
20
+ * ============================================================
21
+ */
22
+ import { appendFileSync, mkdirSync } from 'node:fs';
23
+ import { homedir } from 'node:os';
24
+ import { join } from 'node:path';
25
+ import { randomUUID } from 'node:crypto';
26
+ // 模块级状态:当前会话的 handle(lazy 初始化)
27
+ let current = null;
28
+ function transcriptDir() {
29
+ return join(homedir(), '.minimal-agent', 'transcripts');
30
+ }
31
+ function generateSessionId() {
32
+ return `${Date.now().toString(36)}-${randomUUID().slice(0, 8)}`;
33
+ }
34
+ function ensureHandle(sessionId) {
35
+ // 如果指定了 sessionId 且与当前不同 → 切到新文件
36
+ if (sessionId && current && current.sessionId === sessionId) {
37
+ return current;
38
+ }
39
+ if (!sessionId && current) {
40
+ return current;
41
+ }
42
+ const sid = sessionId ?? generateSessionId();
43
+ const dir = transcriptDir();
44
+ try {
45
+ mkdirSync(dir, { recursive: true });
46
+ }
47
+ catch (err) {
48
+ console.warn(`[transcript] mkdir failed: ${err instanceof Error ? err.message : String(err)}`);
49
+ }
50
+ const handle = {
51
+ sessionId: sid,
52
+ path: join(dir, `${sid}.jsonl`),
53
+ };
54
+ current = handle;
55
+ return handle;
56
+ }
57
+ function writeLine(line) {
58
+ try {
59
+ const handle = ensureHandle();
60
+ appendFileSync(handle.path, `${JSON.stringify(line)}\n`, 'utf8');
61
+ }
62
+ catch (err) {
63
+ console.warn(`[transcript] write failed: ${err instanceof Error ? err.message : String(err)}`);
64
+ }
65
+ }
66
+ function nowIso() {
67
+ return new Date().toISOString();
68
+ }
69
+ /**
70
+ * 初始化 transcript:在 ~/.minimal-agent/transcripts/ 下打开 <sessionId>.jsonl。
71
+ * 如果未提供 sessionId,用时间戳 + 随机后缀生成。
72
+ * 多次调用同一 sessionId 会复用同一文件(append)。
73
+ */
74
+ export function initTranscript(sessionId) {
75
+ // 显式 init:即便 current 已存在也允许切换到指定 sessionId
76
+ if (sessionId) {
77
+ const dir = transcriptDir();
78
+ try {
79
+ mkdirSync(dir, { recursive: true });
80
+ }
81
+ catch (err) {
82
+ console.warn(`[transcript] mkdir failed: ${err instanceof Error ? err.message : String(err)}`);
83
+ }
84
+ current = {
85
+ sessionId,
86
+ path: join(dir, `${sessionId}.jsonl`),
87
+ };
88
+ return current;
89
+ }
90
+ return ensureHandle();
91
+ }
92
+ /** 获取当前会话的 transcript 路径。未 init 时 lazy 初始化。 */
93
+ export function getCurrentTranscriptPath() {
94
+ return ensureHandle().path;
95
+ }
96
+ /** 追加一条 user 消息。 */
97
+ export function appendUserMessage(content) {
98
+ const handle = ensureHandle();
99
+ writeLine({
100
+ type: 'user',
101
+ uuid: randomUUID(),
102
+ session_id: handle.sessionId,
103
+ timestamp: nowIso(),
104
+ message: {
105
+ role: 'user',
106
+ content: [{ type: 'text', text: content }],
107
+ },
108
+ });
109
+ }
110
+ /** 追加一条 assistant 消息。tool_calls 转成 content 数组里的 tool_use parts。 */
111
+ export function appendAssistantMessage(message) {
112
+ const handle = ensureHandle();
113
+ const parts = [];
114
+ // 文本部分:null / 空字符串都跳过
115
+ if (typeof message.content === 'string' && message.content.length > 0) {
116
+ parts.push({ type: 'text', text: message.content });
117
+ }
118
+ // tool_calls 拍平成 tool_use parts
119
+ if (Array.isArray(message.tool_calls)) {
120
+ for (const tc of message.tool_calls) {
121
+ let input;
122
+ try {
123
+ input = JSON.parse(tc.function.arguments);
124
+ }
125
+ catch {
126
+ input = { _raw: tc.function.arguments };
127
+ }
128
+ parts.push({
129
+ type: 'tool_use',
130
+ id: tc.id,
131
+ name: tc.function.name,
132
+ input,
133
+ });
134
+ }
135
+ }
136
+ writeLine({
137
+ type: 'assistant',
138
+ uuid: randomUUID(),
139
+ session_id: handle.sessionId,
140
+ timestamp: nowIso(),
141
+ message: {
142
+ role: 'assistant',
143
+ content: parts,
144
+ },
145
+ });
146
+ }
147
+ /** 追加一条 tool 执行结果(来自 tool role 消息)。 */
148
+ export function appendToolResult(toolCallId, _toolName, ok, content) {
149
+ const handle = ensureHandle();
150
+ writeLine({
151
+ type: 'user',
152
+ uuid: randomUUID(),
153
+ session_id: handle.sessionId,
154
+ timestamp: nowIso(),
155
+ message: {
156
+ role: 'user',
157
+ content: [
158
+ {
159
+ type: 'tool_result',
160
+ tool_use_id: toolCallId,
161
+ content,
162
+ is_error: !ok,
163
+ },
164
+ ],
165
+ },
166
+ });
167
+ }
168
+ /** 重置当前 handle(用于测试 / /new 清屏后开新 session)。 */
169
+ export function resetTranscript() {
170
+ current = null;
171
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * ============================================================
3
+ * src/prompts/projectInstructions.ts —— 项目级指令加载
4
+ * ------------------------------------------------------------
5
+ * 检查当前工作目录下是否存在 minimal-agent.md,
6
+ * 如果存在则全量读取并返回其内容。
7
+ *
8
+ * 用途类似:
9
+ * - kakadeai 的 CLAUDE.md
10
+ * - Cursor 的 .cursorrules
11
+ * - GitHub Copilot 的 .github/copilot-instructions.md
12
+ *
13
+ * 设计原则:
14
+ * 1. 静默失败:文件不存在时不报错、不打印警告、返回 null
15
+ * 2. 全量注入:不做截断或摘要,原样拼入 messages 数组
16
+ * 3. 实时读取:每次 buildInitialHistory 都重新读(用户改了立即生效)
17
+ * 4. 独立消息:作为单独的 system message 插入,
18
+ * 不污染全局 system prompt 的 token 结构
19
+ * ============================================================
20
+ */
21
+ import { readFile } from 'node:fs/promises';
22
+ import { join } from 'node:path';
23
+ const FILENAME = 'minimal-agent.md';
24
+ /**
25
+ * 尝试从 cwd 加载项目指令文件。
26
+ *
27
+ * @param cwd 当前工作目录
28
+ * @returns 文件内容字符串(如果存在),null 如果不存在或读取出错
29
+ */
30
+ export async function loadProjectInstructions(cwd) {
31
+ const filePath = join(cwd, FILENAME);
32
+ try {
33
+ const content = await readFile(filePath, 'utf-8');
34
+ const trimmed = content.trim();
35
+ if (trimmed.length === 0)
36
+ return null;
37
+ return trimmed;
38
+ }
39
+ catch (e) {
40
+ // ENOENT(文件不存在)→ 最常见的情况 → 静默返回 null
41
+ // 其他错误(权限/I/O/编码等)→ stderr 警告一次,仍返回 null 不阻止启动
42
+ const code = e.code;
43
+ if (code !== 'ENOENT') {
44
+ process.stderr.write(`[minimal-agent] 跳过项目指令 ${filePath}:${code ?? e.message}\n`);
45
+ }
46
+ return null;
47
+ }
48
+ }