@minko-ai/minko 0.1.0 → 0.1.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 +577 -2
- package/assets/mascot-preview.png +0 -0
- package/assets/mascot-preview.txt +46 -0
- package/assets/mascot-source.png +0 -0
- package/assets/skills/README.md +21 -0
- package/assets/skills/brainstorming/LICENSE +21 -0
- package/assets/skills/brainstorming/SKILL.md +316 -0
- package/assets/skills/buddhist-method/LICENSE +21 -0
- package/assets/skills/buddhist-method/SKILL.md +137 -0
- package/assets/skills/buddhist-method/references/ariyasacca-debug.md +57 -0
- package/assets/skills/buddhist-method/references/extended-principles.md +100 -0
- package/assets/skills/code-humanizer/LICENSE +21 -0
- package/assets/skills/code-humanizer/SKILL.md +174 -0
- package/assets/skills/code-review/LICENSE +21 -0
- package/assets/skills/code-review/SKILL.md +232 -0
- package/assets/skills/code-review/reference/angular.md +768 -0
- package/assets/skills/code-review/reference/architecture-review-guide.md +472 -0
- package/assets/skills/code-review/reference/c.md +890 -0
- package/assets/skills/code-review/reference/code-quality-universal.md +488 -0
- package/assets/skills/code-review/reference/code-review-best-practices.md +136 -0
- package/assets/skills/code-review/reference/common-bugs-checklist.md +302 -0
- package/assets/skills/code-review/reference/cpp.md +893 -0
- package/assets/skills/code-review/reference/cross-cutting/async-concurrency-patterns.md +515 -0
- package/assets/skills/code-review/reference/cross-cutting/error-handling-principles.md +492 -0
- package/assets/skills/code-review/reference/cross-cutting/n-plus-one-queries.md +309 -0
- package/assets/skills/code-review/reference/cross-cutting/sql-injection-prevention.md +308 -0
- package/assets/skills/code-review/reference/cross-cutting/xss-prevention.md +264 -0
- package/assets/skills/code-review/reference/csharp.md +525 -0
- package/assets/skills/code-review/reference/css-less-sass.md +661 -0
- package/assets/skills/code-review/reference/dart.md +670 -0
- package/assets/skills/code-review/reference/django.md +985 -0
- package/assets/skills/code-review/reference/fastapi.md +580 -0
- package/assets/skills/code-review/reference/go.md +993 -0
- package/assets/skills/code-review/reference/java.md +409 -0
- package/assets/skills/code-review/reference/java8.md +586 -0
- package/assets/skills/code-review/reference/kotlin.md +1018 -0
- package/assets/skills/code-review/reference/nestjs.md +593 -0
- package/assets/skills/code-review/reference/performance-review-guide.md +816 -0
- package/assets/skills/code-review/reference/php.md +684 -0
- package/assets/skills/code-review/reference/python.md +1073 -0
- package/assets/skills/code-review/reference/qt.md +757 -0
- package/assets/skills/code-review/reference/react.md +871 -0
- package/assets/skills/code-review/reference/ruby.md +964 -0
- package/assets/skills/code-review/reference/rust.md +846 -0
- package/assets/skills/code-review/reference/security-review-guide.md +494 -0
- package/assets/skills/code-review/reference/svelte.md +1064 -0
- package/assets/skills/code-review/reference/swift.md +936 -0
- package/assets/skills/code-review/reference/typescript.md +1016 -0
- package/assets/skills/code-review/reference/vue.md +924 -0
- package/assets/skills/code-review/reference/zig.md +440 -0
- package/assets/skills/cyclomatic-complexity/LICENSE +201 -0
- package/assets/skills/cyclomatic-complexity/SKILL.md +69 -0
- package/assets/skills/five-whys/LICENSE +21 -0
- package/assets/skills/five-whys/SKILL.md +247 -0
- package/assets/skills/five-whys/examples/performance-regression.md +161 -0
- package/assets/skills/five-whys/examples/production-incident.md +134 -0
- package/assets/skills/five-whys/references/software-patterns.md +284 -0
- package/assets/skills/five-whys/references/toyota-origins.md +257 -0
- package/assets/skills/systematic-debugging/LICENSE +21 -0
- package/assets/skills/systematic-debugging/SKILL.md +283 -0
- package/assets/skills/test-driven-development/LICENSE +21 -0
- package/assets/skills/test-driven-development/SKILL.md +330 -0
- package/assets/skills/verification-before-completion/LICENSE +21 -0
- package/assets/skills/verification-before-completion/SKILL.md +120 -0
- package/assets/skills/vibe-sec/LICENSE +201 -0
- package/assets/skills/vibe-sec/SKILL.md +758 -0
- package/assets/skills/writing-plans/LICENSE +21 -0
- package/assets/skills/writing-plans/SKILL.md +204 -0
- package/bin/minko.js +2 -2
- package/dist/agent/approvalTokens.js +224 -0
- package/dist/agent/branchLock.js +315 -0
- package/dist/agent/compact.js +182 -0
- package/dist/agent/context.js +183 -0
- package/dist/agent/cron.js +326 -0
- package/dist/agent/hooks.js +211 -0
- package/dist/agent/lanes.js +511 -0
- package/dist/agent/loop.js +337 -0
- package/dist/agent/memory.js +159 -0
- package/dist/agent/permissions.js +71 -0
- package/dist/agent/plan.js +118 -0
- package/dist/agent/prompt.js +120 -0
- package/dist/agent/subagent.js +67 -0
- package/dist/agent/tasks.js +449 -0
- package/dist/agent/transcript.js +62 -0
- package/dist/agent/workerStore.js +172 -0
- package/dist/agent/workerTypes.js +1 -0
- package/dist/agent/workers.js +503 -0
- package/dist/cli/app.js +261 -0
- package/dist/cli/argv.js +206 -0
- package/dist/cli/commandsCommand.js +12 -0
- package/dist/cli/configCommand.js +170 -0
- package/dist/cli/doctorCommand.js +112 -0
- package/dist/cli/exportCommand.js +85 -0
- package/dist/cli/forkCommand.js +24 -0
- package/dist/cli/linkCommand.js +272 -0
- package/dist/cli/loginCommand.js +69 -0
- package/dist/cli/mcpLoginCommand.js +68 -0
- package/dist/cli/pluginCommand.js +113 -0
- package/dist/cli/powerCommand.js +174 -0
- package/dist/cli/promptFooter.js +74 -0
- package/dist/cli/relayCommand.js +38 -0
- package/dist/cli/sandboxCommand.js +31 -0
- package/dist/cli/serveCommand.js +104 -0
- package/dist/cli/statusCommand.js +120 -0
- package/dist/cli/tui.js +1104 -0
- package/dist/commands/registry.js +99 -0
- package/dist/config/paths.js +81 -0
- package/dist/config/store.js +605 -0
- package/dist/index.js +199 -0
- package/dist/link/protocol.js +159 -0
- package/dist/link/relay.js +184 -0
- package/dist/link/server.js +566 -0
- package/dist/link/tunnel.js +90 -0
- package/dist/lsp/client.js +340 -0
- package/dist/lsp/tool.js +177 -0
- package/dist/mcp/client.js +469 -0
- package/dist/mcp/httpClient.js +191 -0
- package/dist/mcp/server.js +206 -0
- package/dist/mcp/tools.js +173 -0
- package/dist/oauth/device.js +111 -0
- package/dist/oauth/store.js +56 -0
- package/dist/plugins/loader.js +291 -0
- package/dist/plugins/market.js +98 -0
- package/dist/providers/anthropic.js +279 -0
- package/dist/providers/factory.js +109 -0
- package/dist/providers/openai.js +182 -0
- package/dist/providers/retry.js +95 -0
- package/dist/providers/sse.js +67 -0
- package/dist/providers/types.js +10 -0
- package/dist/rag/embeddings.js +171 -0
- package/dist/rag/index.js +373 -0
- package/dist/rag/server.js +167 -0
- package/dist/rag/tool.js +148 -0
- package/dist/sandbox/index.js +55 -0
- package/dist/sdk/server.js +150 -0
- package/dist/sdk/session.js +104 -0
- package/dist/session/liveness.js +148 -0
- package/dist/session/recorder.js +49 -0
- package/dist/session/store.js +171 -0
- package/dist/skills/index.js +428 -0
- package/dist/theme.js +30 -0
- package/dist/tools/fs.js +195 -0
- package/dist/tools/notebook.js +109 -0
- package/dist/tools/registry.js +32 -0
- package/dist/tools/search.js +130 -0
- package/dist/tools/shell.js +200 -0
- package/dist/tools/structured.js +83 -0
- package/dist/tools/types.js +1 -0
- package/dist/ui/ansi.js +135 -0
- package/dist/ui/banner.js +103 -0
- package/dist/ui/input.js +49 -0
- package/dist/ui/inputModel.js +113 -0
- package/dist/ui/keyboard.js +142 -0
- package/dist/ui/mascot.js +34 -0
- package/dist/ui/mascotData.js +96 -0
- package/dist/ui/md.js +183 -0
- package/dist/ui/screen.js +49 -0
- package/dist/ui/spinner.js +52 -0
- package/dist/ui/transcript.js +287 -0
- package/dist/util/clipboard.js +53 -0
- package/dist/util/errors.js +50 -0
- package/dist/util/version.js +12 -0
- package/dist/web/fetch.js +168 -0
- package/dist/web/search.js +97 -0
- package/package.json +45 -36
- package/src/agent/loop.ts +0 -159
- package/src/agent/prompt.ts +0 -45
- package/src/cli/app.ts +0 -396
- package/src/cli/argv.ts +0 -139
- package/src/cli/configCommand.ts +0 -175
- package/src/config/paths.ts +0 -20
- package/src/config/store.ts +0 -149
- package/src/index.ts +0 -120
- package/src/providers/anthropic.ts +0 -215
- package/src/providers/openai.ts +0 -158
- package/src/providers/sse.ts +0 -57
- package/src/providers/types.ts +0 -59
- package/src/session/store.ts +0 -125
- package/src/theme.ts +0 -41
- package/src/tools/fs.ts +0 -143
- package/src/tools/registry.ts +0 -19
- package/src/tools/shell.ts +0 -120
- package/src/tools/types.ts +0 -26
- package/src/ui/ansi.ts +0 -110
- package/src/ui/banner.ts +0 -39
- package/src/ui/input.ts +0 -54
- package/src/ui/md.ts +0 -160
- package/src/ui/spinner.ts +0 -58
- package/src/util/errors.ts +0 -48
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { parseToolArguments } from "../providers/types.js";
|
|
3
|
+
import { toolSpecs } from "../tools/registry.js";
|
|
4
|
+
import { compactMessages, estimateTokens, isContextWindowError } from "./compact.js";
|
|
5
|
+
import { runHookEvent } from "./hooks.js";
|
|
6
|
+
import { decidePermission } from "./permissions.js";
|
|
7
|
+
import { PLAN_TOOL_NAMES } from "./plan.js";
|
|
8
|
+
import { repairTranscript } from "./transcript.js";
|
|
9
|
+
const MAX_TOOL_OUTPUT = 64 * 1024;
|
|
10
|
+
/** What the model sees in history is capped much lower than what the UI shows,
|
|
11
|
+
* otherwise every round resends tens of kilobytes and the context balloons. */
|
|
12
|
+
const MAX_HISTORY_OUTPUT = 8 * 1024;
|
|
13
|
+
/** Safety cap for one request's estimated input. This is deliberately not the
|
|
14
|
+
* cumulative usage of a turn: every tool round resends the conversation, so
|
|
15
|
+
* summing rounds would stop long tasks that still fit the model's window. */
|
|
16
|
+
const REQUEST_BUDGET = 400000;
|
|
17
|
+
const DENIED_READONLY = '当前权限模式是「禁止访问文件」,这次调用没有执行。运行 minko power 可以修改。';
|
|
18
|
+
const DENIED_WORKSPACE = '当前权限模式是「工作区权限」,只能读写当前工作目录内的内容,这次调用没有执行。';
|
|
19
|
+
function outsideWorkspace(args, cwd) {
|
|
20
|
+
const raw = typeof args.path === 'string' ? args.path : undefined;
|
|
21
|
+
if (!raw) {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
const target = path.resolve(cwd, raw);
|
|
25
|
+
const relative = path.relative(cwd, target);
|
|
26
|
+
return relative.startsWith('..') || path.isAbsolute(relative);
|
|
27
|
+
}
|
|
28
|
+
/** 400000 → "40", 1240000 → "124", 9500 → "0.9". */
|
|
29
|
+
function formatWanTokens(tokens) {
|
|
30
|
+
const wan = tokens / 10000;
|
|
31
|
+
return wan >= 10 ? String(Math.round(wan)) : wan.toFixed(1);
|
|
32
|
+
}
|
|
33
|
+
export async function runAgent(options) {
|
|
34
|
+
const { provider, tools, cwd, signal, approval, hooks } = options;
|
|
35
|
+
const power = options.power ?? 'restricted';
|
|
36
|
+
const specs = toolSpecs(tools);
|
|
37
|
+
const messages = options.messages.slice();
|
|
38
|
+
const maxRounds = options.maxRounds ?? 40;
|
|
39
|
+
const usage = { input: 0, output: 0 };
|
|
40
|
+
let rounds = 0;
|
|
41
|
+
const compactConfig = {
|
|
42
|
+
maxEstimatedTokens: options.compaction?.maxTokens ?? 100_000,
|
|
43
|
+
preserveRecent: options.compaction?.preserveRecent ?? 4,
|
|
44
|
+
};
|
|
45
|
+
const contextRecoveryPreserve = [4, 2, 1, 0];
|
|
46
|
+
let contextRecoveries = 0;
|
|
47
|
+
for (let round = 0; round < maxRounds; round += 1) {
|
|
48
|
+
if (signal.aborted) {
|
|
49
|
+
break;
|
|
50
|
+
}
|
|
51
|
+
const compacted = compactMessages(messages, compactConfig);
|
|
52
|
+
if (compacted.removed > 0) {
|
|
53
|
+
messages.splice(0, messages.length, ...compacted.messages);
|
|
54
|
+
hooks.onNotice?.('上下文已压缩:较早的 ' + compacted.removed + ' 条消息已折叠为摘要');
|
|
55
|
+
}
|
|
56
|
+
// The cap applies to what is about to be sent, not to the summed input of
|
|
57
|
+
// every round in this turn. Compact once more when the current request is
|
|
58
|
+
// still oversized; only stop when even the preserved tail is too large.
|
|
59
|
+
let estimated = estimateTokens(messages);
|
|
60
|
+
if (estimated > REQUEST_BUDGET) {
|
|
61
|
+
const forced = compactMessages(messages, {
|
|
62
|
+
maxEstimatedTokens: 0,
|
|
63
|
+
preserveRecent: compactConfig.preserveRecent,
|
|
64
|
+
});
|
|
65
|
+
if (forced.removed > 0) {
|
|
66
|
+
messages.splice(0, messages.length, ...forced.messages);
|
|
67
|
+
estimated = estimateTokens(messages);
|
|
68
|
+
hooks.onNotice?.('当前请求约 ' + formatWanTokens(estimated) + ' 万 token,已压缩较早内容后继续');
|
|
69
|
+
}
|
|
70
|
+
if (estimated > REQUEST_BUDGET) {
|
|
71
|
+
hooks.onNotice?.('当前请求约 ' + formatWanTokens(estimated) + ' 万 token,超过单次上限且无法再压缩;'
|
|
72
|
+
+ '请把任务拆小,或用 /new 开新会话');
|
|
73
|
+
break;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
rounds += 1;
|
|
77
|
+
let result;
|
|
78
|
+
for (;;) {
|
|
79
|
+
try {
|
|
80
|
+
result = await provider.stream(messages, specs, {
|
|
81
|
+
onText: hooks.onText,
|
|
82
|
+
onReasoning: hooks.onReasoning,
|
|
83
|
+
onRetry: (info) => {
|
|
84
|
+
const seconds = (info.delayMs / 1000).toFixed(1);
|
|
85
|
+
hooks.onNotice?.('请求失败,' + seconds + ' 秒后第 ' + info.attempt + ' 次重试');
|
|
86
|
+
},
|
|
87
|
+
onFallback: (info) => {
|
|
88
|
+
hooks.onNotice?.('服务商 ' + info.from + ' 失败,切换到 ' + info.to);
|
|
89
|
+
},
|
|
90
|
+
}, signal);
|
|
91
|
+
break;
|
|
92
|
+
}
|
|
93
|
+
catch (error) {
|
|
94
|
+
if (signal.aborted) {
|
|
95
|
+
throw error;
|
|
96
|
+
}
|
|
97
|
+
if (contextRecoveries < contextRecoveryPreserve.length && isContextWindowError(error)) {
|
|
98
|
+
const preserve = contextRecoveryPreserve[contextRecoveries];
|
|
99
|
+
contextRecoveries += 1;
|
|
100
|
+
const forced = compactMessages(messages, {
|
|
101
|
+
maxEstimatedTokens: 0,
|
|
102
|
+
preserveRecent: preserve,
|
|
103
|
+
});
|
|
104
|
+
if (forced.removed > 0) {
|
|
105
|
+
messages.splice(0, messages.length, ...forced.messages);
|
|
106
|
+
hooks.onNotice?.('上下文超限,已压缩较早内容后重试(保留 ' + preserve + ' 条最近消息)');
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
throw error;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
usage.input += result.usage.input;
|
|
114
|
+
usage.output += result.usage.output;
|
|
115
|
+
if (result.usage.cached && result.usage.cached > 0) {
|
|
116
|
+
usage.cached = (usage.cached ?? 0) + result.usage.cached;
|
|
117
|
+
}
|
|
118
|
+
const assistant = {
|
|
119
|
+
role: 'assistant',
|
|
120
|
+
content: result.text,
|
|
121
|
+
toolCalls: result.toolCalls,
|
|
122
|
+
reasoning: result.reasoning.length > 0 ? result.reasoning : undefined,
|
|
123
|
+
};
|
|
124
|
+
messages.push(assistant);
|
|
125
|
+
hooks.onAssistantMessage?.(assistant);
|
|
126
|
+
if (result.toolCalls.length === 0) {
|
|
127
|
+
break;
|
|
128
|
+
}
|
|
129
|
+
for (const call of result.toolCalls) {
|
|
130
|
+
if (signal.aborted) {
|
|
131
|
+
break;
|
|
132
|
+
}
|
|
133
|
+
const output = await executeTool(call, tools, cwd, signal, approval, power, hooks, options.permissions, options.toolHooks, options.planState, options.config, options.approvalStore);
|
|
134
|
+
messages.push({
|
|
135
|
+
role: 'tool',
|
|
136
|
+
toolCallId: call.id,
|
|
137
|
+
name: call.name,
|
|
138
|
+
content: output,
|
|
139
|
+
});
|
|
140
|
+
hooks.onAssistantMessage?.(messages[messages.length - 1]);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
// An abort or a provider failure can leave an assistant tool call without
|
|
144
|
+
// its result. Fill those holes so the session stays valid when resumed.
|
|
145
|
+
const repaired = repairTranscript(messages);
|
|
146
|
+
return { messages: repaired.messages, usage, rounds };
|
|
147
|
+
}
|
|
148
|
+
async function executeTool(call, tools, cwd, signal, approval, power, hooks, permissions, toolHooks, planState, config, approvalStore) {
|
|
149
|
+
const tool = tools.find((candidate) => candidate.spec.name === call.name);
|
|
150
|
+
const startedAt = Date.now();
|
|
151
|
+
let args = {};
|
|
152
|
+
try {
|
|
153
|
+
args = parseToolArguments(call.arguments);
|
|
154
|
+
}
|
|
155
|
+
catch (error) {
|
|
156
|
+
const message = '工具参数不是合法 JSON:' + (error instanceof Error ? error.message : String(error));
|
|
157
|
+
hooks.onToolEnd?.(call, { ok: false, output: message, ms: 0 });
|
|
158
|
+
return message;
|
|
159
|
+
}
|
|
160
|
+
if (!tool) {
|
|
161
|
+
const message = '没有名为 ' + call.name + ' 的工具。可用工具:' + tools.map((item) => item.spec.name).join('、');
|
|
162
|
+
hooks.onToolEnd?.(call, { ok: false, output: message, ms: 0 });
|
|
163
|
+
return message;
|
|
164
|
+
}
|
|
165
|
+
hooks.onToolStart?.(call, args);
|
|
166
|
+
try {
|
|
167
|
+
const pre = await runHookEvent({
|
|
168
|
+
event: 'PreToolUse',
|
|
169
|
+
config: toolHooks,
|
|
170
|
+
toolName: call.name,
|
|
171
|
+
toolInput: JSON.stringify(args),
|
|
172
|
+
signal,
|
|
173
|
+
});
|
|
174
|
+
if (pre.cancelled) {
|
|
175
|
+
return deniedTool(call, hooks, startedAt, '已中断,hook 取消了这个工具调用。');
|
|
176
|
+
}
|
|
177
|
+
if (pre.denied || pre.failed) {
|
|
178
|
+
const reason = pre.messages.length > 0 ? pre.messages.join(';') : 'hook 判定阻止';
|
|
179
|
+
return deniedTool(call, hooks, startedAt, '工具调用被 hook 阻止:' + reason);
|
|
180
|
+
}
|
|
181
|
+
if (pre.updatedInput !== undefined) {
|
|
182
|
+
try {
|
|
183
|
+
const parsed = JSON.parse(pre.updatedInput);
|
|
184
|
+
if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
185
|
+
args = parsed;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
catch {
|
|
189
|
+
// Keep the model's original arguments when the hook sends invalid JSON.
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
const rule = decidePermission(permissions, call.name, args);
|
|
193
|
+
if (rule === 'deny' || pre.permission === 'deny') {
|
|
194
|
+
const reason = pre.permissionReason ?? '匹配到 deny 规则';
|
|
195
|
+
return deniedTool(call, hooks, startedAt, '权限规则拒绝了这个工具调用:' + call.name + '(' + reason + ')。');
|
|
196
|
+
}
|
|
197
|
+
if (planState?.planMode && !tool.readOnly && !PLAN_TOOL_NAMES.has(call.name)) {
|
|
198
|
+
return deniedTool(call, hooks, startedAt, '计划模式只允许只读调查和计划工具;请先调用 exit_plan_mode 提交计划,或让用户退出计划模式。');
|
|
199
|
+
}
|
|
200
|
+
if (power === 'readonly') {
|
|
201
|
+
hooks.onToolEnd?.(call, { ok: false, output: DENIED_READONLY, ms: Date.now() - startedAt });
|
|
202
|
+
return DENIED_READONLY;
|
|
203
|
+
}
|
|
204
|
+
const needsApproval = !tool.readOnly
|
|
205
|
+
&& (tool.needsApproval ? tool.needsApproval(args, cwd) : true);
|
|
206
|
+
if (power === 'workspace' && !PLAN_TOOL_NAMES.has(call.name)) {
|
|
207
|
+
const outside = outsideWorkspace(args, cwd);
|
|
208
|
+
if ((!tool.readOnly && needsApproval) || (tool.readOnly && outside)) {
|
|
209
|
+
hooks.onToolEnd?.(call, { ok: false, output: DENIED_WORKSPACE, ms: Date.now() - startedAt });
|
|
210
|
+
return DENIED_WORKSPACE;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
const forcedAsk = rule === 'ask' || pre.permission === 'ask';
|
|
214
|
+
const ruleAllows = rule === 'allow' || pre.permission === 'allow';
|
|
215
|
+
const autoAllows = power === 'full' || approval === 'auto';
|
|
216
|
+
const needsPrompt = forcedAsk || (!autoAllows && !ruleAllows && needsApproval);
|
|
217
|
+
let approved = true;
|
|
218
|
+
if (needsPrompt) {
|
|
219
|
+
const token = approvalStore?.consume(call.name, args) ?? null;
|
|
220
|
+
if (token) {
|
|
221
|
+
hooks.onNotice?.('使用审批令牌 ' + token.id + '('
|
|
222
|
+
+ token.uses + '/' + token.maxUses + ')');
|
|
223
|
+
}
|
|
224
|
+
else {
|
|
225
|
+
approved = await hooks.approve({
|
|
226
|
+
tool: tool.spec.name,
|
|
227
|
+
title: forcedAsk ? '规则要求确认' : approvalTitle(tool.spec.name),
|
|
228
|
+
detail: describeCall(tool.spec.name, args)
|
|
229
|
+
+ (pre.permissionReason ? '(' + pre.permissionReason + ')' : ''),
|
|
230
|
+
danger: tool.spec.name === 'run_shell' ? 'exec' : 'write',
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
if (!approved) {
|
|
235
|
+
return deniedTool(call, hooks, startedAt, forcedAsk
|
|
236
|
+
? '规则要求确认,用户拒绝了这个工具调用。'
|
|
237
|
+
: '用户拒绝执行这个工具调用,没有产生任何改动。');
|
|
238
|
+
}
|
|
239
|
+
let output;
|
|
240
|
+
let failed = false;
|
|
241
|
+
try {
|
|
242
|
+
output = await withTimeout(tool.run(args, {
|
|
243
|
+
cwd,
|
|
244
|
+
signal,
|
|
245
|
+
approve: hooks.approve,
|
|
246
|
+
config,
|
|
247
|
+
planMode: Boolean(planState?.planMode),
|
|
248
|
+
}), signal, 120000);
|
|
249
|
+
}
|
|
250
|
+
catch (error) {
|
|
251
|
+
failed = true;
|
|
252
|
+
output = '工具执行失败:' + (error instanceof Error ? error.message : String(error));
|
|
253
|
+
}
|
|
254
|
+
const post = await runHookEvent({
|
|
255
|
+
event: failed ? 'PostToolUseFailure' : 'PostToolUse',
|
|
256
|
+
config: toolHooks,
|
|
257
|
+
toolName: call.name,
|
|
258
|
+
toolInput: JSON.stringify(args),
|
|
259
|
+
toolOutput: output,
|
|
260
|
+
isError: failed,
|
|
261
|
+
signal,
|
|
262
|
+
});
|
|
263
|
+
if (pre.messages.length > 0 || post.messages.length > 0) {
|
|
264
|
+
output = appendHookMessages(output, [...pre.messages, ...post.messages]);
|
|
265
|
+
}
|
|
266
|
+
if (post.denied || post.failed) {
|
|
267
|
+
output = '[hook] ' + (post.messages.join(';') || 'hook 判定失败') + '\n' + output;
|
|
268
|
+
}
|
|
269
|
+
const clipped = output.length > MAX_TOOL_OUTPUT
|
|
270
|
+
? output.slice(0, MAX_TOOL_OUTPUT) + '\n(输出过长,已截断)'
|
|
271
|
+
: output;
|
|
272
|
+
hooks.onToolEnd?.(call, { ok: !failed, output: clipped, ms: Date.now() - startedAt });
|
|
273
|
+
return clipped.length > MAX_HISTORY_OUTPUT
|
|
274
|
+
? clipped.slice(0, MAX_HISTORY_OUTPUT) + '\n…(结果过长,已截断)'
|
|
275
|
+
: clipped;
|
|
276
|
+
}
|
|
277
|
+
catch (error) {
|
|
278
|
+
const message = '工具执行失败:' + (error instanceof Error ? error.message : String(error));
|
|
279
|
+
hooks.onToolEnd?.(call, { ok: false, output: message, ms: Date.now() - startedAt });
|
|
280
|
+
return message;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
function deniedTool(call, hooks, startedAt, message) {
|
|
284
|
+
hooks.onToolEnd?.(call, { ok: false, output: message, ms: Date.now() - startedAt });
|
|
285
|
+
return message;
|
|
286
|
+
}
|
|
287
|
+
function appendHookMessages(output, messages) {
|
|
288
|
+
if (messages.length === 0) {
|
|
289
|
+
return output;
|
|
290
|
+
}
|
|
291
|
+
const block = '[hook] ' + messages.join('\n[hook] ');
|
|
292
|
+
return output.length > 0 ? output + '\n' + block : block;
|
|
293
|
+
}
|
|
294
|
+
function approvalTitle(name) {
|
|
295
|
+
if (name === 'run_shell') {
|
|
296
|
+
return '执行命令';
|
|
297
|
+
}
|
|
298
|
+
if (name === 'exit_plan_mode') {
|
|
299
|
+
return '确认计划';
|
|
300
|
+
}
|
|
301
|
+
if (name === 'todo_write' || name === 'enter_plan_mode') {
|
|
302
|
+
return '计划';
|
|
303
|
+
}
|
|
304
|
+
return '修改文件';
|
|
305
|
+
}
|
|
306
|
+
function describeCall(name, args) {
|
|
307
|
+
if (name === 'run_shell') {
|
|
308
|
+
return String(args.command ?? '');
|
|
309
|
+
}
|
|
310
|
+
if (name === 'exit_plan_mode') {
|
|
311
|
+
return '提交计划并退出计划模式';
|
|
312
|
+
}
|
|
313
|
+
if (name === 'enter_plan_mode') {
|
|
314
|
+
return '进入计划模式';
|
|
315
|
+
}
|
|
316
|
+
if (name === 'todo_write') {
|
|
317
|
+
const todos = Array.isArray(args.todos) ? args.todos.length : 0;
|
|
318
|
+
return '更新 ' + todos + ' 项待办';
|
|
319
|
+
}
|
|
320
|
+
if (name === 'write_file') {
|
|
321
|
+
const content = typeof args.content === 'string' ? args.content : '';
|
|
322
|
+
return String(args.path ?? '') + '(' + content.length + ' 字符)';
|
|
323
|
+
}
|
|
324
|
+
return String(args.path ?? '');
|
|
325
|
+
}
|
|
326
|
+
function withTimeout(promise, signal, ms) {
|
|
327
|
+
return new Promise((resolve, reject) => {
|
|
328
|
+
const timer = setTimeout(() => reject(new Error('工具执行超时')), ms);
|
|
329
|
+
const onAbort = () => reject(new Error('已中断'));
|
|
330
|
+
const cleanup = () => {
|
|
331
|
+
clearTimeout(timer);
|
|
332
|
+
signal.removeEventListener('abort', onAbort);
|
|
333
|
+
};
|
|
334
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
335
|
+
promise.then((value) => { cleanup(); resolve(value); }, (error) => { cleanup(); reject(error); });
|
|
336
|
+
});
|
|
337
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { minkoHome } from "../config/paths.js";
|
|
5
|
+
const MEMORY_TYPES = ['user', 'feedback', 'project', 'reference'];
|
|
6
|
+
const MAX_CONTENT = 20_000;
|
|
7
|
+
export function defaultMemoryDir() {
|
|
8
|
+
return path.join(minkoHome(), 'memory');
|
|
9
|
+
}
|
|
10
|
+
export function writeMemory(dir, input) {
|
|
11
|
+
const name = input.name.trim();
|
|
12
|
+
const description = input.description.trim();
|
|
13
|
+
const content = input.content.trim();
|
|
14
|
+
if (name.length === 0 || name.length > 80) {
|
|
15
|
+
throw new Error('name 必须是 1-80 个字符');
|
|
16
|
+
}
|
|
17
|
+
if (description.length === 0 || description.length > 200) {
|
|
18
|
+
throw new Error('description 必须是 1-200 个字符');
|
|
19
|
+
}
|
|
20
|
+
if (!MEMORY_TYPES.includes(input.type)) {
|
|
21
|
+
throw new Error('type 必须是 user / feedback / project / reference');
|
|
22
|
+
}
|
|
23
|
+
if (content.length === 0 || content.length > MAX_CONTENT) {
|
|
24
|
+
throw new Error('content 必须是 1-' + MAX_CONTENT + ' 个字符');
|
|
25
|
+
}
|
|
26
|
+
const slug = slugFor(name);
|
|
27
|
+
const file = path.join(dir, slug + '.md');
|
|
28
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
29
|
+
fs.writeFileSync(file, [
|
|
30
|
+
'---',
|
|
31
|
+
'name: ' + oneLine(name),
|
|
32
|
+
'description: ' + oneLine(description),
|
|
33
|
+
'type: ' + input.type,
|
|
34
|
+
'---',
|
|
35
|
+
content,
|
|
36
|
+
].join('\n'), 'utf8');
|
|
37
|
+
updateIndex(dir, name, description, slug);
|
|
38
|
+
return file;
|
|
39
|
+
}
|
|
40
|
+
export function readMemory(dir, name) {
|
|
41
|
+
const file = path.join(dir, slugFor(name.trim()) + '.md');
|
|
42
|
+
try {
|
|
43
|
+
const raw = fs.readFileSync(file, 'utf8');
|
|
44
|
+
const frontmatter = raw.match(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/);
|
|
45
|
+
return (frontmatter ? raw.slice(frontmatter[0].length) : raw).trim();
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
export function listMemoryIndex(dir) {
|
|
52
|
+
try {
|
|
53
|
+
return fs.readFileSync(path.join(dir, 'MEMORY.md'), 'utf8').trim();
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return '';
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
export function loadMemoryContext(dir) {
|
|
60
|
+
const index = listMemoryIndex(dir);
|
|
61
|
+
if (index.length === 0) {
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
return { dir, index };
|
|
65
|
+
}
|
|
66
|
+
/** Read/write tools over a user-owned memory directory. */
|
|
67
|
+
export function createMemoryTools(dir) {
|
|
68
|
+
const write = {
|
|
69
|
+
readOnly: false,
|
|
70
|
+
// Writing to Minko's own memory store is low-risk and should not prompt.
|
|
71
|
+
needsApproval: () => false,
|
|
72
|
+
spec: {
|
|
73
|
+
name: 'memory_write',
|
|
74
|
+
description: '把跨会话仍有价值的信息写入长期记忆(用户偏好、纠正、项目背景)。不要保存代码结构、临时任务状态。',
|
|
75
|
+
parameters: {
|
|
76
|
+
type: 'object',
|
|
77
|
+
properties: {
|
|
78
|
+
name: { type: 'string', description: '短名称,1-80 字符' },
|
|
79
|
+
description: { type: 'string', description: '一句话摘要,1-200 字符' },
|
|
80
|
+
type: {
|
|
81
|
+
type: 'string',
|
|
82
|
+
enum: ['user', 'feedback', 'project', 'reference'],
|
|
83
|
+
},
|
|
84
|
+
content: { type: 'string', description: '记忆正文,最多 20000 字符' },
|
|
85
|
+
},
|
|
86
|
+
required: ['name', 'description', 'type', 'content'],
|
|
87
|
+
additionalProperties: false,
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
async run(args) {
|
|
91
|
+
const name = typeof args.name === 'string' ? args.name : '';
|
|
92
|
+
const description = typeof args.description === 'string' ? args.description : '';
|
|
93
|
+
const type = typeof args.type === 'string' ? args.type : 'user';
|
|
94
|
+
const content = typeof args.content === 'string' ? args.content : '';
|
|
95
|
+
const file = writeMemory(dir, { name, description, type, content });
|
|
96
|
+
return '已保存记忆:' + name.trim() + '(' + type + ')\n' + file;
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
const read = {
|
|
100
|
+
readOnly: true,
|
|
101
|
+
spec: {
|
|
102
|
+
name: 'memory_read',
|
|
103
|
+
description: '读取一条长期记忆的完整内容;MEMORY.md 索引会自动出现在上下文里。',
|
|
104
|
+
parameters: {
|
|
105
|
+
type: 'object',
|
|
106
|
+
properties: {
|
|
107
|
+
name: { type: 'string', description: '记忆名称' },
|
|
108
|
+
},
|
|
109
|
+
required: ['name'],
|
|
110
|
+
additionalProperties: false,
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
async run(args) {
|
|
114
|
+
const name = typeof args.name === 'string' ? args.name.trim() : '';
|
|
115
|
+
if (name.length === 0) {
|
|
116
|
+
throw new Error('缺少 name 参数');
|
|
117
|
+
}
|
|
118
|
+
const content = readMemory(dir, name);
|
|
119
|
+
if (content === null) {
|
|
120
|
+
throw new Error('没有找到记忆「' + name + '」。先用 memory_write 创建。');
|
|
121
|
+
}
|
|
122
|
+
return '记忆「' + name + '」:\n' + content;
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
return [write, read];
|
|
126
|
+
}
|
|
127
|
+
function updateIndex(dir, name, description, slug) {
|
|
128
|
+
const indexPath = path.join(dir, 'MEMORY.md');
|
|
129
|
+
let lines = [];
|
|
130
|
+
try {
|
|
131
|
+
lines = fs.readFileSync(indexPath, 'utf8').split(/\r?\n/);
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
// First entry.
|
|
135
|
+
}
|
|
136
|
+
const link = '(' + slug + '.md)';
|
|
137
|
+
lines = lines.filter((line) => !line.includes(link));
|
|
138
|
+
while (lines.length > 0 && lines[lines.length - 1].trim().length === 0) {
|
|
139
|
+
lines.pop();
|
|
140
|
+
}
|
|
141
|
+
if (lines.length === 0) {
|
|
142
|
+
lines.push('# Memory index', '');
|
|
143
|
+
}
|
|
144
|
+
lines.push('- [' + oneLine(name) + '](' + slug + '.md) — ' + oneLine(description));
|
|
145
|
+
fs.writeFileSync(indexPath, lines.join('\n') + '\n', 'utf8');
|
|
146
|
+
}
|
|
147
|
+
function slugFor(name) {
|
|
148
|
+
const base = name.toLowerCase()
|
|
149
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
150
|
+
.replace(/^-+|-+$/g, '')
|
|
151
|
+
.slice(0, 40);
|
|
152
|
+
if (base.length > 0) {
|
|
153
|
+
return base;
|
|
154
|
+
}
|
|
155
|
+
return 'm-' + crypto.createHash('sha1').update(name).digest('hex').slice(0, 8);
|
|
156
|
+
}
|
|
157
|
+
function oneLine(value) {
|
|
158
|
+
return value.replace(/\s+/g, ' ').trim();
|
|
159
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rule syntax:
|
|
3
|
+
* run_shell
|
|
4
|
+
* run_shell(git push*)
|
|
5
|
+
* write_file(src/*)
|
|
6
|
+
* A pattern without `*` or `?` is a case-insensitive prefix match.
|
|
7
|
+
*/
|
|
8
|
+
export function parsePermissionRule(raw) {
|
|
9
|
+
const trimmed = raw.trim();
|
|
10
|
+
const match = trimmed.match(/^([A-Za-z0-9_.-]+)(?:\((.*)\))?$/s);
|
|
11
|
+
if (!match) {
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
const pattern = match[2] === undefined ? undefined : match[2].trim();
|
|
15
|
+
return pattern && pattern.length > 0
|
|
16
|
+
? { tool: match[1], pattern }
|
|
17
|
+
: { tool: match[1] };
|
|
18
|
+
}
|
|
19
|
+
export function permissionSubject(toolName, args) {
|
|
20
|
+
if (toolName === 'run_shell' && typeof args.command === 'string') {
|
|
21
|
+
return args.command;
|
|
22
|
+
}
|
|
23
|
+
if (typeof args.path === 'string') {
|
|
24
|
+
return args.path.replace(/\\/g, '/');
|
|
25
|
+
}
|
|
26
|
+
if (typeof args.pattern === 'string') {
|
|
27
|
+
return args.pattern.replace(/\\/g, '/');
|
|
28
|
+
}
|
|
29
|
+
try {
|
|
30
|
+
return JSON.stringify(args) ?? '';
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return '';
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
export function ruleMatches(rule, toolName, args) {
|
|
37
|
+
if (rule.tool.toLowerCase() !== toolName.toLowerCase()) {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
if (rule.pattern === undefined) {
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
const subject = permissionSubject(toolName, args);
|
|
44
|
+
const pattern = rule.pattern.replace(/\\/g, '/');
|
|
45
|
+
if (/[*?]/.test(pattern)) {
|
|
46
|
+
return globToRegExp(pattern).test(subject);
|
|
47
|
+
}
|
|
48
|
+
return subject.toLowerCase().startsWith(pattern.toLowerCase());
|
|
49
|
+
}
|
|
50
|
+
/** deny > ask > allow; the first matching list wins. */
|
|
51
|
+
export function decidePermission(rules, toolName, args) {
|
|
52
|
+
if (!rules) {
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
for (const decision of ['deny', 'ask', 'allow']) {
|
|
56
|
+
for (const raw of rules[decision]) {
|
|
57
|
+
const rule = parsePermissionRule(raw);
|
|
58
|
+
if (rule && ruleMatches(rule, toolName, args)) {
|
|
59
|
+
return decision;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
function globToRegExp(pattern) {
|
|
66
|
+
const escaped = pattern
|
|
67
|
+
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
|
68
|
+
.replace(/\*/g, '.*')
|
|
69
|
+
.replace(/\?/g, '.');
|
|
70
|
+
return new RegExp('^' + escaped + '$', 'i');
|
|
71
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
export const PLAN_TOOL_NAMES = new Set(['todo_write', 'enter_plan_mode', 'exit_plan_mode']);
|
|
2
|
+
export function normalizeTodos(value) {
|
|
3
|
+
if (!Array.isArray(value)) {
|
|
4
|
+
return [];
|
|
5
|
+
}
|
|
6
|
+
const out = [];
|
|
7
|
+
for (const raw of value) {
|
|
8
|
+
if (out.length >= 50) {
|
|
9
|
+
break;
|
|
10
|
+
}
|
|
11
|
+
if (raw === null || typeof raw !== 'object') {
|
|
12
|
+
continue;
|
|
13
|
+
}
|
|
14
|
+
const record = raw;
|
|
15
|
+
if (typeof record.content !== 'string') {
|
|
16
|
+
continue;
|
|
17
|
+
}
|
|
18
|
+
const content = record.content.trim().slice(0, 500);
|
|
19
|
+
if (content.length === 0) {
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
const status = record.status === 'in_progress' || record.status === 'completed'
|
|
23
|
+
? record.status
|
|
24
|
+
: 'pending';
|
|
25
|
+
out.push({ content, status });
|
|
26
|
+
}
|
|
27
|
+
return out;
|
|
28
|
+
}
|
|
29
|
+
export function renderTodos(todos) {
|
|
30
|
+
if (todos.length === 0) {
|
|
31
|
+
return '(清单为空)';
|
|
32
|
+
}
|
|
33
|
+
return todos.map((todo) => {
|
|
34
|
+
const mark = todo.status === 'completed' ? 'x' : todo.status === 'in_progress' ? '~' : ' ';
|
|
35
|
+
return '- [' + mark + '] ' + todo.content;
|
|
36
|
+
}).join('\n');
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Plan/Todo tools operate on the session's plan state. They are created per
|
|
40
|
+
* session so state survives turns and is persisted with the session meta.
|
|
41
|
+
*/
|
|
42
|
+
export function createPlanTools(state) {
|
|
43
|
+
const todoWrite = {
|
|
44
|
+
readOnly: true,
|
|
45
|
+
spec: {
|
|
46
|
+
name: 'todo_write',
|
|
47
|
+
description: '维护多步骤任务的待办清单;每次调用整表替换。状态:pending / in_progress / completed。',
|
|
48
|
+
parameters: {
|
|
49
|
+
type: 'object',
|
|
50
|
+
properties: {
|
|
51
|
+
todos: {
|
|
52
|
+
type: 'array',
|
|
53
|
+
items: {
|
|
54
|
+
type: 'object',
|
|
55
|
+
properties: {
|
|
56
|
+
content: { type: 'string' },
|
|
57
|
+
status: {
|
|
58
|
+
type: 'string',
|
|
59
|
+
enum: ['pending', 'in_progress', 'completed'],
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
required: ['content'],
|
|
63
|
+
additionalProperties: false,
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
required: ['todos'],
|
|
68
|
+
additionalProperties: false,
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
async run(args) {
|
|
72
|
+
const todos = normalizeTodos(args.todos);
|
|
73
|
+
state.todos = todos;
|
|
74
|
+
return '清单已更新(' + todos.length + ' 项):\n' + renderTodos(todos);
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
const enterPlanMode = {
|
|
78
|
+
readOnly: true,
|
|
79
|
+
spec: {
|
|
80
|
+
name: 'enter_plan_mode',
|
|
81
|
+
description: '进入只读计划模式:先调查,再提交分步骤计划;此模式不能写文件或执行命令。',
|
|
82
|
+
parameters: { type: 'object', properties: {}, additionalProperties: false },
|
|
83
|
+
},
|
|
84
|
+
async run() {
|
|
85
|
+
state.planMode = true;
|
|
86
|
+
return '已进入计划模式:只能只读调查;完成后调用 exit_plan_mode 提交计划。';
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
const exitPlanMode = {
|
|
90
|
+
readOnly: false,
|
|
91
|
+
needsApproval: () => true,
|
|
92
|
+
spec: {
|
|
93
|
+
name: 'exit_plan_mode',
|
|
94
|
+
description: '提交计划并退出计划模式;需要用户确认。',
|
|
95
|
+
parameters: {
|
|
96
|
+
type: 'object',
|
|
97
|
+
properties: {
|
|
98
|
+
plan: {
|
|
99
|
+
type: 'string',
|
|
100
|
+
description: '分步骤计划:改哪些文件、怎么验证、有什么风险。',
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
required: ['plan'],
|
|
104
|
+
additionalProperties: false,
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
async run(args) {
|
|
108
|
+
const plan = typeof args.plan === 'string' ? args.plan.trim() : '';
|
|
109
|
+
if (plan.length === 0) {
|
|
110
|
+
throw new Error('缺少 plan 参数');
|
|
111
|
+
}
|
|
112
|
+
state.planMode = false;
|
|
113
|
+
state.plan = plan;
|
|
114
|
+
return '计划已确认,退出计划模式:\n\n' + plan;
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
return [todoWrite, enterPlanMode, exitPlanMode];
|
|
118
|
+
}
|