autocrew 0.1.0

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 (165) hide show
  1. package/HAMLETDEER.md +562 -0
  2. package/LICENSE +21 -0
  3. package/README.md +190 -0
  4. package/README_CN.md +190 -0
  5. package/adapters/openclaw/index.ts +68 -0
  6. package/bin/autocrew.mjs +23 -0
  7. package/bin/autocrew.ts +13 -0
  8. package/openclaw.plugin.json +36 -0
  9. package/package.json +74 -0
  10. package/skills/_writing-style/SKILL.md +68 -0
  11. package/skills/audience-profiler/SKILL.md +241 -0
  12. package/skills/content-attribution/SKILL.md +128 -0
  13. package/skills/content-review/SKILL.md +257 -0
  14. package/skills/cover-generator/SKILL.md +93 -0
  15. package/skills/humanizer-zh/SKILL.md +75 -0
  16. package/skills/intel-digest/SKILL.md +57 -0
  17. package/skills/intel-pull/SKILL.md +74 -0
  18. package/skills/manage-pipeline/SKILL.md +63 -0
  19. package/skills/memory-distill/SKILL.md +89 -0
  20. package/skills/onboarding/SKILL.md +117 -0
  21. package/skills/pipeline-status/SKILL.md +51 -0
  22. package/skills/platform-rewrite/SKILL.md +125 -0
  23. package/skills/pre-publish/SKILL.md +142 -0
  24. package/skills/publish-content/SKILL.md +500 -0
  25. package/skills/remix-content/SKILL.md +77 -0
  26. package/skills/research/SKILL.md +127 -0
  27. package/skills/setup/SKILL.md +353 -0
  28. package/skills/spawn-batch-writer/SKILL.md +66 -0
  29. package/skills/spawn-planner/SKILL.md +72 -0
  30. package/skills/spawn-writer/SKILL.md +60 -0
  31. package/skills/teardown/SKILL.md +144 -0
  32. package/skills/title-craft/SKILL.md +234 -0
  33. package/skills/topic-ideas/SKILL.md +105 -0
  34. package/skills/video-timeline/SKILL.md +117 -0
  35. package/skills/write-script/SKILL.md +232 -0
  36. package/skills/xhs-cover-review/SKILL.md +48 -0
  37. package/src/adapters/browser/browser-cdp.ts +260 -0
  38. package/src/adapters/browser/browser-relay.ts +236 -0
  39. package/src/adapters/browser/gateway-client.ts +148 -0
  40. package/src/adapters/browser/types.ts +36 -0
  41. package/src/adapters/image/gemini.ts +219 -0
  42. package/src/adapters/research/tikhub.ts +19 -0
  43. package/src/cli/banner.ts +18 -0
  44. package/src/cli/bootstrap.ts +33 -0
  45. package/src/cli/commands/adapt.ts +28 -0
  46. package/src/cli/commands/advance.ts +28 -0
  47. package/src/cli/commands/assets.ts +24 -0
  48. package/src/cli/commands/audit.ts +18 -0
  49. package/src/cli/commands/contents.ts +18 -0
  50. package/src/cli/commands/cover.ts +58 -0
  51. package/src/cli/commands/events.ts +17 -0
  52. package/src/cli/commands/humanize.ts +27 -0
  53. package/src/cli/commands/index.ts +80 -0
  54. package/src/cli/commands/init.ts +28 -0
  55. package/src/cli/commands/intel.ts +55 -0
  56. package/src/cli/commands/learn.ts +34 -0
  57. package/src/cli/commands/memory.ts +18 -0
  58. package/src/cli/commands/migrate.ts +24 -0
  59. package/src/cli/commands/open.ts +21 -0
  60. package/src/cli/commands/pipelines.ts +18 -0
  61. package/src/cli/commands/pre-publish.ts +27 -0
  62. package/src/cli/commands/profile.ts +31 -0
  63. package/src/cli/commands/research.ts +36 -0
  64. package/src/cli/commands/restore.ts +28 -0
  65. package/src/cli/commands/review.ts +61 -0
  66. package/src/cli/commands/start.ts +28 -0
  67. package/src/cli/commands/status.ts +14 -0
  68. package/src/cli/commands/templates.ts +15 -0
  69. package/src/cli/commands/topics.ts +18 -0
  70. package/src/cli/commands/trash.ts +28 -0
  71. package/src/cli/commands/upgrade.ts +48 -0
  72. package/src/cli/commands/versions.ts +24 -0
  73. package/src/cli/index.ts +40 -0
  74. package/src/data/sensitive-words-builtin.json +114 -0
  75. package/src/data/source-presets.yaml +54 -0
  76. package/src/e2e.test.ts +596 -0
  77. package/src/modules/auth/cookie-manager.ts +113 -0
  78. package/src/modules/cards/template-engine.ts +74 -0
  79. package/src/modules/cards/templates/comparison-table.ts +71 -0
  80. package/src/modules/cards/templates/data-chart.ts +76 -0
  81. package/src/modules/cards/templates/flow-chart.ts +49 -0
  82. package/src/modules/cards/templates/key-points.ts +59 -0
  83. package/src/modules/cover/prompt-builder.test.ts +157 -0
  84. package/src/modules/cover/prompt-builder.ts +212 -0
  85. package/src/modules/cover/ratio-adapter.test.ts +122 -0
  86. package/src/modules/cover/ratio-adapter.ts +104 -0
  87. package/src/modules/filter/sensitive-words.test.ts +72 -0
  88. package/src/modules/filter/sensitive-words.ts +212 -0
  89. package/src/modules/humanizer/zh.test.ts +75 -0
  90. package/src/modules/humanizer/zh.ts +175 -0
  91. package/src/modules/intel/collector.ts +19 -0
  92. package/src/modules/intel/collectors/competitor.test.ts +71 -0
  93. package/src/modules/intel/collectors/competitor.ts +65 -0
  94. package/src/modules/intel/collectors/rss.test.ts +56 -0
  95. package/src/modules/intel/collectors/rss.ts +70 -0
  96. package/src/modules/intel/collectors/trends.test.ts +80 -0
  97. package/src/modules/intel/collectors/trends.ts +107 -0
  98. package/src/modules/intel/collectors/web-search.test.ts +85 -0
  99. package/src/modules/intel/collectors/web-search.ts +81 -0
  100. package/src/modules/intel/integration.test.ts +203 -0
  101. package/src/modules/intel/intel-engine.test.ts +103 -0
  102. package/src/modules/intel/intel-engine.ts +96 -0
  103. package/src/modules/intel/source-config.test.ts +113 -0
  104. package/src/modules/intel/source-config.ts +131 -0
  105. package/src/modules/learnings/diff-tracker.test.ts +144 -0
  106. package/src/modules/learnings/diff-tracker.ts +189 -0
  107. package/src/modules/learnings/rule-distiller.ts +141 -0
  108. package/src/modules/memory/distill.ts +208 -0
  109. package/src/modules/migrate/legacy-migrate.test.ts +169 -0
  110. package/src/modules/migrate/legacy-migrate.ts +229 -0
  111. package/src/modules/pro/api-client.ts +192 -0
  112. package/src/modules/pro/gate.test.ts +110 -0
  113. package/src/modules/pro/gate.ts +104 -0
  114. package/src/modules/profile/creator-profile.test.ts +178 -0
  115. package/src/modules/profile/creator-profile.ts +248 -0
  116. package/src/modules/publish/douyin-api.ts +34 -0
  117. package/src/modules/publish/wechat-mp.ts +320 -0
  118. package/src/modules/publish/xiaohongshu-api.ts +127 -0
  119. package/src/modules/research/free-engine.ts +360 -0
  120. package/src/modules/timeline/markup-generator.ts +63 -0
  121. package/src/modules/timeline/parser.ts +275 -0
  122. package/src/modules/workflow/templates.ts +124 -0
  123. package/src/modules/writing/platform-rewrite.ts +190 -0
  124. package/src/modules/writing/title-hashtag.ts +385 -0
  125. package/src/runtime/context.test.ts +97 -0
  126. package/src/runtime/context.ts +129 -0
  127. package/src/runtime/events.test.ts +83 -0
  128. package/src/runtime/events.ts +104 -0
  129. package/src/runtime/hooks.ts +174 -0
  130. package/src/runtime/tool-runner.test.ts +204 -0
  131. package/src/runtime/tool-runner.ts +282 -0
  132. package/src/runtime/workflow-engine.test.ts +455 -0
  133. package/src/runtime/workflow-engine.ts +391 -0
  134. package/src/server/index.ts +409 -0
  135. package/src/server/start.ts +39 -0
  136. package/src/storage/local-store.test.ts +304 -0
  137. package/src/storage/local-store.ts +704 -0
  138. package/src/storage/pipeline-store.test.ts +363 -0
  139. package/src/storage/pipeline-store.ts +698 -0
  140. package/src/tools/asset.ts +96 -0
  141. package/src/tools/content-save.ts +276 -0
  142. package/src/tools/cover-review.ts +221 -0
  143. package/src/tools/humanize.ts +54 -0
  144. package/src/tools/init.ts +133 -0
  145. package/src/tools/intel.ts +92 -0
  146. package/src/tools/memory.ts +76 -0
  147. package/src/tools/pipeline-ops.ts +109 -0
  148. package/src/tools/pipeline.ts +168 -0
  149. package/src/tools/pre-publish.ts +232 -0
  150. package/src/tools/publish.ts +183 -0
  151. package/src/tools/registry.ts +198 -0
  152. package/src/tools/research.ts +304 -0
  153. package/src/tools/review.ts +305 -0
  154. package/src/tools/rewrite.ts +165 -0
  155. package/src/tools/status.ts +30 -0
  156. package/src/tools/timeline.ts +234 -0
  157. package/src/tools/topic-create.ts +50 -0
  158. package/src/types/providers.ts +69 -0
  159. package/src/types/timeline.test.ts +147 -0
  160. package/src/types/timeline.ts +83 -0
  161. package/src/utils/retry.test.ts +97 -0
  162. package/src/utils/retry.ts +85 -0
  163. package/templates/AGENTS.md +99 -0
  164. package/templates/SOUL.md +31 -0
  165. package/templates/TOOLS.md +76 -0
@@ -0,0 +1,204 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import { ToolRunner, type ToolDefinition } from "../runtime/tool-runner.js";
3
+ import { createContext } from "../runtime/context.js";
4
+ import { EventBus } from "../runtime/events.js";
5
+ import fs from "node:fs/promises";
6
+ import os from "node:os";
7
+ import path from "node:path";
8
+
9
+ let testDir: string;
10
+
11
+ function makeTool(name: string, result: Record<string, unknown> = { ok: true }): ToolDefinition {
12
+ return {
13
+ name,
14
+ label: name,
15
+ description: `Test tool ${name}`,
16
+ parameters: { type: "object" as const, properties: {} },
17
+ execute: vi.fn(async () => result),
18
+ };
19
+ }
20
+
21
+ /** Create a complete profile so onboarding gate doesn't block */
22
+ async function seedProfile(dir: string) {
23
+ const profileDir = path.join(dir, "profiles");
24
+ await fs.mkdir(profileDir, { recursive: true });
25
+ const profile = {
26
+ industry: "tech",
27
+ platforms: ["xhs"],
28
+ audiencePersona: { name: "test", age: "25-35", job: "dev" },
29
+ styleCalibrated: true,
30
+ writingRules: [],
31
+ competitorAccounts: [],
32
+ performanceHistory: [],
33
+ };
34
+ await fs.writeFile(path.join(dir, "creator-profile.json"), JSON.stringify(profile));
35
+ }
36
+
37
+ describe("ToolRunner", () => {
38
+ let runner: ToolRunner;
39
+ let eventBus: EventBus;
40
+
41
+ beforeEach(async () => {
42
+ testDir = await fs.mkdtemp(path.join(os.tmpdir(), "autocrew-runner-test-"));
43
+ await seedProfile(testDir);
44
+ const ctx = createContext({ data_dir: testDir });
45
+ eventBus = new EventBus();
46
+ runner = new ToolRunner({ ctx, eventBus });
47
+ });
48
+
49
+ it("registers and retrieves tools", () => {
50
+ runner.register(makeTool("test_tool"));
51
+ expect(runner.getTool("test_tool")).toBeDefined();
52
+ expect(runner.getTools()).toHaveLength(1);
53
+ });
54
+
55
+ it("executes a tool and returns result", async () => {
56
+ runner.register(makeTool("test_tool", { ok: true, value: 42 }));
57
+ const result = await runner.execute("test_tool", {});
58
+ expect(result.ok).toBe(true);
59
+ expect(result.value).toBe(42);
60
+ });
61
+
62
+ it("returns error for unknown tool", async () => {
63
+ const result = await runner.execute("nonexistent", {});
64
+ expect(result.ok).toBe(false);
65
+ expect(result.error).toContain("Unknown tool");
66
+ });
67
+
68
+ it("injects _dataDir via middleware", async () => {
69
+ const tool = makeTool("test_tool");
70
+ runner.register(tool);
71
+ await runner.execute("test_tool", {});
72
+ const passedParams = (tool.execute as any).mock.calls[0][0];
73
+ expect(passedParams._dataDir).toBe(testDir);
74
+ });
75
+
76
+ it("catches errors and returns error result", async () => {
77
+ runner.register({
78
+ name: "crash_tool",
79
+ label: "Crash",
80
+ description: "Crashes",
81
+ parameters: { type: "object" as const, properties: {} },
82
+ execute: async () => { throw new Error("boom"); },
83
+ });
84
+ const result = await runner.execute("crash_tool", {});
85
+ expect(result.ok).toBe(false);
86
+ expect(result.error).toContain("boom");
87
+ });
88
+
89
+ it("records audit entries", async () => {
90
+ runner.register(makeTool("test_tool"));
91
+ await runner.execute("test_tool", { action: "test" });
92
+ const ctx = runner["ctx"];
93
+ expect(ctx.audit.length).toBeGreaterThanOrEqual(1);
94
+ expect(ctx.audit[0].tool).toBe("test_tool");
95
+ expect(ctx.audit[0].action).toBe("test");
96
+ expect(ctx.audit[0].ok).toBe(true);
97
+ expect(ctx.audit[0].durationMs).toBeGreaterThanOrEqual(0);
98
+ });
99
+
100
+ it("tracks activeContentId in workspace", async () => {
101
+ runner.register(makeTool("autocrew_content", { ok: true, id: "content-123" }));
102
+ await runner.execute("autocrew_content", {});
103
+ const ctx = runner["ctx"];
104
+ expect(ctx.workspace.activeContentId).toBe("content-123");
105
+ });
106
+
107
+ it("tracks activeTopicId in workspace", async () => {
108
+ runner.register(makeTool("autocrew_topic", { ok: true, id: "topic-456" }));
109
+ await runner.execute("autocrew_topic", {});
110
+ const ctx = runner["ctx"];
111
+ expect(ctx.workspace.activeTopicId).toBe("topic-456");
112
+ });
113
+
114
+ it("emits pre and post events", async () => {
115
+ const handler = vi.fn();
116
+ eventBus.on("*", handler);
117
+ runner.register(makeTool("test_tool"));
118
+ await runner.execute("test_tool", {});
119
+ // Should have pre + post events
120
+ const types = handler.mock.calls.map((c: any) => c[0].type);
121
+ expect(types).toContain("tool:pre_execute");
122
+ expect(types).toContain("tool:post_execute");
123
+ });
124
+
125
+ it("emits execute_failed event on error result", async () => {
126
+ const handler = vi.fn();
127
+ eventBus.on("tool:execute_failed", handler);
128
+ runner.register(makeTool("fail_tool", { ok: false, error: "nope" }));
129
+ await runner.execute("fail_tool", {});
130
+ expect(handler).toHaveBeenCalledTimes(1);
131
+ });
132
+
133
+ it("injects gemini config for needsGemini tools", async () => {
134
+ const tool: ToolDefinition = {
135
+ ...makeTool("gemini_tool"),
136
+ needsGemini: true,
137
+ };
138
+ runner.register(tool);
139
+ await runner.execute("gemini_tool", {});
140
+ const passedParams = (tool.execute as any).mock.calls[0][0];
141
+ expect("_geminiApiKey" in passedParams).toBe(true);
142
+ expect("_geminiModel" in passedParams).toBe(true);
143
+ });
144
+
145
+ it("does NOT inject gemini config for non-gemini tools", async () => {
146
+ const tool = makeTool("normal_tool");
147
+ runner.register(tool);
148
+ await runner.execute("normal_tool", {});
149
+ const passedParams = (tool.execute as any).mock.calls[0][0];
150
+ expect("_geminiApiKey" in passedParams).toBe(false);
151
+ });
152
+
153
+ it("onboarding gate blocks tools when no profile exists", async () => {
154
+ // Create a runner with empty dataDir (no profile)
155
+ const emptyDir = await fs.mkdtemp(path.join(os.tmpdir(), "autocrew-empty-"));
156
+ const emptyCtx = createContext({ data_dir: emptyDir });
157
+ const emptyRunner = new ToolRunner({ ctx: emptyCtx, eventBus });
158
+
159
+ emptyRunner.register(makeTool("autocrew_content", { ok: true }));
160
+ const result = await emptyRunner.execute("autocrew_content", {});
161
+ expect(result.ok).toBe(false);
162
+ expect(result.error).toBe("onboarding_required");
163
+
164
+ await fs.rm(emptyDir, { recursive: true, force: true });
165
+ });
166
+
167
+ it("onboarding gate allows exempt tools without profile", async () => {
168
+ const emptyDir = await fs.mkdtemp(path.join(os.tmpdir(), "autocrew-empty-"));
169
+ const emptyCtx = createContext({ data_dir: emptyDir });
170
+ const emptyRunner = new ToolRunner({ ctx: emptyCtx, eventBus });
171
+
172
+ emptyRunner.register(makeTool("autocrew_init", { ok: true, dataDir: emptyDir }));
173
+ const result = await emptyRunner.execute("autocrew_init", {});
174
+ expect(result.ok).toBe(true);
175
+
176
+ await fs.rm(emptyDir, { recursive: true, force: true });
177
+ });
178
+
179
+ it("onboarding gate blocks when style not calibrated", async () => {
180
+ const uncalDir = await fs.mkdtemp(path.join(os.tmpdir(), "autocrew-uncal-"));
181
+ const profile = {
182
+ industry: "tech",
183
+ platforms: ["xhs"],
184
+ audiencePersona: { name: "test", age: "25-35", job: "dev" },
185
+ styleCalibrated: false,
186
+ writingRules: [],
187
+ competitorAccounts: [],
188
+ performanceHistory: [],
189
+ };
190
+ await fs.writeFile(path.join(uncalDir, "creator-profile.json"), JSON.stringify(profile));
191
+
192
+ const uncalCtx = createContext({ data_dir: uncalDir });
193
+ const uncalRunner = new ToolRunner({ ctx: uncalCtx, eventBus });
194
+ uncalRunner.register(makeTool("autocrew_content", { ok: true }));
195
+
196
+ const result = await uncalRunner.execute("autocrew_content", {});
197
+ expect(result.ok).toBe(false);
198
+ // styleCalibrated:false is detected as missing info by detectMissingInfo,
199
+ // so profile_incomplete fires before style_not_calibrated
200
+ expect(["profile_incomplete", "style_not_calibrated"]).toContain(result.error);
201
+
202
+ await fs.rm(uncalDir, { recursive: true, force: true });
203
+ });
204
+ });
@@ -0,0 +1,282 @@
1
+ /**
2
+ * ToolRunner — middleware pipeline for tool execution.
3
+ *
4
+ * Inspired by Claude Code's StreamingToolExecutor. Provides:
5
+ * - Middleware chain (dataDir injection, config, error boundary, audit)
6
+ * - Unified tool registration (eliminates copy-paste in index.ts)
7
+ * - Pre/Post hook integration points
8
+ */
9
+ import {
10
+ type ToolContext,
11
+ type AuditEntry,
12
+ updateWorkspace,
13
+ recordAudit,
14
+ resolveGeminiKey,
15
+ resolveGeminiModel,
16
+ resolveGatewayUrl,
17
+ } from "./context.js";
18
+ import { type EventBus, createEvent } from "./events.js";
19
+ import { loadProfile, detectMissingInfo } from "../modules/profile/creator-profile.js";
20
+
21
+ // --- Types ---
22
+
23
+ export type ToolResult = Record<string, unknown> & { ok?: boolean; error?: string };
24
+ export type ToolExecuteFn = (params: Record<string, unknown>) => Promise<ToolResult>;
25
+ export type Middleware = (
26
+ ctx: ToolContext,
27
+ toolName: string,
28
+ params: Record<string, unknown>,
29
+ next: () => Promise<ToolResult>,
30
+ ) => Promise<ToolResult>;
31
+
32
+ export interface ToolDefinition {
33
+ name: string;
34
+ label: string;
35
+ description: string;
36
+ parameters: unknown;
37
+ execute: ToolExecuteFn;
38
+ /** Tool actions that require Pro (e.g. ["generate_ratios"]) */
39
+ proActions?: string[];
40
+ /** If true, inject _geminiApiKey and _geminiModel into params */
41
+ needsGemini?: boolean;
42
+ /** If true, this tool is exempt from onboarding gate (e.g. init, pro_status) */
43
+ skipOnboardingGate?: boolean;
44
+ }
45
+
46
+ export interface ToolRunnerOptions {
47
+ ctx: ToolContext;
48
+ eventBus?: EventBus;
49
+ middleware?: Middleware[];
50
+ }
51
+
52
+ // --- Built-in Middleware ---
53
+
54
+ /** Tools exempt from onboarding gate */
55
+ const ONBOARDING_EXEMPT_TOOLS = new Set([
56
+ "autocrew_init",
57
+ "autocrew_pro_status",
58
+ "autocrew_status",
59
+ "autocrew_memory",
60
+ ]);
61
+
62
+ /** Block non-exempt tools if profile is incomplete or style not calibrated */
63
+ const onboardingGateMiddleware: Middleware = async (ctx, toolName, _params, next) => {
64
+ // Skip for exempt tools
65
+ if (ONBOARDING_EXEMPT_TOOLS.has(toolName)) return next();
66
+
67
+ try {
68
+ const profile = await loadProfile(ctx.dataDir);
69
+ if (!profile) {
70
+ return {
71
+ ok: false,
72
+ error: "onboarding_required",
73
+ message: "⚠️ 首次使用 AutoCrew,需要先完成初始设置。",
74
+ action_required: "请先调用 autocrew_init 初始化数据目录,然后通过对话收集用户的行业、平台、受众信息,保存到 creator-profile.json。完成后再调用 autocrew_pro_status 确认。",
75
+ steps: [
76
+ "1. 调用 autocrew_init 初始化",
77
+ "2. 询问用户:你的行业/领域是什么?",
78
+ "3. 询问用户:你主要在哪些平台发内容?(小红书/抖音/公众号/视频号)",
79
+ "4. 询问用户:你的目标受众是谁?",
80
+ "5. 通过风格校准确定写作风格(正式vs口语、专业vs大白话等)",
81
+ "6. 生成 STYLE.md 并更新 creator-profile.json",
82
+ "7. 完成后再执行用户的原始请求",
83
+ ],
84
+ };
85
+ }
86
+
87
+ const missing = detectMissingInfo(profile);
88
+ if (missing.length > 0) {
89
+ return {
90
+ ok: false,
91
+ error: "profile_incomplete",
92
+ message: `⚠️ 创作者档案不完整,缺少:${missing.join("、")}`,
93
+ action_required: `请通过对话补充以下信息:${missing.join("、")}。更新 creator-profile.json 后再继续。`,
94
+ missing,
95
+ };
96
+ }
97
+
98
+ if (!profile.styleCalibrated) {
99
+ return {
100
+ ok: false,
101
+ error: "style_not_calibrated",
102
+ message: "⚠️ 还没有完成风格校准。写出来的内容可能不符合你的品牌调性。",
103
+ action_required: "请先进行风格校准:通过 A/B 选择题确定写作风格偏好,生成 STYLE.md,然后更新 creator-profile.json 的 styleCalibrated 为 true。",
104
+ steps: [
105
+ "1. 询问用户的风格偏好(正式vs口语、专业vs大白话、长文vs短文、情感vs干货)",
106
+ "2. 根据回答生成 ~/.autocrew/STYLE.md",
107
+ "3. 更新 creator-profile.json: styleCalibrated = true",
108
+ "4. 完成后再执行用户的原始请求",
109
+ ],
110
+ };
111
+ }
112
+ } catch {
113
+ // If profile check fails, let the tool proceed (don't block on errors)
114
+ }
115
+
116
+ return next();
117
+ };
118
+
119
+ /** Inject _dataDir into every tool call */
120
+ const dataDirMiddleware: Middleware = async (ctx, _tool, params, next) => {
121
+ params._dataDir = ctx.dataDir;
122
+ params._gatewayUrl = resolveGatewayUrl(ctx);
123
+ return next();
124
+ };
125
+
126
+ /** Inject Gemini config for tools that need it */
127
+ const geminiConfigMiddleware: Middleware = async (ctx, _tool, params, next) => {
128
+ // Only inject if the tool definition says it needs Gemini
129
+ // The runner checks this before adding to the chain
130
+ params._geminiApiKey = resolveGeminiKey(ctx);
131
+ params._geminiModel = resolveGeminiModel(ctx);
132
+ return next();
133
+ };
134
+
135
+ /** Catch errors and return friendly error objects */
136
+ const errorBoundaryMiddleware: Middleware = async (_ctx, toolName, _params, next) => {
137
+ try {
138
+ return await next();
139
+ } catch (err: unknown) {
140
+ const message = err instanceof Error ? err.message : String(err);
141
+ return { ok: false, error: `[${toolName}] ${message}` };
142
+ }
143
+ };
144
+
145
+ /** Record audit trail */
146
+ const auditMiddleware: Middleware = async (ctx, toolName, params, next) => {
147
+ const start = Date.now();
148
+ let result: ToolResult;
149
+ try {
150
+ result = await next();
151
+ } catch (err) {
152
+ const entry: AuditEntry = {
153
+ tool: toolName,
154
+ action: params.action as string | undefined,
155
+ timestamp: new Date().toISOString(),
156
+ durationMs: Date.now() - start,
157
+ ok: false,
158
+ error: err instanceof Error ? err.message : String(err),
159
+ };
160
+ recordAudit(ctx, entry);
161
+ throw err;
162
+ }
163
+ const entry: AuditEntry = {
164
+ tool: toolName,
165
+ action: params.action as string | undefined,
166
+ timestamp: new Date().toISOString(),
167
+ durationMs: Date.now() - start,
168
+ ok: result.ok !== false,
169
+ error: result.error as string | undefined,
170
+ };
171
+ recordAudit(ctx, entry);
172
+ return result;
173
+ };
174
+
175
+ /** Update workspace state based on tool results */
176
+ const workspaceTrackingMiddleware: Middleware = async (ctx, toolName, params, next) => {
177
+ const result = await next();
178
+
179
+ // Track active content/topic IDs
180
+ if (result.ok !== false) {
181
+ if (toolName === "autocrew_content" && result.id) {
182
+ updateWorkspace(ctx, { activeContentId: result.id as string });
183
+ }
184
+ if (toolName === "autocrew_topic" && result.id) {
185
+ updateWorkspace(ctx, { activeTopicId: result.id as string });
186
+ }
187
+ updateWorkspace(ctx, { lastToolResult: result, lastToolName: toolName });
188
+ }
189
+
190
+ return result;
191
+ };
192
+
193
+ // --- ToolRunner ---
194
+
195
+ export class ToolRunner {
196
+ private ctx: ToolContext;
197
+ private eventBus?: EventBus;
198
+ private tools = new Map<string, ToolDefinition>();
199
+ private middleware: Middleware[];
200
+
201
+ constructor(options: ToolRunnerOptions) {
202
+ this.ctx = options.ctx;
203
+ this.eventBus = options.eventBus;
204
+ this.middleware = [
205
+ dataDirMiddleware,
206
+ onboardingGateMiddleware,
207
+ errorBoundaryMiddleware,
208
+ auditMiddleware,
209
+ workspaceTrackingMiddleware,
210
+ ...(options.middleware || []),
211
+ ];
212
+ }
213
+
214
+ /** Register a tool definition */
215
+ register(def: ToolDefinition): void {
216
+ this.tools.set(def.name, def);
217
+ }
218
+
219
+ /** Get all registered tool definitions (for OpenClaw/MCP registration) */
220
+ getTools(): ToolDefinition[] {
221
+ return Array.from(this.tools.values());
222
+ }
223
+
224
+ /** Get a single tool definition by name */
225
+ getTool(name: string): ToolDefinition | undefined {
226
+ return this.tools.get(name);
227
+ }
228
+
229
+ /** Execute a tool through the middleware pipeline */
230
+ async execute(toolName: string, params: Record<string, unknown>): Promise<ToolResult> {
231
+ const def = this.tools.get(toolName);
232
+ if (!def) {
233
+ return { ok: false, error: `Unknown tool: ${toolName}` };
234
+ }
235
+
236
+ // Build middleware chain for this specific tool
237
+ const chain = [...this.middleware];
238
+
239
+ // Inject Gemini config only for tools that need it
240
+ if (def.needsGemini) {
241
+ chain.splice(1, 0, geminiConfigMiddleware); // After dataDir, before errorBoundary
242
+ }
243
+
244
+ // Emit PreToolUse event
245
+ if (this.eventBus) {
246
+ this.eventBus.emit(createEvent("tool:pre_execute", { tool: toolName, action: params.action as string }));
247
+ }
248
+
249
+ // Execute through middleware chain
250
+ const result = await this.runChain(chain, toolName, { ...params }, def.execute);
251
+
252
+ // Emit PostToolUse event
253
+ if (this.eventBus) {
254
+ const eventType = result.ok !== false ? "tool:post_execute" : "tool:execute_failed";
255
+ this.eventBus.emit(createEvent(eventType, {
256
+ tool: toolName,
257
+ action: params.action as string,
258
+ ok: result.ok !== false,
259
+ contentId: (result.id || result.contentId || params.content_id) as string | undefined,
260
+ }));
261
+ }
262
+
263
+ return result;
264
+ }
265
+
266
+ private async runChain(
267
+ chain: Middleware[],
268
+ toolName: string,
269
+ params: Record<string, unknown>,
270
+ executeFn: ToolExecuteFn,
271
+ ): Promise<ToolResult> {
272
+ let index = 0;
273
+ const next = async (): Promise<ToolResult> => {
274
+ if (index < chain.length) {
275
+ const mw = chain[index++];
276
+ return mw(this.ctx, toolName, params, next);
277
+ }
278
+ return executeFn(params);
279
+ };
280
+ return next();
281
+ }
282
+ }