@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,217 @@
1
+ /**
2
+ * Pure-function reducer: AgentEvent → RunState.
3
+ *
4
+ * Mutates `state` in place for performance (this runs on every streaming
5
+ * token). Always sets state.updatedAt. Caller is expected to immediately
6
+ * re-render through the (throttled) card renderer.
7
+ */
8
+ const TOOL_ARGS_PREVIEW_LIMIT = 120;
9
+ const TOOL_RESULT_PREVIEW_LIMIT = 800;
10
+ const TEXT_BLOCK_MAX_CHARS = 12_000;
11
+ const THINKING_BLOCK_MAX_CHARS = 4_000;
12
+ export function reduceRunState(state, event) {
13
+ state.updatedAt = Date.now();
14
+ switch (event.type) {
15
+ case "turn_start":
16
+ // No state change — just signals a new LLM round trip.
17
+ return state;
18
+ case "text_delta": {
19
+ const last = state.blocks[state.blocks.length - 1];
20
+ if (last && last.kind === "text" && last.streaming) {
21
+ last.text = appendBounded(last.text, event.content, TEXT_BLOCK_MAX_CHARS);
22
+ }
23
+ else {
24
+ closeStreamingBlocks(state);
25
+ const block = {
26
+ kind: "text",
27
+ text: appendBounded("", event.content, TEXT_BLOCK_MAX_CHARS),
28
+ streaming: true,
29
+ };
30
+ state.blocks.push(block);
31
+ }
32
+ return state;
33
+ }
34
+ case "reasoning_delta": {
35
+ const last = state.blocks[state.blocks.length - 1];
36
+ if (last && last.kind === "thinking" && last.streaming) {
37
+ last.text = appendBounded(last.text, event.content, THINKING_BLOCK_MAX_CHARS);
38
+ }
39
+ else {
40
+ closeStreamingBlocks(state);
41
+ const block = {
42
+ kind: "thinking",
43
+ text: appendBounded("", event.content, THINKING_BLOCK_MAX_CHARS),
44
+ streaming: true,
45
+ };
46
+ state.blocks.push(block);
47
+ }
48
+ return state;
49
+ }
50
+ case "tool_call_start":
51
+ case "tool_call_delta":
52
+ case "tool_call_end":
53
+ // Args streaming is noisy; we wait for `tool_start` (parsed args) before
54
+ // creating the visible tool block.
55
+ return state;
56
+ case "tool_start": {
57
+ closeStreamingBlocks(state);
58
+ const block = {
59
+ kind: "tool",
60
+ id: event.id,
61
+ name: event.name,
62
+ argsPreview: formatArgsPreview(event.args),
63
+ status: "running",
64
+ startedAt: Date.now(),
65
+ };
66
+ state.blocks.push(block);
67
+ return state;
68
+ }
69
+ case "tool_update":
70
+ // Subagent updates carry rich child events; render as nested status
71
+ // tweak (status flips to "running" for queued→running). We keep it
72
+ // minimal in v1 — the parent tool block reflects high-level state.
73
+ return state;
74
+ case "tool_end": {
75
+ const block = findToolBlockById(state, event.id);
76
+ if (block) {
77
+ block.status = event.result.isError ? "err" : "ok";
78
+ block.resultPreview = truncateOneline(event.result.content, TOOL_RESULT_PREVIEW_LIMIT);
79
+ block.endedAt = Date.now();
80
+ }
81
+ return state;
82
+ }
83
+ case "turn_end": {
84
+ if (event.usage) {
85
+ state.usage = mergeUsage(state.usage, event.usage);
86
+ }
87
+ return state;
88
+ }
89
+ case "mode_changed":
90
+ state.mode = event.mode;
91
+ return state;
92
+ case "todos_updated":
93
+ // Todos render is deferred (would need a todos block kind). v1 ignores.
94
+ return state;
95
+ case "context_recovered":
96
+ // Internal recovery — silent.
97
+ return state;
98
+ case "agent_end":
99
+ closeStreamingBlocks(state);
100
+ if (state.status === "running") {
101
+ state.status = "completed";
102
+ }
103
+ return state;
104
+ default:
105
+ return state;
106
+ }
107
+ }
108
+ /** Mark `status="interrupted"` and close any streaming blocks. */
109
+ export function markInterrupted(state) {
110
+ closeStreamingBlocks(state);
111
+ state.status = "interrupted";
112
+ state.updatedAt = Date.now();
113
+ return state;
114
+ }
115
+ /** Mark `status="error"`. */
116
+ export function markError(state, error) {
117
+ closeStreamingBlocks(state);
118
+ state.status = "error";
119
+ const message = typeof error === "string" ? error : (error.message || String(error));
120
+ state.error = { message };
121
+ state.updatedAt = Date.now();
122
+ return state;
123
+ }
124
+ /** Mark `status="idle_timeout"`. */
125
+ export function markIdleTimeout(state) {
126
+ closeStreamingBlocks(state);
127
+ if (state.status === "running") {
128
+ state.status = "idle_timeout";
129
+ }
130
+ state.updatedAt = Date.now();
131
+ return state;
132
+ }
133
+ /**
134
+ * Last-block-only check used by idle watchdog: if there's an in-flight tool,
135
+ * we don't count toward idle.
136
+ */
137
+ export function hasInFlightTool(state) {
138
+ for (let i = state.blocks.length - 1; i >= 0; i--) {
139
+ const block = state.blocks[i];
140
+ if (block.kind === "tool" && block.status === "running")
141
+ return true;
142
+ }
143
+ return false;
144
+ }
145
+ function closeStreamingBlocks(state) {
146
+ for (const block of state.blocks) {
147
+ if (block.kind === "text" || block.kind === "thinking") {
148
+ block.streaming = false;
149
+ }
150
+ }
151
+ }
152
+ function findToolBlockById(state, id) {
153
+ for (let i = state.blocks.length - 1; i >= 0; i--) {
154
+ const block = state.blocks[i];
155
+ if (block.kind === "tool" && block.id === id)
156
+ return block;
157
+ }
158
+ return undefined;
159
+ }
160
+ function appendBounded(prev, delta, max) {
161
+ if (prev.length + delta.length <= max)
162
+ return prev + delta;
163
+ const next = prev + delta;
164
+ // Drop oldest characters; keep tail. Mark truncation only once.
165
+ const overflow = next.length - max;
166
+ return "…" + next.slice(overflow + 1);
167
+ }
168
+ function formatArgsPreview(args) {
169
+ // Pretty-print scalar fields inline; nested objects get JSON-stringified
170
+ // briefly. This is the user-facing one-liner under the tool name.
171
+ const parts = [];
172
+ for (const [key, value] of Object.entries(args)) {
173
+ let formatted;
174
+ if (typeof value === "string") {
175
+ formatted = value.length > 60 ? `"${value.slice(0, 57)}..."` : `"${value}"`;
176
+ }
177
+ else if (typeof value === "number" || typeof value === "boolean") {
178
+ formatted = String(value);
179
+ }
180
+ else if (value === null) {
181
+ formatted = "null";
182
+ }
183
+ else {
184
+ try {
185
+ const json = JSON.stringify(value);
186
+ formatted = json.length > 60 ? `${json.slice(0, 57)}...` : json;
187
+ }
188
+ catch {
189
+ formatted = "[unserializable]";
190
+ }
191
+ }
192
+ parts.push(`${key}=${formatted}`);
193
+ if (parts.join(", ").length > TOOL_ARGS_PREVIEW_LIMIT) {
194
+ parts[parts.length - 1] = parts[parts.length - 1].slice(0, TOOL_ARGS_PREVIEW_LIMIT - parts.slice(0, -1).join(", ").length) + "…";
195
+ break;
196
+ }
197
+ }
198
+ return parts.join(", ");
199
+ }
200
+ function truncateOneline(text, max) {
201
+ const trimmed = text.replace(/\s+/g, " ").trim();
202
+ if (trimmed.length <= max)
203
+ return trimmed;
204
+ return trimmed.slice(0, max - 1) + "…";
205
+ }
206
+ function mergeUsage(prev, next) {
207
+ if (!prev)
208
+ return { ...next };
209
+ return {
210
+ promptTokens: prev.promptTokens + (next.promptTokens ?? 0),
211
+ completionTokens: prev.completionTokens + (next.completionTokens ?? 0),
212
+ promptCacheHitTokens: (prev.promptCacheHitTokens ?? 0) + (next.promptCacheHitTokens ?? 0),
213
+ promptCacheMissTokens: (prev.promptCacheMissTokens ?? 0) + (next.promptCacheMissTokens ?? 0),
214
+ reasoningTokens: (prev.reasoningTokens ?? 0) + (next.reasoningTokens ?? 0),
215
+ totalTokens: (prev.totalTokens ?? 0) + (next.totalTokens ?? 0),
216
+ };
217
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Thin wrapper around `LarkChannel` from @larksuiteoapi/node-sdk.
3
+ *
4
+ * We don't add much behavior — just provide a stable, mockable surface for
5
+ * the rest of the Feishu host. Tests construct a `MockBubbleChannel` that
6
+ * implements the same interface without touching real network.
7
+ */
8
+ import { type LarkChannel, type NormalizedMessage, type CardActionEvent, type RejectEvent, type SendResult, type SendOptions, type WSConnectionStatus } from "@larksuiteoapi/node-sdk";
9
+ export type { NormalizedMessage, CardActionEvent };
10
+ export interface BubbleChannelOptions {
11
+ appId: string;
12
+ appSecret: string;
13
+ /** Forwarded to LarkChannelOptions.outbound.streamThrottleMs. */
14
+ outputThrottleMs?: number;
15
+ /**
16
+ * If true (default), group messages require @bot mention before being
17
+ * forwarded. Note: this is enforced at the LarkChannel policy layer; the
18
+ * router applies the per-scope `requireMentionInGroup` setting on top.
19
+ */
20
+ requireMentionInGroup?: boolean;
21
+ }
22
+ export interface BubbleChannel {
23
+ /** Resolve once the WebSocket has handshaken at least once. */
24
+ connect(): Promise<void>;
25
+ /** Close cleanly. */
26
+ disconnect(): Promise<void>;
27
+ /** Connection-status snapshot, for /status / /doctor commands. */
28
+ getStatus(): WSConnectionStatus | undefined;
29
+ /** Send a one-off message (text, card, etc.) — see SDK SendInput. */
30
+ send(chatId: string, input: SendInput, opts?: SendOptions): Promise<SendResult>;
31
+ /** Patch an already-sent card. */
32
+ updateCard(messageId: string, card: object): Promise<void>;
33
+ /**
34
+ * Open a streaming card; producer is given a controller with `update()`.
35
+ * The SDK handles throttling and the 30KB element auto-rollover.
36
+ */
37
+ stream(chatId: string, input: StreamInput, opts?: SendOptions): Promise<SendResult>;
38
+ /** Event subscriptions. Returned function unsubscribes. */
39
+ onMessage(handler: (msg: NormalizedMessage) => void | Promise<void>): () => void;
40
+ onCardAction(handler: (evt: CardActionEvent) => void | Promise<void>): () => void;
41
+ onReject(handler: (evt: RejectEvent) => void): () => void;
42
+ onError(handler: (err: Error) => void): () => void;
43
+ onReconnecting(handler: () => void): () => void;
44
+ onReconnected(handler: () => void): () => void;
45
+ /** Fetch the chat's mode (cached by callers, not by us). */
46
+ getChatMode(chatId: string): Promise<"p2p" | "group" | "topic">;
47
+ /** Reveal the bot's own open_id once connected. Useful for self-mention checks. */
48
+ botOpenId(): string | undefined;
49
+ }
50
+ export type SendInput = Parameters<LarkChannel["send"]>[1];
51
+ export type StreamInput = Parameters<LarkChannel["stream"]>[1];
52
+ export declare function createBubbleChannel(opts: BubbleChannelOptions): BubbleChannel;
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Thin wrapper around `LarkChannel` from @larksuiteoapi/node-sdk.
3
+ *
4
+ * We don't add much behavior — just provide a stable, mockable surface for
5
+ * the rest of the Feishu host. Tests construct a `MockBubbleChannel` that
6
+ * implements the same interface without touching real network.
7
+ */
8
+ import { createLarkChannel, LoggerLevel, } from "@larksuiteoapi/node-sdk";
9
+ export function createBubbleChannel(opts) {
10
+ const verbose = process.env.BUBBLE_FEISHU_DEBUG === "1";
11
+ const sdkOpts = {
12
+ appId: opts.appId,
13
+ appSecret: opts.appSecret,
14
+ transport: "websocket",
15
+ loggerLevel: verbose ? LoggerLevel.debug : LoggerLevel.warn,
16
+ includeRawEvent: verbose,
17
+ outbound: {
18
+ streamThrottleMs: opts.outputThrottleMs ?? 400,
19
+ },
20
+ policy: {
21
+ // Bubble enforces mention/whitelist itself in the router. Keep the
22
+ // SDK's policy layer permissive so we can give cleaner error
23
+ // feedback than a silent SDK-side reject.
24
+ dmMode: "open",
25
+ requireMention: false,
26
+ respondToMentionAll: false,
27
+ },
28
+ };
29
+ const inner = createLarkChannel(sdkOpts);
30
+ return {
31
+ async connect() {
32
+ await inner.connect();
33
+ },
34
+ async disconnect() {
35
+ await inner.disconnect();
36
+ },
37
+ getStatus() {
38
+ return inner.getConnectionStatus();
39
+ },
40
+ async send(chatId, input, opts) {
41
+ return inner.send(chatId, input, opts);
42
+ },
43
+ async updateCard(messageId, card) {
44
+ return inner.updateCard(messageId, card);
45
+ },
46
+ async stream(chatId, input, opts) {
47
+ return inner.stream(chatId, input, opts);
48
+ },
49
+ onMessage(handler) {
50
+ return inner.on("message", handler);
51
+ },
52
+ onCardAction(handler) {
53
+ return inner.on("cardAction", handler);
54
+ },
55
+ onReject(handler) {
56
+ return inner.on("reject", handler);
57
+ },
58
+ onError(handler) {
59
+ return inner.on("error", handler);
60
+ },
61
+ onReconnecting(handler) {
62
+ return inner.on("reconnecting", handler);
63
+ },
64
+ onReconnected(handler) {
65
+ return inner.on("reconnected", handler);
66
+ },
67
+ async getChatMode(chatId) {
68
+ return inner.getChatMode(chatId);
69
+ },
70
+ botOpenId() {
71
+ return inner.botIdentity?.openId;
72
+ },
73
+ };
74
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * FeishuConfig load / save. Failures surface as thrown errors with helpful
3
+ * remediation hints; callers (serve.ts) translate them into user-facing
4
+ * messages.
5
+ */
6
+ import type { FeishuConfig } from "./types.js";
7
+ export declare class FeishuConfigError extends Error {
8
+ readonly hint?: string | undefined;
9
+ constructor(message: string, hint?: string | undefined);
10
+ }
11
+ export declare function configExists(): boolean;
12
+ export declare function loadConfig(): FeishuConfig;
13
+ export declare function saveConfig(config: FeishuConfig): void;
14
+ export declare function resolveAppSecret(config: FeishuConfig): string;
15
+ export interface BootstrapInput {
16
+ appId: string;
17
+ appSecret: string;
18
+ ownerOpenId: string;
19
+ }
20
+ /**
21
+ * First-time setup: encrypt the secret, write keystore + config files.
22
+ * Used by the wizard after a successful registerApp() flow.
23
+ */
24
+ export declare function bootstrapConfig(input: BootstrapInput): FeishuConfig;
@@ -0,0 +1,97 @@
1
+ /**
2
+ * FeishuConfig load / save. Failures surface as thrown errors with helpful
3
+ * remediation hints; callers (serve.ts) translate them into user-facing
4
+ * messages.
5
+ */
6
+ import { existsSync, readFileSync, writeFileSync, chmodSync } from "node:fs";
7
+ import { getConfigPath, getSecretsPath } from "./paths.js";
8
+ import { validateFeishuConfig } from "./schema.js";
9
+ import { DEFAULT_PREFERENCES, DEFAULT_GLOBAL_LIMITS } from "./types.js";
10
+ import { decryptSecret, encryptWithSelfCheck, loadKeystoreFile, saveKeystoreFile } from "./secrets.js";
11
+ export class FeishuConfigError extends Error {
12
+ hint;
13
+ constructor(message, hint) {
14
+ super(message);
15
+ this.hint = hint;
16
+ this.name = "FeishuConfigError";
17
+ }
18
+ }
19
+ export function configExists() {
20
+ return existsSync(getConfigPath());
21
+ }
22
+ export function loadConfig() {
23
+ const path = getConfigPath();
24
+ if (!existsSync(path)) {
25
+ throw new FeishuConfigError(`No Feishu config found at ${path}`, "Run `bubble serve --feishu --setup` to create one.");
26
+ }
27
+ let raw;
28
+ try {
29
+ raw = readFileSync(path, "utf8");
30
+ }
31
+ catch (err) {
32
+ throw new FeishuConfigError(`Failed to read config: ${err.message}`);
33
+ }
34
+ let parsed;
35
+ try {
36
+ parsed = JSON.parse(raw);
37
+ }
38
+ catch (err) {
39
+ throw new FeishuConfigError(`Config is not valid JSON: ${err.message}`);
40
+ }
41
+ const result = validateFeishuConfig(parsed);
42
+ if (!result.ok || !result.value) {
43
+ throw new FeishuConfigError(`Config has invalid shape:\n - ${result.errors.join("\n - ")}`, `Edit ${path} or rerun --setup.`);
44
+ }
45
+ return result.value;
46
+ }
47
+ export function saveConfig(config) {
48
+ const path = getConfigPath();
49
+ writeFileSync(path, JSON.stringify(config, null, 2), { encoding: "utf8", mode: 0o600 });
50
+ try {
51
+ chmodSync(path, 0o600);
52
+ }
53
+ catch {
54
+ // Best-effort.
55
+ }
56
+ }
57
+ export function resolveAppSecret(config) {
58
+ const ref = config.app.secretRef;
59
+ if (ref.source === "env") {
60
+ const value = process.env[ref.varName];
61
+ if (!value || !value.trim()) {
62
+ throw new FeishuConfigError(`Env var ${ref.varName} (referenced by config.app.secretRef) is empty or unset`, `Set it in your shell before running, e.g. \`export ${ref.varName}=...\``);
63
+ }
64
+ return value.trim();
65
+ }
66
+ if (ref.source === "keystore") {
67
+ try {
68
+ const record = loadKeystoreFile(getSecretsPath());
69
+ return decryptSecret(record);
70
+ }
71
+ catch (err) {
72
+ throw new FeishuConfigError(`Failed to load App Secret from keystore: ${err.message}`, "Rerun `bubble serve --feishu --setup` to regenerate.");
73
+ }
74
+ }
75
+ throw new FeishuConfigError(`Unknown secretRef.source: ${ref.source}`);
76
+ }
77
+ /**
78
+ * First-time setup: encrypt the secret, write keystore + config files.
79
+ * Used by the wizard after a successful registerApp() flow.
80
+ */
81
+ export function bootstrapConfig(input) {
82
+ const { record, check } = encryptWithSelfCheck(input.appSecret);
83
+ saveKeystoreFile(getSecretsPath(), record);
84
+ const config = {
85
+ version: 1,
86
+ app: {
87
+ appId: input.appId,
88
+ ownerOpenId: input.ownerOpenId,
89
+ secretRef: { source: "keystore", name: "default" },
90
+ encryptCheck: check,
91
+ },
92
+ preferences: { ...DEFAULT_PREFERENCES },
93
+ globalLimits: { ...DEFAULT_GLOBAL_LIMITS },
94
+ };
95
+ saveConfig(config);
96
+ return config;
97
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Small formatting helpers shared across the Feishu host.
3
+ */
4
+ import type { PermissionMode } from "../types.js";
5
+ export declare function formatPermissionMode(mode: PermissionMode): string;
6
+ export declare function isPermissionModeName(value: string): value is PermissionMode;
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Small formatting helpers shared across the Feishu host.
3
+ */
4
+ const MODE_LABELS = {
5
+ default: "default",
6
+ plan: "plan",
7
+ bypassPermissions: "bypass",
8
+ };
9
+ export function formatPermissionMode(mode) {
10
+ return MODE_LABELS[mode] ?? mode;
11
+ }
12
+ export function isPermissionModeName(value) {
13
+ return value === "default" || value === "plan" || value === "bypassPermissions";
14
+ }
@@ -0,0 +1,4 @@
1
+ /**
2
+ * Public entry for the Feishu host. Most callers will only need `serveFeishu`.
3
+ */
4
+ export { serveFeishu, type ServeFeishuOptions } from "./serve.js";
@@ -0,0 +1,4 @@
1
+ /**
2
+ * Public entry for the Feishu host. Most callers will only need `serveFeishu`.
3
+ */
4
+ export { serveFeishu } from "./serve.js";
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Minimal JSONL logger for the Feishu host.
3
+ *
4
+ * Writes one line per event to `~/.bubble/feishu/logs/YYYY-MM-DD.log`.
5
+ * Auto-rotates by date; old files (>7d) are deleted at startup.
6
+ */
7
+ export type LogLevel = "debug" | "info" | "warn" | "error";
8
+ export interface LogFields {
9
+ phase?: string;
10
+ scope?: string;
11
+ chatId?: string;
12
+ userId?: string;
13
+ messageId?: string;
14
+ error?: {
15
+ message: string;
16
+ name?: string;
17
+ stack?: string;
18
+ };
19
+ [key: string]: unknown;
20
+ }
21
+ export declare class FeishuLogger {
22
+ private currentDate;
23
+ private currentPath;
24
+ log(level: LogLevel, msg: string, fields?: LogFields): void;
25
+ debug(msg: string, fields?: LogFields): void;
26
+ info(msg: string, fields?: LogFields): void;
27
+ warn(msg: string, fields?: LogFields): void;
28
+ error(msg: string, fields?: LogFields): void;
29
+ /** Delete log files older than `maxAgeDays`. */
30
+ pruneOldLogs(maxAgeDays?: number): void;
31
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Minimal JSONL logger for the Feishu host.
3
+ *
4
+ * Writes one line per event to `~/.bubble/feishu/logs/YYYY-MM-DD.log`.
5
+ * Auto-rotates by date; old files (>7d) are deleted at startup.
6
+ */
7
+ import { appendFileSync, readdirSync, unlinkSync, statSync } from "node:fs";
8
+ import { join } from "node:path";
9
+ import { getLogsDir } from "./paths.js";
10
+ export class FeishuLogger {
11
+ currentDate = "";
12
+ currentPath = "";
13
+ log(level, msg, fields = {}) {
14
+ const ts = new Date();
15
+ const dateKey = ts.toISOString().slice(0, 10);
16
+ if (dateKey !== this.currentDate) {
17
+ this.currentDate = dateKey;
18
+ this.currentPath = join(getLogsDir(), `${dateKey}.log`);
19
+ }
20
+ const line = JSON.stringify({
21
+ ts: ts.toISOString(),
22
+ level,
23
+ msg,
24
+ ...fields,
25
+ });
26
+ try {
27
+ appendFileSync(this.currentPath, line + "\n");
28
+ }
29
+ catch {
30
+ // Logging failures must not crash the process.
31
+ }
32
+ }
33
+ debug(msg, fields) { this.log("debug", msg, fields); }
34
+ info(msg, fields) { this.log("info", msg, fields); }
35
+ warn(msg, fields) { this.log("warn", msg, fields); }
36
+ error(msg, fields) { this.log("error", msg, fields); }
37
+ /** Delete log files older than `maxAgeDays`. */
38
+ pruneOldLogs(maxAgeDays = 7) {
39
+ const cutoffMs = Date.now() - maxAgeDays * 24 * 60 * 60 * 1000;
40
+ const dir = getLogsDir();
41
+ let entries;
42
+ try {
43
+ entries = readdirSync(dir);
44
+ }
45
+ catch {
46
+ return;
47
+ }
48
+ for (const name of entries) {
49
+ if (!name.endsWith(".log"))
50
+ continue;
51
+ const path = join(dir, name);
52
+ try {
53
+ const stat = statSync(path);
54
+ if (stat.mtimeMs < cutoffMs)
55
+ unlinkSync(path);
56
+ }
57
+ catch {
58
+ // skip
59
+ }
60
+ }
61
+ }
62
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Path helpers for the Feishu host. All state lives under
3
+ * `~/.bubble/feishu/` (or the dev/test variants, via getBubbleHome()).
4
+ */
5
+ export declare function getFeishuHome(): string;
6
+ export declare function getConfigPath(): string;
7
+ export declare function getSecretsPath(): string;
8
+ export declare function getScopesPath(): string;
9
+ export declare function getSessionsPath(): string;
10
+ export declare function getProcessRegistryPath(): string;
11
+ export declare function getLogsDir(): string;
12
+ export declare function getMediaDir(chatId: string): string;
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Path helpers for the Feishu host. All state lives under
3
+ * `~/.bubble/feishu/` (or the dev/test variants, via getBubbleHome()).
4
+ */
5
+ import { mkdirSync } from "node:fs";
6
+ import { join } from "node:path";
7
+ import { getBubbleHome } from "../bubble-home.js";
8
+ export function getFeishuHome() {
9
+ const dir = join(getBubbleHome(), "feishu");
10
+ mkdirSync(dir, { recursive: true });
11
+ return dir;
12
+ }
13
+ export function getConfigPath() {
14
+ return join(getFeishuHome(), "config.json");
15
+ }
16
+ export function getSecretsPath() {
17
+ return join(getFeishuHome(), "secrets.enc");
18
+ }
19
+ export function getScopesPath() {
20
+ return join(getFeishuHome(), "scopes.json");
21
+ }
22
+ export function getSessionsPath() {
23
+ return join(getFeishuHome(), "sessions.json");
24
+ }
25
+ export function getProcessRegistryPath() {
26
+ return join(getFeishuHome(), "processes.json");
27
+ }
28
+ export function getLogsDir() {
29
+ const dir = join(getFeishuHome(), "logs");
30
+ mkdirSync(dir, { recursive: true });
31
+ return dir;
32
+ }
33
+ export function getMediaDir(chatId) {
34
+ const safe = chatId.replace(/[^A-Za-z0-9_-]/g, "_");
35
+ const dir = join(getFeishuHome(), "media", safe);
36
+ mkdirSync(dir, { recursive: true });
37
+ return dir;
38
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Detect concurrent `bubble serve --feishu` instances for the same App ID.
3
+ *
4
+ * Two processes against the same App ID will fight over the long
5
+ * connection and double-process messages. We record the running PID + appId
6
+ * in `~/.bubble/feishu/processes.json` at startup and remove it at exit.
7
+ *
8
+ * On startup we check the file; any entry whose pid is still alive AND
9
+ * whose appId matches is a conflict.
10
+ */
11
+ import type { ProcessRegistryEntry } from "./types.js";
12
+ export interface ConflictInfo {
13
+ entry: ProcessRegistryEntry;
14
+ }
15
+ export declare class ProcessRegistry {
16
+ private file;
17
+ private path;
18
+ constructor();
19
+ /** Return all entries whose pid is alive AND whose appId matches. */
20
+ findConflicts(appId: string): ConflictInfo[];
21
+ /** Kill any conflicting entry's process (SIGTERM). Returns count killed. */
22
+ killConflicts(appId: string): number;
23
+ /** Remove dead entries from disk. */
24
+ gc(): void;
25
+ register(entry: ProcessRegistryEntry): void;
26
+ deregister(pid: number): void;
27
+ private read;
28
+ private flush;
29
+ }