@bubblebrain-ai/bubble 0.0.10 → 0.0.12

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 (175) hide show
  1. package/dist/agent.d.ts +1 -0
  2. package/dist/agent.js +6 -2
  3. package/dist/cli.d.ts +10 -0
  4. package/dist/cli.js +31 -3
  5. package/dist/feedback/collect.d.ts +7 -0
  6. package/dist/feedback/collect.js +119 -0
  7. package/dist/feedback/config.d.ts +14 -0
  8. package/dist/feedback/config.js +16 -0
  9. package/dist/feedback/redact.d.ts +1 -0
  10. package/dist/feedback/redact.js +25 -0
  11. package/dist/feedback/submit.d.ts +6 -0
  12. package/dist/feedback/submit.js +43 -0
  13. package/dist/feedback/types.d.ts +22 -0
  14. package/dist/feishu/agent-host/approval-card.d.ts +11 -0
  15. package/dist/feishu/agent-host/approval-card.js +46 -0
  16. package/dist/feishu/agent-host/approval-ui.d.ts +59 -0
  17. package/dist/feishu/agent-host/approval-ui.js +214 -0
  18. package/dist/feishu/agent-host/run-driver.d.ts +51 -0
  19. package/dist/feishu/agent-host/run-driver.js +302 -0
  20. package/dist/feishu/agent-host/runtime-deps.d.ts +33 -0
  21. package/dist/feishu/agent-host/runtime-deps.js +8 -0
  22. package/dist/feishu/card/budget.d.ts +40 -0
  23. package/dist/feishu/card/budget.js +134 -0
  24. package/dist/feishu/card/renderer.d.ts +29 -0
  25. package/dist/feishu/card/renderer.js +245 -0
  26. package/dist/feishu/card/run-state-types.d.ts +49 -0
  27. package/dist/feishu/card/run-state-types.js +15 -0
  28. package/dist/feishu/card/run-state.d.ts +21 -0
  29. package/dist/feishu/card/run-state.js +217 -0
  30. package/dist/feishu/channel/channel.d.ts +52 -0
  31. package/dist/feishu/channel/channel.js +74 -0
  32. package/dist/feishu/config.d.ts +24 -0
  33. package/dist/feishu/config.js +97 -0
  34. package/dist/feishu/format.d.ts +6 -0
  35. package/dist/feishu/format.js +14 -0
  36. package/dist/feishu/index.d.ts +4 -0
  37. package/dist/feishu/index.js +4 -0
  38. package/dist/feishu/logger.d.ts +31 -0
  39. package/dist/feishu/logger.js +62 -0
  40. package/dist/feishu/paths.d.ts +12 -0
  41. package/dist/feishu/paths.js +38 -0
  42. package/dist/feishu/process-registry.d.ts +29 -0
  43. package/dist/feishu/process-registry.js +90 -0
  44. package/dist/feishu/router/commands.d.ts +38 -0
  45. package/dist/feishu/router/commands.js +286 -0
  46. package/dist/feishu/router/event-router.d.ts +40 -0
  47. package/dist/feishu/router/event-router.js +208 -0
  48. package/dist/feishu/router/whitelist.d.ts +23 -0
  49. package/dist/feishu/router/whitelist.js +20 -0
  50. package/dist/feishu/runtime/active-runs.d.ts +32 -0
  51. package/dist/feishu/runtime/active-runs.js +84 -0
  52. package/dist/feishu/runtime/pending-queue.d.ts +36 -0
  53. package/dist/feishu/runtime/pending-queue.js +98 -0
  54. package/dist/feishu/runtime/process-pool.d.ts +29 -0
  55. package/dist/feishu/runtime/process-pool.js +49 -0
  56. package/dist/feishu/schema.d.ts +17 -0
  57. package/dist/feishu/schema.js +252 -0
  58. package/dist/feishu/scope/scope-registry.d.ts +39 -0
  59. package/dist/feishu/scope/scope-registry.js +148 -0
  60. package/dist/feishu/scope/session-binder.d.ts +44 -0
  61. package/dist/feishu/scope/session-binder.js +100 -0
  62. package/dist/feishu/scope/session-store.d.ts +24 -0
  63. package/dist/feishu/scope/session-store.js +73 -0
  64. package/dist/feishu/secrets.d.ts +37 -0
  65. package/dist/feishu/secrets.js +129 -0
  66. package/dist/feishu/serve.d.ts +12 -0
  67. package/dist/feishu/serve.js +288 -0
  68. package/dist/feishu/types.d.ts +75 -0
  69. package/dist/feishu/types.js +23 -0
  70. package/dist/feishu/wizard.d.ts +24 -0
  71. package/dist/feishu/wizard.js +121 -0
  72. package/dist/main.js +98 -32
  73. package/dist/model-catalog.js +3 -0
  74. package/dist/prompt/compose.js +3 -3
  75. package/dist/prompt/environment.js +2 -0
  76. package/dist/prompt/reminders.js +1 -1
  77. package/dist/provider-openai-codex.d.ts +8 -1
  78. package/dist/provider-openai-codex.js +33 -9
  79. package/dist/provider.d.ts +2 -0
  80. package/dist/session-title.d.ts +16 -0
  81. package/dist/session-title.js +134 -0
  82. package/dist/session-types.d.ts +5 -0
  83. package/dist/session.d.ts +16 -0
  84. package/dist/session.js +154 -2
  85. package/dist/skills/invocation.js +0 -18
  86. package/dist/skills/registry.d.ts +1 -0
  87. package/dist/skills/registry.js +2 -0
  88. package/dist/slash-commands/commands.js +15 -22
  89. package/dist/slash-commands/feishu.d.ts +17 -0
  90. package/dist/slash-commands/feishu.js +400 -0
  91. package/dist/slash-commands/registry.js +1 -1
  92. package/dist/slash-commands/types.d.ts +3 -1
  93. package/dist/text-display.d.ts +3 -0
  94. package/dist/text-display.js +25 -0
  95. package/dist/tools/index.d.ts +1 -0
  96. package/dist/tools/index.js +3 -1
  97. package/dist/tools/skill-search.d.ts +10 -0
  98. package/dist/tools/skill-search.js +134 -0
  99. package/dist/tools/skill.js +1 -4
  100. package/dist/tui-ink/app.js +265 -118
  101. package/dist/tui-ink/code-highlight.js +2 -3
  102. package/dist/tui-ink/detect-theme.d.ts +1 -18
  103. package/dist/tui-ink/detect-theme.js +1 -37
  104. package/dist/tui-ink/display-history.d.ts +20 -3
  105. package/dist/tui-ink/display-history.js +26 -27
  106. package/dist/tui-ink/feedback-dialog.d.ts +19 -0
  107. package/dist/tui-ink/feedback-dialog.js +123 -0
  108. package/dist/tui-ink/feishu-setup-picker.d.ts +5 -0
  109. package/dist/tui-ink/feishu-setup-picker.js +261 -0
  110. package/dist/tui-ink/input-box.d.ts +25 -1
  111. package/dist/tui-ink/input-box.js +132 -11
  112. package/dist/tui-ink/input-history.js +3 -5
  113. package/dist/tui-ink/markdown.d.ts +32 -0
  114. package/dist/tui-ink/markdown.js +111 -4
  115. package/dist/tui-ink/message-list.d.ts +1 -6
  116. package/dist/tui-ink/message-list.js +86 -34
  117. package/dist/tui-ink/model-picker.d.ts +18 -0
  118. package/dist/tui-ink/model-picker.js +81 -27
  119. package/dist/tui-ink/run-session-picker.d.ts +10 -0
  120. package/dist/tui-ink/run-session-picker.js +22 -0
  121. package/dist/tui-ink/run.js +7 -2
  122. package/dist/tui-ink/session-picker.d.ts +10 -0
  123. package/dist/tui-ink/session-picker.js +110 -0
  124. package/dist/tui-ink/terminal-mouse.d.ts +4 -0
  125. package/dist/tui-ink/terminal-mouse.js +23 -0
  126. package/dist/tui-ink/theme.js +2 -2
  127. package/dist/tui-ink/trace-groups.js +25 -2
  128. package/dist/tui-ink/welcome.js +2 -4
  129. package/package.json +4 -5
  130. package/dist/tui/clipboard.d.ts +0 -1
  131. package/dist/tui/clipboard.js +0 -53
  132. package/dist/tui/display-history.d.ts +0 -44
  133. package/dist/tui/display-history.js +0 -243
  134. package/dist/tui/escape-confirmation.d.ts +0 -15
  135. package/dist/tui/escape-confirmation.js +0 -30
  136. package/dist/tui/file-mentions.d.ts +0 -29
  137. package/dist/tui/file-mentions.js +0 -174
  138. package/dist/tui/global-key-router.d.ts +0 -3
  139. package/dist/tui/global-key-router.js +0 -87
  140. package/dist/tui/image-paste.d.ts +0 -95
  141. package/dist/tui/image-paste.js +0 -505
  142. package/dist/tui/markdown-inline.d.ts +0 -22
  143. package/dist/tui/markdown-inline.js +0 -68
  144. package/dist/tui/markdown-theme-rules.d.ts +0 -23
  145. package/dist/tui/markdown-theme-rules.js +0 -164
  146. package/dist/tui/markdown-theme.d.ts +0 -5
  147. package/dist/tui/markdown-theme.js +0 -27
  148. package/dist/tui/opencode-spinner.d.ts +0 -21
  149. package/dist/tui/opencode-spinner.js +0 -216
  150. package/dist/tui/prompt-keybindings.d.ts +0 -42
  151. package/dist/tui/prompt-keybindings.js +0 -35
  152. package/dist/tui/recent-activity.d.ts +0 -8
  153. package/dist/tui/recent-activity.js +0 -71
  154. package/dist/tui/render-signature.d.ts +0 -1
  155. package/dist/tui/render-signature.js +0 -7
  156. package/dist/tui/run.d.ts +0 -38
  157. package/dist/tui/run.js +0 -6996
  158. package/dist/tui/sidebar-mcp.d.ts +0 -31
  159. package/dist/tui/sidebar-mcp.js +0 -62
  160. package/dist/tui/sidebar-state.d.ts +0 -12
  161. package/dist/tui/sidebar-state.js +0 -69
  162. package/dist/tui/streaming-tool-args.d.ts +0 -15
  163. package/dist/tui/streaming-tool-args.js +0 -30
  164. package/dist/tui/tool-renderers/fallback.d.ts +0 -2
  165. package/dist/tui/tool-renderers/fallback.js +0 -75
  166. package/dist/tui/tool-renderers/registry.d.ts +0 -3
  167. package/dist/tui/tool-renderers/registry.js +0 -11
  168. package/dist/tui/tool-renderers/subagent.d.ts +0 -2
  169. package/dist/tui/tool-renderers/subagent.js +0 -114
  170. package/dist/tui/tool-renderers/types.d.ts +0 -36
  171. package/dist/tui/tool-renderers/write-preview.d.ts +0 -12
  172. package/dist/tui/tool-renderers/write-preview.js +0 -30
  173. package/dist/tui/tool-renderers/write.d.ts +0 -6
  174. package/dist/tui/tool-renderers/write.js +0 -88
  175. /package/dist/{tui/tool-renderers → feedback}/types.js +0 -0
@@ -0,0 +1,400 @@
1
+ /**
2
+ * `/feishu` slash command — control the Feishu remote-access serve process
3
+ * from inside the Bubble TUI without leaving it.
4
+ *
5
+ * Subcommands:
6
+ * /feishu equivalent to `/feishu status`
7
+ * /feishu status show running state and configured scopes
8
+ * /feishu start spawn `bubble serve --feishu` detached
9
+ * /feishu stop SIGTERM the running serve instance
10
+ * /feishu logs [N] tail last N lines of today's log (default 30)
11
+ *
12
+ * The serve subprocess runs independently of the TUI — closing the TUI
13
+ * does not stop it. Use `/feishu stop` (or kill the PID directly) to
14
+ * terminate.
15
+ */
16
+ import { spawn } from "node:child_process";
17
+ import { existsSync, openSync, readFileSync, statSync } from "node:fs";
18
+ import { join } from "node:path";
19
+ import { configExists, loadConfig } from "../feishu/config.js";
20
+ import { getConfigPath, getLogsDir } from "../feishu/paths.js";
21
+ import { ProcessRegistry } from "../feishu/process-registry.js";
22
+ import { ScopeRegistry } from "../feishu/scope/scope-registry.js";
23
+ const SUBCOMMANDS = ["status", "setup", "start", "stop", "logs", "discover", "bind"];
24
+ export const feishuCommand = {
25
+ name: "feishu",
26
+ description: "Control the Feishu remote-access service (setup/status/start/stop/logs/discover/bind)",
27
+ async handler(args, ctx) {
28
+ const [sub, ...rest] = args.trim().split(/\s+/).filter(Boolean);
29
+ const cmd = (sub ?? "status").toLowerCase();
30
+ if (!SUBCOMMANDS.includes(cmd)) {
31
+ return `Unknown subcommand \`${cmd}\`. Usage: /feishu [setup|status|start|stop|logs|discover|bind]`;
32
+ }
33
+ switch (cmd) {
34
+ case "setup":
35
+ return runSetup(ctx);
36
+ case "status":
37
+ return runStatus();
38
+ case "start":
39
+ return runStart();
40
+ case "stop":
41
+ return runStop();
42
+ case "logs":
43
+ return runLogs(parseInt(rest[0] ?? "30", 10));
44
+ case "discover":
45
+ return runDiscover();
46
+ case "bind":
47
+ return runBind(rest);
48
+ default:
49
+ return "";
50
+ }
51
+ },
52
+ };
53
+ function runSetup(ctx) {
54
+ if (!ctx.openPicker) {
55
+ return "Setup wizard is only available in interactive TUI mode. Run `bubble serve --feishu --setup` from a shell instead.";
56
+ }
57
+ if (configExists()) {
58
+ return [
59
+ "已检测到现有 config (`~/.bubble/feishu/config.json`)。重新 setup 会覆盖现有的应用注册。",
60
+ "",
61
+ "如果只是想加新的 chat scope,编辑 `~/.bubble/feishu/scopes.json` 即可;要重置则先 `rm ~/.bubble/feishu/config.json ~/.bubble/feishu/secrets.enc` 再运行 `/feishu setup`。",
62
+ ].join("\n");
63
+ }
64
+ ctx.openPicker("feishu-setup");
65
+ }
66
+ function runStatus() {
67
+ if (!configExists()) {
68
+ return [
69
+ "Feishu serve is **not configured**.",
70
+ "",
71
+ "Run from a shell: `bubble serve --feishu --setup` to scan the QR code and create config.",
72
+ `Config path: \`${getConfigPath()}\``,
73
+ ].join("\n");
74
+ }
75
+ let config;
76
+ try {
77
+ config = loadConfig();
78
+ }
79
+ catch (err) {
80
+ return `Failed to read config: ${err.message}`;
81
+ }
82
+ const procRegistry = new ProcessRegistry();
83
+ const conflicts = procRegistry.findConflicts(config.app.appId);
84
+ const running = conflicts.length > 0;
85
+ let scopesCount = 0;
86
+ try {
87
+ scopesCount = ScopeRegistry.load().list().length;
88
+ }
89
+ catch {
90
+ // ignore
91
+ }
92
+ const lines = [];
93
+ lines.push(`**Feishu serve status**`);
94
+ lines.push(`- app: \`${config.app.appId}\``);
95
+ lines.push(`- owner: \`${config.app.ownerOpenId}\``);
96
+ lines.push(`- scopes configured: ${scopesCount}`);
97
+ if (running) {
98
+ const pids = conflicts.map((c) => c.entry.pid).join(", ");
99
+ lines.push(`- 🟢 running (pid ${pids})`);
100
+ lines.push(` use \`/feishu stop\` to terminate, \`/feishu logs\` to tail logs`);
101
+ }
102
+ else {
103
+ lines.push(`- ⚪ not running`);
104
+ lines.push(` use \`/feishu start\` to launch`);
105
+ }
106
+ return lines.join("\n");
107
+ }
108
+ function runStart() {
109
+ if (!configExists()) {
110
+ return [
111
+ "Cannot start: no Feishu config found.",
112
+ "",
113
+ "Run from a shell first: `bubble serve --feishu --setup`",
114
+ "(the wizard needs an interactive terminal to scan the QR code).",
115
+ ].join("\n");
116
+ }
117
+ let config;
118
+ try {
119
+ config = loadConfig();
120
+ }
121
+ catch (err) {
122
+ return `Failed to read config: ${err.message}`;
123
+ }
124
+ // Bail if already running for this appId.
125
+ const procRegistry = new ProcessRegistry();
126
+ procRegistry.gc();
127
+ if (procRegistry.findConflicts(config.app.appId).length > 0) {
128
+ return "Feishu serve is already running. Use `/feishu status` for details.";
129
+ }
130
+ // Spawn detached subprocess. Redirect stdout/stderr to a log file so we
131
+ // don't fight the TUI for the terminal. Use process.execPath + argv[1] so
132
+ // the subprocess inherits whatever way the user launched the TUI (npm bin,
133
+ // node dist/main.js, bun dist/main.js, etc.).
134
+ const scriptPath = process.argv[1];
135
+ if (!scriptPath) {
136
+ return "Cannot determine bubble script path; please launch from shell instead.";
137
+ }
138
+ const stdoutLog = join(getLogsDir(), "serve-stdout.log");
139
+ const stderrLog = join(getLogsDir(), "serve-stderr.log");
140
+ let outFd;
141
+ let errFd;
142
+ try {
143
+ outFd = openSync(stdoutLog, "a");
144
+ errFd = openSync(stderrLog, "a");
145
+ }
146
+ catch (err) {
147
+ return `Failed to open log files: ${err.message}`;
148
+ }
149
+ let child;
150
+ try {
151
+ child = spawn(process.execPath, [scriptPath, "serve", "--feishu"], {
152
+ detached: true,
153
+ stdio: ["ignore", outFd, errFd],
154
+ env: { ...process.env },
155
+ });
156
+ }
157
+ catch (err) {
158
+ return `Failed to spawn: ${err.message}`;
159
+ }
160
+ // unref so the parent (TUI) can exit without waiting for the child.
161
+ child.unref();
162
+ // If spawn failed asynchronously, we won't catch it here — the user will
163
+ // see emptiness in /feishu status, which then prompts them to check logs.
164
+ return [
165
+ `🚀 Started Feishu serve (pid ${child.pid ?? "?"}).`,
166
+ ` stdout: \`${stdoutLog}\``,
167
+ ` stderr: \`${stderrLog}\``,
168
+ "",
169
+ "Use `/feishu status` to verify, `/feishu logs` to tail.",
170
+ ].join("\n");
171
+ }
172
+ function runStop() {
173
+ if (!configExists())
174
+ return "No config — nothing to stop.";
175
+ let config;
176
+ try {
177
+ config = loadConfig();
178
+ }
179
+ catch (err) {
180
+ return `Failed to read config: ${err.message}`;
181
+ }
182
+ const procRegistry = new ProcessRegistry();
183
+ const conflicts = procRegistry.findConflicts(config.app.appId);
184
+ if (conflicts.length === 0) {
185
+ return "Feishu serve is not running.";
186
+ }
187
+ const killed = procRegistry.killConflicts(config.app.appId);
188
+ if (killed === 0) {
189
+ return "Found stale registry entries but no live processes — cleaned up.";
190
+ }
191
+ return `⏹ Sent SIGTERM to ${killed} process(es). Use \`/feishu status\` to confirm shutdown.`;
192
+ }
193
+ function runDiscover() {
194
+ if (!configExists()) {
195
+ return "未配置。先跑 `/feishu setup`。";
196
+ }
197
+ let config;
198
+ try {
199
+ config = loadConfig();
200
+ }
201
+ catch (err) {
202
+ return `Failed to read config: ${err.message}`;
203
+ }
204
+ // Scan today's + yesterday's log for `scope_not_found` (or user_not_allowed)
205
+ // events. Most recent first.
206
+ const events = collectGateEvents();
207
+ if (events.length === 0) {
208
+ return [
209
+ "没找到未授权的 chat 记录。",
210
+ "",
211
+ "确保:",
212
+ "1. `/feishu start` 已经把服务跑起来了(`/feishu status` 看一下)",
213
+ "2. 你已经在手机飞书里给 bot 发过至少一条消息",
214
+ "3. 如果之前测试过,可能已经被 `requireMentionInGroup` 过滤;用私聊试试",
215
+ ].join("\n");
216
+ }
217
+ const known = new Set(loadKnownChats());
218
+ const unknown = events.filter((e) => !known.has(e.chatId));
219
+ if (unknown.length === 0) {
220
+ return [
221
+ "找到的 chat 都已经配置过了:",
222
+ ...events.slice(0, 5).map((e) => `- ${e.chatId} (user ${e.userId}, ${e.reason})`),
223
+ ].join("\n");
224
+ }
225
+ const lines = [
226
+ `发现 ${unknown.length} 个未授权的 chat:`,
227
+ "",
228
+ ];
229
+ // Group by chatId, keep latest senderId per chat.
230
+ const byChat = new Map();
231
+ for (const e of unknown) {
232
+ if (!byChat.has(e.chatId))
233
+ byChat.set(e.chatId, e);
234
+ }
235
+ let idx = 1;
236
+ for (const [chatId, info] of byChat.entries()) {
237
+ lines.push(`${idx}. \`${chatId}\``);
238
+ lines.push(` sender: \`${info.userId}\` · ${info.reason} · ${info.ts}`);
239
+ lines.push(` → \`/feishu bind ${chatId} <你的项目路径>\``);
240
+ lines.push("");
241
+ idx++;
242
+ }
243
+ lines.push(`(owner open_id = \`${config.app.ownerOpenId}\`,会自动加入新 scope 的 allowedUsers)`);
244
+ return lines.join("\n");
245
+ }
246
+ function collectGateEvents() {
247
+ const today = new Date();
248
+ const yesterday = new Date(today.getTime() - 24 * 60 * 60 * 1000);
249
+ const dates = [iso(today), iso(yesterday)];
250
+ const all = [];
251
+ for (const d of dates) {
252
+ const path = join(getLogsDir(), `${d}.log`);
253
+ if (!existsSync(path))
254
+ continue;
255
+ let raw;
256
+ try {
257
+ raw = readFileSync(path, "utf8");
258
+ }
259
+ catch {
260
+ continue;
261
+ }
262
+ for (const line of raw.split("\n")) {
263
+ if (!line.trim())
264
+ continue;
265
+ try {
266
+ const obj = JSON.parse(line);
267
+ if (obj.msg !== "gate_rejected")
268
+ continue;
269
+ all.push({
270
+ chatId: String(obj.chatId ?? ""),
271
+ userId: String(obj.userId ?? ""),
272
+ reason: String(obj.reason ?? ""),
273
+ ts: String(obj.ts ?? "").slice(11, 19),
274
+ });
275
+ }
276
+ catch { /* skip */ }
277
+ }
278
+ }
279
+ return all.reverse(); // newest first
280
+ }
281
+ function iso(d) {
282
+ return d.toISOString().slice(0, 10);
283
+ }
284
+ function loadKnownChats() {
285
+ try {
286
+ return ScopeRegistry.load().list().map((s) => s.chatId);
287
+ }
288
+ catch {
289
+ return [];
290
+ }
291
+ }
292
+ function runBind(args) {
293
+ if (!configExists()) {
294
+ return "未配置。先跑 `/feishu setup`。";
295
+ }
296
+ let config;
297
+ try {
298
+ config = loadConfig();
299
+ }
300
+ catch (err) {
301
+ return `Failed to read config: ${err.message}`;
302
+ }
303
+ if (args.length < 2) {
304
+ return [
305
+ "用法:`/feishu bind <chat_id> <cwd> [display_name]`",
306
+ "",
307
+ "示例:`/feishu bind oc_abc123 ~/projects/my-app my-app`",
308
+ "",
309
+ "用 `/feishu discover` 找到未授权的 chat_id。",
310
+ ].join("\n");
311
+ }
312
+ const chatId = args[0].trim();
313
+ const cwdRaw = args[1].trim();
314
+ const displayName = args.slice(2).join(" ").trim();
315
+ // Expand ~
316
+ const cwd = (cwdRaw === "~" || cwdRaw.startsWith("~/"))
317
+ ? (process.env.HOME ?? "") + cwdRaw.slice(1)
318
+ : cwdRaw;
319
+ // Validate
320
+ let stat;
321
+ try {
322
+ stat = existsSync(cwd) ? statSync(cwd) : undefined;
323
+ }
324
+ catch {
325
+ stat = undefined;
326
+ }
327
+ if (!stat || !stat.isDirectory()) {
328
+ return `❌ cwd 无效(不存在或不是目录):\`${cwd}\``;
329
+ }
330
+ if (!chatId.startsWith("oc_")) {
331
+ // Not strictly required, but worth flagging.
332
+ return `⚠️ chat_id 看起来不像飞书的(一般以 \`oc_\` 开头)。确认无误后强制添加请直接编辑 \`~/.bubble/feishu/scopes.json\`。收到:\`${chatId}\``;
333
+ }
334
+ const registry = ScopeRegistry.load();
335
+ if (registry.has(chatId)) {
336
+ return `⚠️ scope \`${chatId}\` 已存在。如果想改 cwd,先 \`rm ~/.bubble/feishu/scopes.json\` 里手动调,或者用 \`/cd\` 在飞书会话里切换。`;
337
+ }
338
+ const finalName = displayName || basenameOf(cwd);
339
+ registry.upsert(chatId, {
340
+ cwd,
341
+ displayName: finalName,
342
+ allowedUsers: [config.app.ownerOpenId],
343
+ admins: [config.app.ownerOpenId],
344
+ defaultPermissionMode: "default",
345
+ model: null,
346
+ createdAt: Date.now(),
347
+ lastActiveAt: Date.now(),
348
+ });
349
+ return [
350
+ `✅ 已绑定 scope:`,
351
+ ` chat: \`${chatId}\``,
352
+ ` cwd: \`${cwd}\``,
353
+ ` name: \`${finalName}\``,
354
+ ` allowedUsers: [\`${config.app.ownerOpenId}\`]`,
355
+ "",
356
+ "现在去飞书重新发条消息,应该能看到卡片回复了。",
357
+ ].join("\n");
358
+ }
359
+ function basenameOf(p) {
360
+ const parts = p.split(/[\\/]/);
361
+ return parts[parts.length - 1] || p;
362
+ }
363
+ function runLogs(n) {
364
+ const tailN = Number.isFinite(n) && n > 0 ? Math.min(n, 500) : 30;
365
+ const dateKey = new Date().toISOString().slice(0, 10);
366
+ const path = join(getLogsDir(), `${dateKey}.log`);
367
+ if (!existsSync(path)) {
368
+ // Fall back to the most recent log file in the dir.
369
+ return `No log file for today yet (\`${path}\`). Start the service first or check \`${getLogsDir()}\`.`;
370
+ }
371
+ let raw;
372
+ try {
373
+ raw = readFileSync(path, "utf8");
374
+ }
375
+ catch (err) {
376
+ return `Failed to read log: ${err.message}`;
377
+ }
378
+ const lines = raw.split("\n").filter((l) => l.trim() !== "");
379
+ const slice = lines.slice(-tailN);
380
+ if (slice.length === 0)
381
+ return `(log is empty: \`${path}\`)`;
382
+ // Format JSON lines compactly for readability in chat.
383
+ const formatted = slice.map((line) => {
384
+ try {
385
+ const obj = JSON.parse(line);
386
+ const ts = String(obj.ts ?? "").slice(11, 19);
387
+ const lvl = String(obj.level ?? "?").padEnd(5);
388
+ const msg = String(obj.msg ?? "");
389
+ const extra = Object.entries(obj)
390
+ .filter(([k]) => !["ts", "level", "msg"].includes(k))
391
+ .map(([k, v]) => `${k}=${typeof v === "string" ? v : JSON.stringify(v)}`)
392
+ .join(" ");
393
+ return `${ts} ${lvl} ${msg}${extra ? " " + extra : ""}`;
394
+ }
395
+ catch {
396
+ return line;
397
+ }
398
+ });
399
+ return ["```", ...formatted, "```", `(${slice.length} lines from \`${path}\`)`].join("\n");
400
+ }
@@ -48,7 +48,7 @@ export class SlashCommandRegistry {
48
48
  if (skill) {
49
49
  return {
50
50
  handled: true,
51
- result: `Skill "${skill.meta.name}": ${skill.meta.description}\nUse /${skill.meta.name} <your request> to run with this skill, or /skill ${skill.meta.name} to inspect it.`,
51
+ result: `Skill "${skill.meta.name}": ${skill.meta.description}\nUse /${skill.meta.name} <your request> to run with this skill, or /skills to choose from the picker.`,
52
52
  };
53
53
  }
54
54
  return {
@@ -17,7 +17,7 @@ export interface SlashCommandContext {
17
17
  exit: () => void;
18
18
  sessionManager?: SessionManager;
19
19
  createProvider: (providerId: string, apiKey: string, baseURL: string) => Provider;
20
- openPicker: (mode: "model" | "key" | "provider" | "provider-add" | "login" | "logout" | "skill", providerId?: string) => void;
20
+ openPicker: (mode: "model" | "key" | "provider" | "provider-add" | "login" | "logout" | "skill" | "feishu-setup", providerId?: string) => void;
21
21
  registry: ProviderRegistry;
22
22
  skillRegistry: SkillRegistry;
23
23
  bashAllowlist?: BashAllowlist;
@@ -34,6 +34,8 @@ export interface SlashCommandContext {
34
34
  getResolvedTheme?: () => "light" | "dark";
35
35
  /** Persist a new theme mode AND apply it to the running TUI. */
36
36
  setThemeMode?: (mode: ThemeMode) => void;
37
+ /** Open the feedback dialog. `initialDescription` prefills the description field. */
38
+ openFeedback?: (initialDescription: string) => void;
37
39
  }
38
40
  /**
39
41
  * Return types for a slash command handler:
@@ -0,0 +1,3 @@
1
+ export declare function normalizeSingleLine(text: string): string;
2
+ export declare function truncateVisual(text: string, maxWidth: number): string;
3
+ export declare function padVisual(text: string, width: number): string;
@@ -0,0 +1,25 @@
1
+ import stringWidth from "string-width";
2
+ export function normalizeSingleLine(text) {
3
+ return text.replace(/\s+/g, " ").trim();
4
+ }
5
+ export function truncateVisual(text, maxWidth) {
6
+ if (maxWidth <= 0)
7
+ return "";
8
+ if (stringWidth(text) <= maxWidth)
9
+ return text;
10
+ if (maxWidth === 1)
11
+ return "…";
12
+ let out = "";
13
+ let width = 0;
14
+ for (const ch of text) {
15
+ const chWidth = stringWidth(ch);
16
+ if (width + chWidth > maxWidth - 1)
17
+ break;
18
+ out += ch;
19
+ width += chWidth;
20
+ }
21
+ return `${out}…`;
22
+ }
23
+ export function padVisual(text, width) {
24
+ return `${text}${" ".repeat(Math.max(0, width - stringWidth(text)))}`;
25
+ }
@@ -11,6 +11,7 @@ export { createLspTool } from "./lsp.js";
11
11
  export { createWebFetchTool } from "./web-fetch.js";
12
12
  export { createWebSearchTool } from "./web-search.js";
13
13
  export { createSkillTool } from "./skill.js";
14
+ export { createSkillSearchTool } from "./skill-search.js";
14
15
  export { createAgentLifecycleTools, createCloseAgentTool, createSendInputTool, createSpawnAgentTool, createWaitAgentTool } from "./agent-lifecycle.js";
15
16
  export { createTodoTool, type TodoStore } from "./todo.js";
16
17
  export { createExitPlanModeTool, type PlanController } from "./exit-plan-mode.js";
@@ -11,6 +11,7 @@ export { createLspTool } from "./lsp.js";
11
11
  export { createWebFetchTool } from "./web-fetch.js";
12
12
  export { createWebSearchTool } from "./web-search.js";
13
13
  export { createSkillTool } from "./skill.js";
14
+ export { createSkillSearchTool } from "./skill-search.js";
14
15
  export { createAgentLifecycleTools, createCloseAgentTool, createSendInputTool, createSpawnAgentTool, createWaitAgentTool } from "./agent-lifecycle.js";
15
16
  export { createTodoTool } from "./todo.js";
16
17
  export { createExitPlanModeTool } from "./exit-plan-mode.js";
@@ -26,6 +27,7 @@ import { getLspService } from "../lsp/index.js";
26
27
  import { createLspTool } from "./lsp.js";
27
28
  import { createReadTool } from "./read.js";
28
29
  import { createSkillTool } from "./skill.js";
30
+ import { createSkillSearchTool } from "./skill-search.js";
29
31
  import { createAgentLifecycleTools } from "./agent-lifecycle.js";
30
32
  import { createTodoTool } from "./todo.js";
31
33
  import { createToolSearchTool } from "./tool-search.js";
@@ -53,7 +55,7 @@ export function createAllTools(cwd, skillRegistry, options = {}) {
53
55
  createMemoryReadSummaryTool(cwd),
54
56
  ...createAgentLifecycleTools(),
55
57
  ...(options.questionController ? [createQuestionTool(options.questionController)] : []),
56
- ...(skillRegistry ? [createSkillTool(skillRegistry)] : []),
58
+ ...(skillRegistry ? [createSkillSearchTool(skillRegistry), createSkillTool(skillRegistry)] : []),
57
59
  ...(options.todoStore ? [createTodoTool(options.todoStore)] : []),
58
60
  ...(options.planController ? [createExitPlanModeTool(options.planController)] : []),
59
61
  ...(options.toolSearchController ? [createToolSearchTool(options.toolSearchController)] : []),
@@ -0,0 +1,10 @@
1
+ import type { SkillRegistry } from "../skills/registry.js";
2
+ import type { SkillSummary } from "../skills/types.js";
3
+ import type { ToolRegistryEntry } from "../types.js";
4
+ interface SkillSearchMatch {
5
+ skill: SkillSummary;
6
+ score: number;
7
+ }
8
+ export declare function createSkillSearchTool(registry: SkillRegistry): ToolRegistryEntry;
9
+ export declare function searchSkillSummaries(skills: SkillSummary[], query: string): SkillSearchMatch[];
10
+ export {};
@@ -0,0 +1,134 @@
1
+ const DEFAULT_MAX_RESULTS = 8;
2
+ const MAX_RESULTS = 25;
3
+ const SOURCE_PRIORITY = {
4
+ project: 0,
5
+ configured: 1,
6
+ user: 2,
7
+ };
8
+ export function createSkillSearchTool(registry) {
9
+ return {
10
+ name: "skill_search",
11
+ readOnly: true,
12
+ effect: "read",
13
+ description: "Search available skills by name, description, tags, and source. Use this before loading a skill when a task may match a specialized workflow.",
14
+ parameters: {
15
+ type: "object",
16
+ properties: {
17
+ query: {
18
+ type: "string",
19
+ description: "Search terms describing the desired skill or workflow.",
20
+ },
21
+ max_results: {
22
+ type: "number",
23
+ description: `Maximum number of matches to return (default ${DEFAULT_MAX_RESULTS}, max ${MAX_RESULTS}).`,
24
+ },
25
+ },
26
+ required: ["query"],
27
+ additionalProperties: false,
28
+ },
29
+ async execute(args) {
30
+ const query = typeof args.query === "string" ? args.query.trim() : "";
31
+ const maxResults = typeof args.max_results === "number" && args.max_results > 0
32
+ ? Math.min(Math.floor(args.max_results), MAX_RESULTS)
33
+ : DEFAULT_MAX_RESULTS;
34
+ const skills = registry.summaries();
35
+ if (skills.length === 0) {
36
+ return { content: "No skills are currently available." };
37
+ }
38
+ const matches = searchSkillSummaries(skills, query).slice(0, maxResults);
39
+ if (matches.length === 0) {
40
+ return {
41
+ content: `No skills matched "${query}". Try broader terms or use /skills to browse all skills manually.`,
42
+ };
43
+ }
44
+ return {
45
+ content: formatSkillSearchResults(matches, skills.length, query),
46
+ };
47
+ },
48
+ };
49
+ }
50
+ export function searchSkillSummaries(skills, query) {
51
+ const terms = normalizeTerms(query);
52
+ const scored = [];
53
+ for (const skill of skills) {
54
+ const score = scoreSkill(skill, terms, query);
55
+ if (score > 0 || terms.length === 0) {
56
+ scored.push({ skill, score });
57
+ }
58
+ }
59
+ scored.sort((a, b) => {
60
+ if (b.score !== a.score)
61
+ return b.score - a.score;
62
+ const ap = SOURCE_PRIORITY[a.skill.source ?? "user"] ?? 3;
63
+ const bp = SOURCE_PRIORITY[b.skill.source ?? "user"] ?? 3;
64
+ if (ap !== bp)
65
+ return ap - bp;
66
+ return a.skill.name.localeCompare(b.skill.name);
67
+ });
68
+ return scored;
69
+ }
70
+ function scoreSkill(skill, terms, rawQuery) {
71
+ const name = skill.name.toLowerCase();
72
+ const desc = (skill.description ?? "").toLowerCase();
73
+ const tags = (skill.tags ?? []).map((tag) => tag.toLowerCase());
74
+ const source = skill.source ?? "user";
75
+ const sourceBonus = source === "project" ? 4 : source === "configured" ? 2 : 0;
76
+ const query = rawQuery.trim().toLowerCase();
77
+ if (terms.length === 0)
78
+ return 1 + sourceBonus;
79
+ let score = 0;
80
+ if (name === query)
81
+ score += 80;
82
+ if (name.includes(query) && query.length > 0)
83
+ score += 30;
84
+ for (const term of terms) {
85
+ if (name === term)
86
+ score += 30;
87
+ else if (name.includes(term))
88
+ score += 12;
89
+ if (tags.some((tag) => tag === term))
90
+ score += 10;
91
+ else if (tags.some((tag) => tag.includes(term)))
92
+ score += 6;
93
+ if (desc.includes(term))
94
+ score += 3;
95
+ if (source.includes(term))
96
+ score += 2;
97
+ }
98
+ return score > 0 ? score + sourceBonus : 0;
99
+ }
100
+ function normalizeTerms(query) {
101
+ const rawTerms = query
102
+ .toLowerCase()
103
+ .split(/[^a-z0-9_\-\u3000-\u9fff]+/i)
104
+ .map((term) => term.trim())
105
+ .filter(Boolean);
106
+ const terms = new Set();
107
+ for (const term of rawTerms) {
108
+ terms.add(term);
109
+ const chars = Array.from(term);
110
+ if (chars.some(isCjkChar) && chars.length > 2) {
111
+ for (let i = 0; i < chars.length - 1; i++) {
112
+ terms.add(`${chars[i]}${chars[i + 1]}`);
113
+ }
114
+ }
115
+ }
116
+ return [...terms];
117
+ }
118
+ function isCjkChar(ch) {
119
+ const code = ch.codePointAt(0) ?? 0;
120
+ return code >= 0x3000 && code <= 0x9fff;
121
+ }
122
+ function formatSkillSearchResults(matches, total, query) {
123
+ const lines = [
124
+ query ? `Skill search results for "${query}" (${matches.length} of ${total}):` : `Available skills (${matches.length} of ${total}):`,
125
+ ];
126
+ for (const { skill } of matches) {
127
+ const tags = skill.tags && skill.tags.length > 0 ? ` [tags: ${skill.tags.join(", ")}]` : "";
128
+ const source = skill.source ? ` (${skill.source})` : "";
129
+ lines.push(`- ${skill.name}${source}: ${skill.description}${tags}`);
130
+ }
131
+ lines.push("");
132
+ lines.push("Call skill with the exact name to load a selected skill.");
133
+ return lines.join("\n");
134
+ }
@@ -38,11 +38,8 @@ export function createSkillTool(registry) {
38
38
  }
39
39
  const skill = registry.get(name);
40
40
  if (!skill) {
41
- const available = registry.summaries().map((item) => item.name).join(", ");
42
41
  return {
43
- content: available
44
- ? `Error: Unknown skill "${name}". Available skills: ${available}`
45
- : `Error: Unknown skill "${name}". No skills are currently available.`,
42
+ content: `Error: Unknown skill "${name}". Use skill_search to find available skills, then retry with the exact skill name.`,
46
43
  isError: true,
47
44
  };
48
45
  }