@leviyuan/lodestar 0.2.9 → 0.3.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.
- package/README.md +74 -41
- package/dist/lodestar-setup.js +12 -0
- package/dist/lodestar.js +147 -0
- package/package.json +15 -6
- package/scripts/postinstall.cjs +97 -0
- package/daemon.ts +0 -355
- package/src/cardkit.ts +0 -349
- package/src/cards/console.ts +0 -352
- package/src/cards/elements.ts +0 -22
- package/src/cards/turn.ts +0 -530
- package/src/cards.ts +0 -32
- package/src/claude-process.ts +0 -417
- package/src/config.ts +0 -98
- package/src/feishu.ts +0 -498
- package/src/instructions.ts +0 -13
- package/src/log.ts +0 -11
- package/src/notify.ts +0 -132
- package/src/paths.ts +0 -57
- package/src/session-ask.ts +0 -165
- package/src/session-permission.ts +0 -136
- package/src/session-tools.ts +0 -233
- package/src/session-types.ts +0 -91
- package/src/session.ts +0 -1137
- package/src/sysinfo.ts +0 -273
- package/src/usage.ts +0 -327
package/src/cards/elements.ts
DELETED
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Element-id convention (must be unique within a card):
|
|
3
|
-
* user_input — the collapsible "你说" panel
|
|
4
|
-
* thinking — the de-emphasized thinking stream
|
|
5
|
-
* tool_<i> — one collapsible per tool call, indexed from 0
|
|
6
|
-
* assistant — the main streaming assistant answer
|
|
7
|
-
* footer — runtime footer (timing / status)
|
|
8
|
-
*/
|
|
9
|
-
export const ELEMENTS = {
|
|
10
|
-
thinking: 'thinking',
|
|
11
|
-
footer: 'footer',
|
|
12
|
-
tool: (i: number) => `tool_${i}`,
|
|
13
|
-
/** Assistant text is segmented: every tool call closes the running segment
|
|
14
|
-
* and the next assistant chunk opens a new one, so element order in the
|
|
15
|
-
* card matches Claude's emission order. */
|
|
16
|
-
assistant: (i: number) => `assistant_${i}`,
|
|
17
|
-
/** Console (hi) card — the subscription-usage row is rendered as its
|
|
18
|
-
* own element so we can replace it after the initial card lands,
|
|
19
|
-
* decoupling the slow ccusage fetch from the rest of the panel's
|
|
20
|
-
* synchronous data. */
|
|
21
|
-
consoleUsage: 'console_usage',
|
|
22
|
-
} as const
|
package/src/cards/turn.ts
DELETED
|
@@ -1,530 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Schema 2.0 turn-card templates: the main streaming card, the per-tool
|
|
3
|
-
* collapsible panels, and the AskUserQuestion interactive panel. All
|
|
4
|
-
* rendering for "the in-flight conversation card" lives here. Console
|
|
5
|
-
* UI lives in console.ts; the shared element-id convention is in
|
|
6
|
-
* elements.ts.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import { ELEMENTS } from './elements'
|
|
10
|
-
|
|
11
|
-
/** Minimal projection of an SDK task — used by Session's local mirror,
|
|
12
|
-
* built incrementally from observed TaskCreate / TaskUpdate input+output
|
|
13
|
-
* pairs. Not authoritative (the SDK is the source of truth), but enough
|
|
14
|
-
* to render the "全部任务清单" footer on every Task* panel. */
|
|
15
|
-
export interface Todo {
|
|
16
|
-
id: number
|
|
17
|
-
subject?: string
|
|
18
|
-
description?: string
|
|
19
|
-
status: 'pending' | 'in_progress' | 'completed' | string
|
|
20
|
-
owner?: string
|
|
21
|
-
activeForm?: string
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
function todoStatusIcon(s: string): string {
|
|
25
|
-
switch (s) {
|
|
26
|
-
case 'pending': return '☐'
|
|
27
|
-
case 'in_progress': return '🔄'
|
|
28
|
-
case 'completed': return '✅'
|
|
29
|
-
default: return '·'
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/** Render the session's full todo mirror as a markdown list. Empty list
|
|
34
|
-
* yields '' so callers can unconditionally concat. Sorted by numeric id
|
|
35
|
-
* so the order matches creation order regardless of Map iteration. */
|
|
36
|
-
function renderTodoList(todos: Todo[]): string {
|
|
37
|
-
if (!todos || todos.length === 0) return ''
|
|
38
|
-
const sorted = [...todos].sort((a, b) => a.id - b.id)
|
|
39
|
-
const lines = ['', '---', `**📋 当前任务清单(${sorted.length} 项)**`, '']
|
|
40
|
-
for (const t of sorted) {
|
|
41
|
-
const icon = todoStatusIcon(t.status)
|
|
42
|
-
const subject = t.subject ?? '(无 subject)'
|
|
43
|
-
const ownerTag = t.owner ? ` · ${t.owner}` : ''
|
|
44
|
-
lines.push(`- ${icon} **#${t.id}** ${subject}${ownerTag}`)
|
|
45
|
-
}
|
|
46
|
-
return lines.join('\n')
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/** Single-line summary used as a collapsible-panel header for a tool call. */
|
|
50
|
-
export function summarizeToolInput(name: string, input: any): string {
|
|
51
|
-
if (!input || typeof input !== 'object') return ''
|
|
52
|
-
const truncate = (s: string, n: number) => s.length > n ? s.slice(0, n) + '…' : s
|
|
53
|
-
// Task workflow tools (TaskCreate / TaskUpdate / TaskList / ...) carry
|
|
54
|
-
// structured fields that summarize much better as natural language than
|
|
55
|
-
// as truncated JSON. Routed first so they don't fall through to the
|
|
56
|
-
// generic Agent/Task case below.
|
|
57
|
-
if (name.startsWith('Task') && name !== 'Task') {
|
|
58
|
-
return truncate(summarizeTaskWorkflow(name, input), 80)
|
|
59
|
-
}
|
|
60
|
-
switch (name) {
|
|
61
|
-
case 'Bash': return truncate(String(input.description ?? input.command ?? ''), 80)
|
|
62
|
-
case 'Read':
|
|
63
|
-
case 'Write':
|
|
64
|
-
case 'Edit':
|
|
65
|
-
case 'NotebookEdit': return truncate(String(input.file_path ?? ''), 80)
|
|
66
|
-
case 'Glob': return truncate(String(input.pattern ?? ''), 80)
|
|
67
|
-
case 'Grep': return truncate(`${input.pattern ?? ''}${input.path ? ' in ' + input.path : ''}`, 80)
|
|
68
|
-
case 'WebFetch':
|
|
69
|
-
case 'WebSearch': return truncate(String(input.url ?? input.query ?? ''), 80)
|
|
70
|
-
case 'Agent':
|
|
71
|
-
case 'Task': return truncate(String(input.description ?? input.subject ?? ''), 80)
|
|
72
|
-
case 'Skill': return truncate(String(input.skill ?? ''), 80)
|
|
73
|
-
}
|
|
74
|
-
// generic fallback: first string-valued field
|
|
75
|
-
for (const v of Object.values(input)) {
|
|
76
|
-
if (typeof v === 'string' && v) return truncate(v, 80)
|
|
77
|
-
}
|
|
78
|
-
return ''
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
/** Header summary for Task* workflow tools — `Task` (singular) is the
|
|
82
|
-
* separate subagent-spawn tool and is handled above; everything else
|
|
83
|
-
* (TaskCreate / TaskUpdate / TaskList / TaskGet / TaskStop / TaskOutput /
|
|
84
|
-
* TaskDelete) summarises through here. */
|
|
85
|
-
function summarizeTaskWorkflow(name: string, input: any): string {
|
|
86
|
-
switch (name) {
|
|
87
|
-
case 'TaskCreate':
|
|
88
|
-
return `📝 创建: ${input.subject ?? '(无 subject)'}`
|
|
89
|
-
case 'TaskUpdate': {
|
|
90
|
-
const parts: string[] = []
|
|
91
|
-
if (input.status) parts.push(`→ ${input.status}`)
|
|
92
|
-
if (input.owner) parts.push(`owner=${input.owner}`)
|
|
93
|
-
if (input.subject) parts.push(`subject="${input.subject}"`)
|
|
94
|
-
if (input.addBlocks) parts.push(`blocks=[${(input.addBlocks ?? []).join(',')}]`)
|
|
95
|
-
if (input.addBlockedBy) parts.push(`blockedBy=[${(input.addBlockedBy ?? []).join(',')}]`)
|
|
96
|
-
const tail = parts.length ? ' ' + parts.join(', ') : ''
|
|
97
|
-
return `✏️ #${input.taskId ?? '?'}${tail}`
|
|
98
|
-
}
|
|
99
|
-
case 'TaskList': return '📋 查询任务列表'
|
|
100
|
-
case 'TaskGet': return `🔍 查询 #${input.taskId ?? '?'}`
|
|
101
|
-
case 'TaskStop': return `⏹ 停止 #${input.taskId ?? '?'}`
|
|
102
|
-
case 'TaskOutput': return `📤 取输出 #${input.taskId ?? '?'}`
|
|
103
|
-
case 'TaskDelete': return `🗑 删除 #${input.taskId ?? '?'}`
|
|
104
|
-
}
|
|
105
|
-
return name
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
/** Markdown body for Task* workflow tools — replaces the generic JSON
|
|
109
|
-
* dump with a human-readable description of the operation plus, once the
|
|
110
|
-
* tool result is in, the SDK's text reply (which already contains "Task
|
|
111
|
-
* #N created" / "Updated task #X" / a rendered list for TaskList). When
|
|
112
|
-
* `todos` is non-empty, the full mirror is appended as a "📋 当前任务
|
|
113
|
-
* 清单" footer so every Task* panel doubles as a current-state readout. */
|
|
114
|
-
function renderTaskWorkflowBody(name: string, input: any, output: string | null, todos?: Todo[]): string {
|
|
115
|
-
const lines: string[] = []
|
|
116
|
-
switch (name) {
|
|
117
|
-
case 'TaskCreate':
|
|
118
|
-
lines.push(`**📝 创建任务**`)
|
|
119
|
-
if (input.subject) lines.push(`- subject: ${input.subject}`)
|
|
120
|
-
if (input.description) lines.push(`- 描述: ${input.description}`)
|
|
121
|
-
if (input.activeForm) lines.push(`- 进行时: ${input.activeForm}`)
|
|
122
|
-
break
|
|
123
|
-
case 'TaskUpdate': {
|
|
124
|
-
lines.push(`**✏️ 更新 #${input.taskId ?? '?'}**`)
|
|
125
|
-
if (input.status) lines.push(`- status → \`${input.status}\``)
|
|
126
|
-
if (input.subject) lines.push(`- subject: ${input.subject}`)
|
|
127
|
-
if (input.description) lines.push(`- description: ${input.description}`)
|
|
128
|
-
if (input.owner) lines.push(`- owner: ${input.owner}`)
|
|
129
|
-
if (input.activeForm) lines.push(`- 进行时: ${input.activeForm}`)
|
|
130
|
-
if (input.addBlocks) lines.push(`- blocks → ${(input.addBlocks).join(', ')}`)
|
|
131
|
-
if (input.addBlockedBy) lines.push(`- blockedBy → ${(input.addBlockedBy).join(', ')}`)
|
|
132
|
-
if (input.metadata) lines.push(`- metadata: \`${JSON.stringify(input.metadata)}\``)
|
|
133
|
-
break
|
|
134
|
-
}
|
|
135
|
-
case 'TaskList': lines.push('**📋 查询当前任务清单**'); break
|
|
136
|
-
case 'TaskGet': lines.push(`**🔍 查询 #${input.taskId ?? '?'}**`); break
|
|
137
|
-
case 'TaskStop': lines.push(`**⏹ 停止 #${input.taskId ?? '?'}**`); break
|
|
138
|
-
case 'TaskOutput': lines.push(`**📤 取 #${input.taskId ?? '?'} 输出**`); break
|
|
139
|
-
case 'TaskDelete': lines.push(`**🗑 删除 #${input.taskId ?? '?'}**`); break
|
|
140
|
-
default:
|
|
141
|
-
lines.push(`**${name}**`)
|
|
142
|
-
lines.push('```\n' + JSON.stringify(input ?? {}, null, 2).slice(0, 1000) + '\n```')
|
|
143
|
-
}
|
|
144
|
-
if (output != null) {
|
|
145
|
-
lines.push('')
|
|
146
|
-
lines.push('---')
|
|
147
|
-
lines.push('**结果**')
|
|
148
|
-
lines.push(output.slice(0, 3000))
|
|
149
|
-
}
|
|
150
|
-
return lines.join('\n') + renderTodoList(todos ?? [])
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
interface MainCardOpts {
|
|
154
|
-
sessionName: string
|
|
155
|
-
turn: number
|
|
156
|
-
model?: string
|
|
157
|
-
effort?: string
|
|
158
|
-
/** What started this turn. `'scheduled'` adds a top-of-card banner so
|
|
159
|
-
* the user can tell a cron-fired wakeup apart from one of their own
|
|
160
|
-
* messages — the user's message bubble is otherwise the only visual
|
|
161
|
-
* cue, and scheduled turns have no preceding bubble in the chat. */
|
|
162
|
-
kind?: 'user_message' | 'scheduled'
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
/** Initial card sent at the start of each turn. Streaming on. */
|
|
166
|
-
export function mainConversationCard(opts: MainCardOpts): object {
|
|
167
|
-
const banner = opts.kind === 'scheduled'
|
|
168
|
-
? [{ tag: 'markdown', content: '⏰ 触发' }]
|
|
169
|
-
: []
|
|
170
|
-
return {
|
|
171
|
-
schema: '2.0',
|
|
172
|
-
config: {
|
|
173
|
-
streaming_mode: true,
|
|
174
|
-
summary: { content: '[Lodestar 正在生成…]' },
|
|
175
|
-
streaming_config: {
|
|
176
|
-
print_frequency_ms: { default: 60, android: 60, ios: 60, pc: 30 },
|
|
177
|
-
print_step: { default: 2, android: 2, ios: 2, pc: 4 },
|
|
178
|
-
print_strategy: 'fast',
|
|
179
|
-
},
|
|
180
|
-
},
|
|
181
|
-
body: {
|
|
182
|
-
// Initial body has just thinking + footer; assistant segments and tool
|
|
183
|
-
// panels are inserted between them in real time as Claude streams.
|
|
184
|
-
// Note: empty-string content is rejected by CardKit PUT so the
|
|
185
|
-
// thinking element starts with a single space placeholder; the first
|
|
186
|
-
// real append overwrites it. No echo of the user's message inside
|
|
187
|
-
// the card — the chat bubble above already shows it.
|
|
188
|
-
elements: [
|
|
189
|
-
...banner,
|
|
190
|
-
{ tag: 'markdown', element_id: ELEMENTS.thinking, content: ' ' },
|
|
191
|
-
{ tag: 'markdown', element_id: ELEMENTS.footer, content: '⏳ working…' },
|
|
192
|
-
],
|
|
193
|
-
},
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
/** Empty assistant segment to be inserted just before the footer. */
|
|
198
|
-
export function assistantSegmentElement(i: number): object {
|
|
199
|
-
return { tag: 'markdown', element_id: ELEMENTS.assistant(i), content: ' ' }
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
/** Final state for the thinking section once a turn closes — collapse the
|
|
203
|
-
* full thinking text into a panel so the card stays clean. Replaces the
|
|
204
|
-
* top-level `thinking` markdown element via PUT /elements/:id. */
|
|
205
|
-
export function thinkingCollapsedPanel(fullText: string): object {
|
|
206
|
-
const trimmed = fullText.trim()
|
|
207
|
-
return {
|
|
208
|
-
tag: 'collapsible_panel',
|
|
209
|
-
element_id: ELEMENTS.thinking,
|
|
210
|
-
header: { title: { tag: 'plain_text', content: `💭 思考过程 (${trimmed.length} 字)` } },
|
|
211
|
-
expanded: false,
|
|
212
|
-
elements: [
|
|
213
|
-
{ tag: 'markdown', content: trimmed.slice(0, 8000) || '_(空)_' },
|
|
214
|
-
],
|
|
215
|
-
}
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
/** Element to insert for each tool call. Expandable for big results.
|
|
219
|
-
*
|
|
220
|
-
* Header is a one-line summary: status + name + summarized input.
|
|
221
|
-
* Body holds the full input + (after completion) the full output.
|
|
222
|
-
* `resolvedNote` is an optional one-liner appended below the input —
|
|
223
|
-
* used to surface "✅ 允许 by Alice" inline after a permission decision
|
|
224
|
-
* lands but before the actual tool execution completes. */
|
|
225
|
-
export function toolCallElement(
|
|
226
|
-
i: number,
|
|
227
|
-
name: string,
|
|
228
|
-
input: any,
|
|
229
|
-
output: string | null,
|
|
230
|
-
status: '⏳' | '✅' | '❌' = '⏳',
|
|
231
|
-
resolvedNote?: string,
|
|
232
|
-
/** Session's full todo mirror — only rendered when the tool is a Task*
|
|
233
|
-
* workflow op. Other tools ignore it. Passed in by Session so every
|
|
234
|
-
* Task* panel shows the *current* state, not just this op's diff. */
|
|
235
|
-
todos?: Todo[],
|
|
236
|
-
): object {
|
|
237
|
-
const summary = summarizeToolInput(name, input)
|
|
238
|
-
const headerText = summary
|
|
239
|
-
? `${status} 🔧 ${name}: ${summary}`
|
|
240
|
-
: `${status} 🔧 ${name}`
|
|
241
|
-
const isTaskWorkflow = name.startsWith('Task') && name !== 'Task'
|
|
242
|
-
const noteBlock = resolvedNote ? `\n\n${resolvedNote}` : ''
|
|
243
|
-
// Task* gets a narrative body (operation + result + current todo list),
|
|
244
|
-
// the rest keeps the JSON-input + raw-output split — generic dump is
|
|
245
|
-
// better for unfamiliar tools where users can't predict what fields
|
|
246
|
-
// matter.
|
|
247
|
-
const body = isTaskWorkflow
|
|
248
|
-
? renderTaskWorkflowBody(name, input, output, todos) + noteBlock
|
|
249
|
-
: '```\n' + JSON.stringify(input ?? {}, null, 2).slice(0, 2000) + '\n```'
|
|
250
|
-
+ noteBlock
|
|
251
|
-
+ (output != null ? '\n---\n**output:**\n```\n' + output.slice(0, 3000) + '\n```' : '')
|
|
252
|
-
return {
|
|
253
|
-
tag: 'collapsible_panel',
|
|
254
|
-
element_id: ELEMENTS.tool(i),
|
|
255
|
-
header: { title: { tag: 'plain_text', content: headerText } },
|
|
256
|
-
expanded: false,
|
|
257
|
-
elements: [
|
|
258
|
-
{ tag: 'markdown', content: body },
|
|
259
|
-
],
|
|
260
|
-
}
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
/** Merged panel for a run of consecutive `Read` tool calls in one turn.
|
|
264
|
-
* Header shows the dynamic count (`Read · 3 次`), body lists one row per
|
|
265
|
-
* Read with its own status + file path. Replaces the individual panels
|
|
266
|
-
* once a second Read joins the batch — single Reads still render as the
|
|
267
|
-
* full `toolCallElement` (with file-contents dump on completion). */
|
|
268
|
-
export function readBatchElement(
|
|
269
|
-
i: number,
|
|
270
|
-
items: Array<{ input: any; output: string | null; isError: boolean }>,
|
|
271
|
-
): object {
|
|
272
|
-
const n = items.length
|
|
273
|
-
const anyError = items.some(it => it.isError)
|
|
274
|
-
const allDone = items.every(it => it.output !== null)
|
|
275
|
-
const status = anyError ? '❌' : allDone ? '✅' : '⏳'
|
|
276
|
-
const headerText = `${status} 🔧 Read · ${n} 次`
|
|
277
|
-
const lines = items.map(it => `\`${String(it.input.file_path ?? '(无 path)')}\``)
|
|
278
|
-
return {
|
|
279
|
-
tag: 'collapsible_panel',
|
|
280
|
-
element_id: ELEMENTS.tool(i),
|
|
281
|
-
header: { title: { tag: 'plain_text', content: headerText } },
|
|
282
|
-
expanded: false,
|
|
283
|
-
elements: [{ tag: 'markdown', content: lines.join('\n') }],
|
|
284
|
-
}
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
/** Same tool panel as `toolCallElement`, but with the 🔐 status and
|
|
288
|
-
* three inline action buttons (allow / allow_always / deny). Expanded
|
|
289
|
-
* by default so the user can read the request without clicking through.
|
|
290
|
-
* This is the "merge into tool panel" UX — the permission decision
|
|
291
|
-
* lives on the same row as the tool call instead of as a separate
|
|
292
|
-
* floating card. */
|
|
293
|
-
export function toolCallPermissionElement(
|
|
294
|
-
i: number,
|
|
295
|
-
name: string,
|
|
296
|
-
input: any,
|
|
297
|
-
requestId: string,
|
|
298
|
-
): object {
|
|
299
|
-
const summary = summarizeToolInput(name, input)
|
|
300
|
-
const headerText = summary
|
|
301
|
-
? `🔐 等审批 · ${name}: ${summary}`
|
|
302
|
-
: `🔐 等审批 · ${name}`
|
|
303
|
-
const inputBlock = '```\n' + JSON.stringify(input ?? {}, null, 2).slice(0, 2000) + '\n```'
|
|
304
|
-
return {
|
|
305
|
-
tag: 'collapsible_panel',
|
|
306
|
-
element_id: ELEMENTS.tool(i),
|
|
307
|
-
header: { title: { tag: 'plain_text', content: headerText } },
|
|
308
|
-
expanded: true,
|
|
309
|
-
elements: [
|
|
310
|
-
{ tag: 'markdown', content: inputBlock },
|
|
311
|
-
{
|
|
312
|
-
tag: 'column_set',
|
|
313
|
-
columns: [
|
|
314
|
-
permissionButtonColumn('✅ 允许', 'primary', requestId, 'allow'),
|
|
315
|
-
permissionButtonColumn('♾️ 始终允许', 'default', requestId, 'allow_always'),
|
|
316
|
-
permissionButtonColumn('❌ 拒绝', 'danger', requestId, 'deny'),
|
|
317
|
-
],
|
|
318
|
-
},
|
|
319
|
-
],
|
|
320
|
-
}
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
function permissionButtonColumn(label: string, type: string, requestId: string, decision: string): object {
|
|
324
|
-
return {
|
|
325
|
-
tag: 'column', width: 'weighted', weight: 1,
|
|
326
|
-
elements: [{
|
|
327
|
-
tag: 'button',
|
|
328
|
-
text: { tag: 'plain_text', content: label },
|
|
329
|
-
type,
|
|
330
|
-
behaviors: [{ type: 'callback', value: { kind: 'permission', request_id: requestId, decision } }],
|
|
331
|
-
}],
|
|
332
|
-
}
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
/** Schema of an AskUserQuestion question, projected to just the fields
|
|
336
|
-
* the panel needs. Mirrors the SDK tool's input — kept loose since the
|
|
337
|
-
* runtime guarantees it matches. */
|
|
338
|
-
export interface AskQuestion {
|
|
339
|
-
question: string
|
|
340
|
-
header?: string
|
|
341
|
-
multiSelect?: boolean
|
|
342
|
-
options: { label: string; description?: string }[]
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
/** Per-question final-state. Mutually-exclusive branches: option pick
|
|
346
|
-
* vs. free-form custom text. */
|
|
347
|
-
export interface AskAnswered {
|
|
348
|
-
optionIdx?: number
|
|
349
|
-
customText?: string
|
|
350
|
-
user?: string
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
/** State the panel renders against. `currentIdx` undefined → terminal
|
|
354
|
-
* (every question answered). Otherwise it's the question currently on
|
|
355
|
-
* screen; everything in `answered` is history. */
|
|
356
|
-
export interface AskState {
|
|
357
|
-
currentIdx?: number
|
|
358
|
-
answered: Map<number, AskAnswered>
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
/** Render one question's body — either as clickable interactive_container
|
|
362
|
-
* rows (when picked === undefined) or as plain markdown summary
|
|
363
|
-
* (already-answered, shown in history-panel context). */
|
|
364
|
-
function renderAskQuestionBody(
|
|
365
|
-
q: AskQuestion,
|
|
366
|
-
toolUseId: string,
|
|
367
|
-
questionIdx: number,
|
|
368
|
-
picked?: AskAnswered,
|
|
369
|
-
): any[] {
|
|
370
|
-
const els: any[] = []
|
|
371
|
-
els.push({ tag: 'markdown', content: `**${q.question}**` })
|
|
372
|
-
for (let oi = 0; oi < q.options.length; oi++) {
|
|
373
|
-
const opt = q.options[oi]
|
|
374
|
-
const desc = opt.description ? ` · ${opt.description}` : ''
|
|
375
|
-
if (picked) {
|
|
376
|
-
const isPicked = picked.optionIdx === oi
|
|
377
|
-
els.push({
|
|
378
|
-
tag: 'markdown',
|
|
379
|
-
content: isPicked
|
|
380
|
-
? `✅ **${opt.label}**${desc}`
|
|
381
|
-
: `~~◯ ${opt.label}${desc}~~`,
|
|
382
|
-
})
|
|
383
|
-
} else {
|
|
384
|
-
els.push({
|
|
385
|
-
tag: 'interactive_container',
|
|
386
|
-
background_style: 'default',
|
|
387
|
-
has_border: true,
|
|
388
|
-
corner_radius: '6px',
|
|
389
|
-
padding: '8px 12px',
|
|
390
|
-
margin: '4px 0px 4px 0px',
|
|
391
|
-
behaviors: [{
|
|
392
|
-
type: 'callback',
|
|
393
|
-
value: {
|
|
394
|
-
kind: 'ask',
|
|
395
|
-
tool_use_id: toolUseId,
|
|
396
|
-
question_idx: questionIdx,
|
|
397
|
-
option_idx: oi,
|
|
398
|
-
},
|
|
399
|
-
}],
|
|
400
|
-
elements: [{ tag: 'markdown', content: `**${opt.label}**${desc}` }],
|
|
401
|
-
})
|
|
402
|
-
}
|
|
403
|
-
}
|
|
404
|
-
if (picked?.customText) {
|
|
405
|
-
els.push({ tag: 'markdown', content: `✏️ **自定义回答**:${picked.customText}` })
|
|
406
|
-
}
|
|
407
|
-
return els
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
/** Folded "📜 已答 N 题" panel — option C from the multi-question
|
|
411
|
-
* design discussion. Returns null when there's no history to show. */
|
|
412
|
-
function renderAskHistoryPanel(
|
|
413
|
-
questions: AskQuestion[],
|
|
414
|
-
answered: Map<number, AskAnswered>,
|
|
415
|
-
): any | null {
|
|
416
|
-
if (answered.size === 0) return null
|
|
417
|
-
const lines: string[] = []
|
|
418
|
-
const sortedIdx = [...answered.keys()].sort((a, b) => a - b)
|
|
419
|
-
for (const idx of sortedIdx) {
|
|
420
|
-
const q = questions[idx]
|
|
421
|
-
const a = answered.get(idx)!
|
|
422
|
-
const tag = q?.header ?? `Q${idx + 1}`
|
|
423
|
-
const value = a.customText
|
|
424
|
-
?? (a.optionIdx !== undefined ? q?.options[a.optionIdx]?.label : undefined)
|
|
425
|
-
?? '?'
|
|
426
|
-
lines.push(`- ✅ **${tag}**:${value}`)
|
|
427
|
-
}
|
|
428
|
-
return {
|
|
429
|
-
tag: 'collapsible_panel',
|
|
430
|
-
header: {
|
|
431
|
-
title: { tag: 'plain_text', content: `📜 已答 ${answered.size} 题(点击展开)` },
|
|
432
|
-
},
|
|
433
|
-
expanded: false,
|
|
434
|
-
elements: [{ tag: 'markdown', content: lines.join('\n') }],
|
|
435
|
-
}
|
|
436
|
-
}
|
|
437
|
-
|
|
438
|
-
/** Tool-panel renderer for `AskUserQuestion` — the SDK's structured
|
|
439
|
-
* multiple-choice question. Daemon takes over the client-side role:
|
|
440
|
-
* instead of letting the request fall through to the generic JSON
|
|
441
|
-
* dump (or worse, the permission flow that misappropriates it), we
|
|
442
|
-
* render each question with one button per option, callbacks tagged
|
|
443
|
-
* `kind:'ask'` so the Lark handler can route the answer back as a
|
|
444
|
-
* `tool_result`.
|
|
445
|
-
*
|
|
446
|
-
* Single-question is the common case; multi-question gets buttons on
|
|
447
|
-
* the first question only and a text-only listing for the rest (an
|
|
448
|
-
* acceptable limitation — these are rare in practice and we can lift
|
|
449
|
-
* it once the UX is validated). */
|
|
450
|
-
export function askUserQuestionElement(
|
|
451
|
-
i: number,
|
|
452
|
-
toolUseId: string,
|
|
453
|
-
questions: AskQuestion[],
|
|
454
|
-
status: '🤔' | '✅' | '❌' = '🤔',
|
|
455
|
-
state?: AskState,
|
|
456
|
-
): object {
|
|
457
|
-
const total = questions.length
|
|
458
|
-
const answered = state?.answered ?? new Map<number, AskAnswered>()
|
|
459
|
-
const currentIdx = state?.currentIdx
|
|
460
|
-
const isTerminal = currentIdx === undefined && answered.size > 0
|
|
461
|
-
const bodyElements: any[] = []
|
|
462
|
-
let headerText: string
|
|
463
|
-
|
|
464
|
-
if (isTerminal) {
|
|
465
|
-
// All questions resolved — collapse and roll up answers in header
|
|
466
|
-
// + body. Single-question case keeps the old "已回答:xxx" header
|
|
467
|
-
// style; multi-question gets a "已回答 · N 题" count and a flat
|
|
468
|
-
// listing of Q→A pairs in the body.
|
|
469
|
-
if (total === 1) {
|
|
470
|
-
const q0 = questions[0]
|
|
471
|
-
const a0 = answered.get(0)
|
|
472
|
-
const value = a0?.customText
|
|
473
|
-
?? (a0?.optionIdx !== undefined ? q0?.options[a0.optionIdx]?.label : undefined)
|
|
474
|
-
?? '?'
|
|
475
|
-
const headerTag = q0?.header ? ` · ${q0.header}` : ''
|
|
476
|
-
headerText = `${status} 已回答${headerTag}:${value}`
|
|
477
|
-
} else {
|
|
478
|
-
headerText = `${status} 已回答 · ${total} 题`
|
|
479
|
-
}
|
|
480
|
-
const sortedIdx = [...answered.keys()].sort((a, b) => a - b)
|
|
481
|
-
for (const idx of sortedIdx) {
|
|
482
|
-
const q = questions[idx]
|
|
483
|
-
const a = answered.get(idx)!
|
|
484
|
-
const tag = q?.header ?? `Q${idx + 1}`
|
|
485
|
-
const value = a.customText
|
|
486
|
-
?? (a.optionIdx !== undefined ? q?.options[a.optionIdx]?.label : undefined)
|
|
487
|
-
?? '?'
|
|
488
|
-
bodyElements.push({
|
|
489
|
-
tag: 'markdown',
|
|
490
|
-
content: `**${tag}**:${value}`,
|
|
491
|
-
})
|
|
492
|
-
}
|
|
493
|
-
const lastUser = [...answered.values()].reverse().find(a => a.user)?.user
|
|
494
|
-
if (lastUser) {
|
|
495
|
-
bodyElements.push({
|
|
496
|
-
tag: 'markdown',
|
|
497
|
-
content: `\n*— 由 ${lastUser} 回答*`,
|
|
498
|
-
})
|
|
499
|
-
}
|
|
500
|
-
} else if (currentIdx !== undefined && questions[currentIdx]) {
|
|
501
|
-
// In-progress: render current question + folded history above.
|
|
502
|
-
// Progress tag in header lets the user see how many are left,
|
|
503
|
-
// even with the history panel folded.
|
|
504
|
-
const q = questions[currentIdx]
|
|
505
|
-
const headerTag = q.header ? ` · ${q.header}` : ''
|
|
506
|
-
const progress = total > 1 ? ` (${currentIdx + 1}/${total})` : ''
|
|
507
|
-
headerText = `${status} 🤔 AskUserQuestion${headerTag}${progress}`
|
|
508
|
-
const history = renderAskHistoryPanel(questions, answered)
|
|
509
|
-
if (history) bodyElements.push(history)
|
|
510
|
-
bodyElements.push(...renderAskQuestionBody(q, toolUseId, currentIdx))
|
|
511
|
-
bodyElements.push({
|
|
512
|
-
tag: 'markdown',
|
|
513
|
-
content: '_💬 也可以直接在群里回复你的答案(裸词命令 `hi`/`kill`/`restart`/`clear` 仍然优先)_',
|
|
514
|
-
})
|
|
515
|
-
} else {
|
|
516
|
-
// Defensive fallback — neither answered nor a valid currentIdx.
|
|
517
|
-
headerText = `${status} 🤔 AskUserQuestion`
|
|
518
|
-
if (questions[0]) {
|
|
519
|
-
bodyElements.push({ tag: 'markdown', content: `**${questions[0].question}**` })
|
|
520
|
-
}
|
|
521
|
-
}
|
|
522
|
-
|
|
523
|
-
return {
|
|
524
|
-
tag: 'collapsible_panel',
|
|
525
|
-
element_id: ELEMENTS.tool(i),
|
|
526
|
-
header: { title: { tag: 'plain_text', content: headerText } },
|
|
527
|
-
expanded: !isTerminal,
|
|
528
|
-
elements: bodyElements,
|
|
529
|
-
}
|
|
530
|
-
}
|
package/src/cards.ts
DELETED
|
@@ -1,32 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Schema 2.0 Feishu card templates — barrel re-export. Each call site
|
|
3
|
-
* uses `import * as cards from './cards'` and reaches everything through
|
|
4
|
-
* this file. Internal split so each module stays under Claude Code's
|
|
5
|
-
* per-read token budget:
|
|
6
|
-
* - cards/elements.ts — ELEMENTS (shared element-id helpers)
|
|
7
|
-
* - cards/turn.ts — main turn card, tool panels, ask panels, todos
|
|
8
|
-
* - cards/console.ts — console + menu cards, formatters,
|
|
9
|
-
* streamingOffSettings
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
|
-
export { ELEMENTS } from './cards/elements'
|
|
13
|
-
export {
|
|
14
|
-
type Todo,
|
|
15
|
-
type AskQuestion,
|
|
16
|
-
type AskAnswered,
|
|
17
|
-
type AskState,
|
|
18
|
-
summarizeToolInput,
|
|
19
|
-
mainConversationCard,
|
|
20
|
-
assistantSegmentElement,
|
|
21
|
-
thinkingCollapsedPanel,
|
|
22
|
-
toolCallElement,
|
|
23
|
-
readBatchElement,
|
|
24
|
-
toolCallPermissionElement,
|
|
25
|
-
askUserQuestionElement,
|
|
26
|
-
} from './cards/turn'
|
|
27
|
-
export {
|
|
28
|
-
consoleUsageContent,
|
|
29
|
-
consoleCard,
|
|
30
|
-
menuCard,
|
|
31
|
-
streamingOffSettings,
|
|
32
|
-
} from './cards/console'
|