@leviyuan/lodestar 0.1.0 → 2.0.14

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/src/cardkit.ts DELETED
@@ -1,215 +0,0 @@
1
- /**
2
- * Feishu Card Kit v1 wrapper.
3
- *
4
- * Endpoints used (base = https://open.feishu.cn/open-apis/cardkit/v1):
5
- * POST /cards/id_convert message_id → card_id
6
- * POST /cards create a card entity
7
- * PUT /cards/:card_id/elements/:element_id/content stream text (typewriter)
8
- * POST /cards/:card_id/elements add element
9
- * PUT /cards/:card_id/elements/:element_id replace element
10
- * DELETE /cards/:card_id/elements/:element_id remove element
11
- * PATCH /cards/:card_id/settings toggle streaming_mode etc.
12
- *
13
- * Per-card invariants enforced here:
14
- * - `sequence` is monotonically increasing per card_id
15
- * - all writes for a card are serialized through a Promise queue
16
- * - text-streaming PUTs are batched on a 120ms / 32-char heuristic to
17
- * stay well under cardkit's per-card rate ceiling
18
- */
19
-
20
- import { getTenantToken } from './feishu'
21
- import { log } from './log'
22
-
23
- const BASE = 'https://open.feishu.cn/open-apis/cardkit/v1'
24
-
25
- const FLUSH_INTERVAL_MS = 120
26
- const FLUSH_MIN_DELTA = 32
27
-
28
- interface CardState {
29
- sequence: number
30
- queue: Promise<void>
31
- buffer: Map<string, string> // element_id → latest full text
32
- lastSent: Map<string, string> // element_id → text last actually PUT
33
- flushTimer: ReturnType<typeof setTimeout> | null
34
- }
35
-
36
- const cards = new Map<string, CardState>()
37
-
38
- function state(cardId: string): CardState {
39
- let s = cards.get(cardId)
40
- if (!s) {
41
- s = {
42
- sequence: 0,
43
- queue: Promise.resolve(),
44
- buffer: new Map(),
45
- lastSent: new Map(),
46
- flushTimer: null,
47
- }
48
- cards.set(cardId, s)
49
- }
50
- return s
51
- }
52
-
53
- function nextSeq(cardId: string): number {
54
- const s = state(cardId)
55
- s.sequence += 1
56
- return s.sequence
57
- }
58
-
59
- async function call(method: string, path: string, body?: object): Promise<any> {
60
- const token = await getTenantToken()
61
- const res = await fetch(`${BASE}${path}`, {
62
- method,
63
- headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
64
- ...(body ? { body: JSON.stringify(body) } : {}),
65
- })
66
- const json = await res.json() as any
67
- if (json?.code && json.code !== 0) {
68
- throw new Error(`cardkit ${method} ${path}: code=${json.code} msg=${json.msg}`)
69
- }
70
- return json?.data
71
- }
72
-
73
- /** Convert a sent interactive message into a card entity. */
74
- export async function convertMessageToCard(messageId: string): Promise<string> {
75
- const data = await call('POST', '/cards/id_convert', { message_id: messageId })
76
- return data.card_id
77
- }
78
-
79
- /** Create a card entity from raw schema-2.0 card JSON. */
80
- export async function createCardEntity(card: object): Promise<string> {
81
- const data = await call('POST', '/cards', {
82
- type: 'card_json',
83
- data: JSON.stringify(card),
84
- })
85
- return data.card_id
86
- }
87
-
88
- /** PUT element content (full text) — triggers typewriter on prefix-match.
89
- *
90
- * NOTE: CardKit rejects empty-string content with code 99992402 ("field
91
- * validation failed"); we drop empty/whitespace-only writes here so callers
92
- * can stream naively without per-call empty checks. */
93
- export function streamText(cardId: string, elementId: string, content: string): Promise<void> {
94
- if (!content || !content.trim()) return Promise.resolve()
95
- const s = state(cardId)
96
- const seq = nextSeq(cardId)
97
- s.queue = s.queue.then(async () => {
98
- try {
99
- await call('PUT', `/cards/${cardId}/elements/${elementId}/content`, {
100
- content, sequence: seq,
101
- })
102
- s.lastSent.set(elementId, content)
103
- } catch (e) {
104
- log(`cardkit streamText ${cardId}/${elementId}: ${e}`)
105
- }
106
- })
107
- return s.queue
108
- }
109
-
110
- /** Throttled streaming: buffer + auto-flush every FLUSH_INTERVAL_MS or
111
- * when the buffered delta crosses FLUSH_MIN_DELTA characters. */
112
- export function streamTextThrottled(cardId: string, elementId: string, fullContent: string): void {
113
- if (!fullContent || !fullContent.trim()) return
114
- const s = state(cardId)
115
- s.buffer.set(elementId, fullContent)
116
-
117
- const last = s.lastSent.get(elementId) ?? ''
118
- const delta = fullContent.length - last.length
119
- if (delta >= FLUSH_MIN_DELTA) {
120
- flush(cardId).catch(e => log(`cardkit flush(min-delta) ${cardId}: ${e}`))
121
- return
122
- }
123
- if (!s.flushTimer) {
124
- s.flushTimer = setTimeout(() => {
125
- flush(cardId).catch(e => log(`cardkit flush(timer) ${cardId}: ${e}`))
126
- }, FLUSH_INTERVAL_MS)
127
- }
128
- }
129
-
130
- /** Force an immediate flush of the buffered streams for a card. */
131
- export async function flush(cardId: string): Promise<void> {
132
- const s = cards.get(cardId)
133
- if (!s) return
134
- if (s.flushTimer) { clearTimeout(s.flushTimer); s.flushTimer = null }
135
- const pending = [...s.buffer.entries()]
136
- s.buffer.clear()
137
- for (const [eid, text] of pending) {
138
- if (s.lastSent.get(eid) === text) continue
139
- await streamText(cardId, eid, text)
140
- }
141
- }
142
-
143
- /** Add a new element to the card body or relative to a sibling. */
144
- export function addElement(
145
- cardId: string,
146
- element: object,
147
- opts: { type?: 'append' | 'insert_before' | 'insert_after'; targetElementId?: string } = {},
148
- ): Promise<void> {
149
- const s = state(cardId)
150
- const seq = nextSeq(cardId)
151
- s.queue = s.queue.then(async () => {
152
- try {
153
- await call('POST', `/cards/${cardId}/elements`, {
154
- type: opts.type ?? 'append',
155
- ...(opts.targetElementId ? { target_element_id: opts.targetElementId } : {}),
156
- elements: JSON.stringify([element]),
157
- sequence: seq,
158
- })
159
- } catch (e) { log(`cardkit addElement ${cardId}: ${e}`) }
160
- })
161
- return s.queue
162
- }
163
-
164
- /** Replace an entire element (used to swap a tool placeholder with its result). */
165
- export function replaceElement(cardId: string, elementId: string, element: object): Promise<void> {
166
- const s = state(cardId)
167
- const seq = nextSeq(cardId)
168
- s.queue = s.queue.then(async () => {
169
- try {
170
- await call('PUT', `/cards/${cardId}/elements/${elementId}`, {
171
- element: JSON.stringify(element),
172
- sequence: seq,
173
- })
174
- } catch (e) { log(`cardkit replaceElement ${cardId}/${elementId}: ${e}`) }
175
- })
176
- return s.queue
177
- }
178
-
179
- /** Delete an element by id. */
180
- export function deleteElement(cardId: string, elementId: string): Promise<void> {
181
- const s = state(cardId)
182
- const seq = nextSeq(cardId)
183
- s.queue = s.queue.then(async () => {
184
- try {
185
- await call('DELETE', `/cards/${cardId}/elements/${elementId}`, {
186
- sequence: seq,
187
- })
188
- } catch (e) { log(`cardkit deleteElement ${cardId}/${elementId}: ${e}`) }
189
- })
190
- return s.queue
191
- }
192
-
193
- /** Patch settings — used to flip streaming_mode off when a turn finishes. */
194
- export function patchSettings(cardId: string, settings: object): Promise<void> {
195
- const s = state(cardId)
196
- const seq = nextSeq(cardId)
197
- s.queue = s.queue.then(async () => {
198
- try {
199
- await call('PATCH', `/cards/${cardId}/settings`, {
200
- settings: JSON.stringify(settings),
201
- sequence: seq,
202
- })
203
- } catch (e) { log(`cardkit patchSettings ${cardId}: ${e}`) }
204
- })
205
- return s.queue
206
- }
207
-
208
- /** Drop in-memory bookkeeping for a finished card. */
209
- export async function dispose(cardId: string): Promise<void> {
210
- const s = cards.get(cardId)
211
- if (!s) return
212
- await flush(cardId)
213
- await s.queue
214
- cards.delete(cardId)
215
- }
package/src/cards.ts DELETED
@@ -1,304 +0,0 @@
1
- /**
2
- * Schema 2.0 Feishu card templates.
3
- *
4
- * Element-id convention (must be unique within a card):
5
- * user_input — the collapsible "你说" panel
6
- * thinking — the de-emphasized thinking stream
7
- * tool_<i> — one collapsible per tool call, indexed from 0
8
- * assistant — the main streaming assistant answer
9
- * footer — runtime footer (timing / status)
10
- */
11
-
12
- export const ELEMENTS = {
13
- thinking: 'thinking',
14
- footer: 'footer',
15
- tool: (i: number) => `tool_${i}`,
16
- /** Assistant text is segmented: every tool call closes the running segment
17
- * and the next assistant chunk opens a new one, so element order in the
18
- * card matches Claude's emission order. */
19
- assistant: (i: number) => `assistant_${i}`,
20
- } as const
21
-
22
- /** Single-line summary used as a collapsible-panel header for a tool call. */
23
- export function summarizeToolInput(name: string, input: any): string {
24
- if (!input || typeof input !== 'object') return ''
25
- const truncate = (s: string, n: number) => s.length > n ? s.slice(0, n) + '…' : s
26
- switch (name) {
27
- case 'Bash': return truncate(String(input.command ?? ''), 80)
28
- case 'Read':
29
- case 'Write':
30
- case 'Edit':
31
- case 'NotebookEdit': return truncate(String(input.file_path ?? ''), 80)
32
- case 'Glob': return truncate(String(input.pattern ?? ''), 80)
33
- case 'Grep': return truncate(`${input.pattern ?? ''}${input.path ? ' in ' + input.path : ''}`, 80)
34
- case 'WebFetch':
35
- case 'WebSearch': return truncate(String(input.url ?? input.query ?? ''), 80)
36
- case 'Agent':
37
- case 'Task': return truncate(String(input.description ?? input.subject ?? ''), 80)
38
- case 'Skill': return truncate(String(input.skill ?? ''), 80)
39
- }
40
- // generic fallback: first string-valued field
41
- for (const v of Object.values(input)) {
42
- if (typeof v === 'string' && v) return truncate(v, 80)
43
- }
44
- return ''
45
- }
46
-
47
- interface MainCardOpts {
48
- sessionName: string
49
- turn: number
50
- model?: string
51
- effort?: string
52
- userText: string
53
- }
54
-
55
- /** Initial card sent at the start of each turn. Streaming on. */
56
- export function mainConversationCard(_opts: MainCardOpts): object {
57
- return {
58
- schema: '2.0',
59
- config: {
60
- streaming_mode: true,
61
- summary: { content: '[Lodestar 正在生成…]' },
62
- streaming_config: {
63
- print_frequency_ms: { default: 60, android: 60, ios: 60, pc: 30 },
64
- print_step: { default: 2, android: 2, ios: 2, pc: 4 },
65
- print_strategy: 'fast',
66
- },
67
- },
68
- body: {
69
- // Initial body has just thinking + footer; assistant segments and tool
70
- // panels are inserted between them in real time as Claude streams.
71
- // Note: empty-string content is rejected by CardKit PUT so the
72
- // thinking element starts with a single space placeholder; the first
73
- // real append overwrites it.
74
- elements: [
75
- { tag: 'markdown', element_id: ELEMENTS.thinking, content: ' ' },
76
- { tag: 'markdown', element_id: ELEMENTS.footer, content: '⏳ working…' },
77
- ],
78
- },
79
- }
80
- }
81
-
82
- /** Empty assistant segment to be inserted just before the footer. */
83
- export function assistantSegmentElement(i: number): object {
84
- return { tag: 'markdown', element_id: ELEMENTS.assistant(i), content: ' ' }
85
- }
86
-
87
- /** Final state for the thinking section once a turn closes — collapse the
88
- * full thinking text into a panel so the card stays clean. Replaces the
89
- * top-level `thinking` markdown element via PUT /elements/:id. */
90
- export function thinkingCollapsedPanel(fullText: string): object {
91
- const trimmed = fullText.trim()
92
- return {
93
- tag: 'collapsible_panel',
94
- element_id: ELEMENTS.thinking,
95
- header: { title: { tag: 'plain_text', content: `💭 思考过程 (${trimmed.length} 字)` } },
96
- expanded: false,
97
- elements: [
98
- { tag: 'markdown', content: trimmed.slice(0, 8000) || '_(空)_' },
99
- ],
100
- }
101
- }
102
-
103
- /** Element to insert for each tool call. expandable for big results.
104
- *
105
- * Header is a one-line summary: status + name + summarized input.
106
- * Body holds the full input + (after completion) the full output. */
107
- export function toolCallElement(
108
- i: number,
109
- name: string,
110
- input: any,
111
- output: string | null,
112
- status: '⏳' | '✅' | '❌' = '⏳',
113
- ): object {
114
- const summary = summarizeToolInput(name, input)
115
- const headerText = summary
116
- ? `${status} 🔧 ${name}: ${summary}`
117
- : `${status} 🔧 ${name}`
118
- const inputBlock = '```\n' + JSON.stringify(input ?? {}, null, 2).slice(0, 2000) + '\n```'
119
- const outputBlock = output != null
120
- ? '\n---\n**output:**\n```\n' + output.slice(0, 3000) + '\n```'
121
- : ''
122
- return {
123
- tag: 'collapsible_panel',
124
- element_id: ELEMENTS.tool(i),
125
- header: { title: { tag: 'plain_text', content: headerText } },
126
- expanded: false,
127
- elements: [
128
- { tag: 'markdown', content: inputBlock + outputBlock },
129
- ],
130
- }
131
- }
132
-
133
- interface PermissionOpts {
134
- sessionName: string
135
- toolName: string
136
- description: string
137
- inputPreview: string
138
- requestId: string
139
- }
140
-
141
- export function permissionCard(opts: PermissionOpts): object {
142
- const { sessionName, toolName, description, inputPreview, requestId } = opts
143
- let pretty = inputPreview
144
- try { pretty = JSON.stringify(JSON.parse(inputPreview), null, 2) } catch {}
145
- return {
146
- schema: '2.0',
147
- config: { update_multi: true },
148
- header: {
149
- title: { tag: 'plain_text', content: `🔐 权限请求 · ${toolName}` },
150
- subtitle: { tag: 'plain_text', content: sessionName },
151
- template: 'orange',
152
- },
153
- body: {
154
- elements: [
155
- { tag: 'markdown', content: description },
156
- { tag: 'markdown', content: '```\n' + pretty.slice(0, 2000) + '\n```' },
157
- {
158
- tag: 'column_set',
159
- columns: [
160
- permissionButtonColumn('✅ 允许', 'primary', requestId, 'allow'),
161
- permissionButtonColumn('♾️ 始终允许', 'default', requestId, 'allow_always'),
162
- permissionButtonColumn('❌ 拒绝', 'danger', requestId, 'deny'),
163
- ],
164
- },
165
- ],
166
- },
167
- }
168
- }
169
-
170
- function permissionButtonColumn(label: string, type: string, requestId: string, decision: string): object {
171
- return {
172
- tag: 'column', width: 'weighted', weight: 1,
173
- elements: [{
174
- tag: 'button',
175
- text: { tag: 'plain_text', content: label },
176
- type,
177
- behaviors: [{ type: 'callback', value: { kind: 'permission', request_id: requestId, decision } }],
178
- }],
179
- }
180
- }
181
-
182
- export function permissionResolvedCard(
183
- toolName: string,
184
- decision: 'allow' | 'allow_always' | 'deny',
185
- user: string,
186
- ): object {
187
- const ok = decision !== 'deny'
188
- const label = decision === 'allow_always' ? '始终允许' : decision === 'allow' ? '已允许' : '已拒绝'
189
- return {
190
- schema: '2.0',
191
- config: { update_multi: true },
192
- header: {
193
- title: { tag: 'plain_text', content: `🔐 权限请求 · ${toolName}` },
194
- template: ok ? 'green' : 'red',
195
- },
196
- body: {
197
- elements: [{
198
- tag: 'markdown',
199
- content: `${ok ? '✅' : '❌'} **${label}** by ${user || '匿名'} · ${new Date().toLocaleTimeString('zh-CN', { hour12: false })}`,
200
- }],
201
- },
202
- }
203
- }
204
-
205
- interface ConsoleOpts {
206
- sessionName: string
207
- status: 'idle' | 'working' | 'awaiting_permission' | 'starting' | 'stopped'
208
- model?: string
209
- effort?: string
210
- uptime?: string
211
- lastActivity?: string
212
- hasSession: boolean
213
- }
214
-
215
- export function consoleCard(opts: ConsoleOpts): object {
216
- const { sessionName, status, model, effort, uptime, lastActivity, hasSession } = opts
217
- const statusEmoji = {
218
- idle: '🟢 闲', working: '⚙️ 工作中', awaiting_permission: '🔐 等审批',
219
- starting: '🚀 启动中', stopped: '⚪ 未运行',
220
- }[status]
221
- const meta = [
222
- `状态: ${statusEmoji}`,
223
- model ? `模型: ${model}${effort ? `/${effort}` : ''}` : null,
224
- uptime ? `运行: ${uptime}` : null,
225
- lastActivity ? `最近: ${lastActivity}` : null,
226
- ].filter(Boolean).join(' · ')
227
-
228
- const buttons: [string, string, string][] = hasSession
229
- ? [
230
- ['⏸ 中断', 'interrupt', 'default'],
231
- ['🧹 /clear', 'clear', 'default'],
232
- ['⏹ 终止', 'stop', 'danger'],
233
- ['📁 ls', 'ls', 'default'],
234
- ]
235
- : [
236
- ['🚀 启动', 'start', 'primary'],
237
- ['🔁 续聊', 'resume', 'default'],
238
- ['📁 ls', 'ls', 'default'],
239
- ]
240
-
241
- const template = status === 'working' ? 'blue'
242
- : status === 'awaiting_permission' ? 'orange'
243
- : status === 'stopped' ? 'grey'
244
- : 'green'
245
-
246
- return {
247
- schema: '2.0',
248
- config: { update_multi: true },
249
- header: {
250
- title: { tag: 'plain_text', content: `🌟 Lodestar · ${sessionName}` },
251
- template,
252
- },
253
- body: {
254
- elements: [
255
- { tag: 'markdown', content: meta || '_(no state)_' },
256
- {
257
- tag: 'column_set',
258
- columns: buttons.map(([label, action, kind]) => ({
259
- tag: 'column', width: 'weighted', weight: 1,
260
- elements: [{
261
- tag: 'button',
262
- text: { tag: 'plain_text', content: label },
263
- type: kind,
264
- behaviors: [{ type: 'callback', value: { kind: 'console', action } }],
265
- }],
266
- })),
267
- },
268
- ],
269
- },
270
- }
271
- }
272
-
273
- interface MenuOpts {
274
- question: string
275
- options: string[]
276
- requestId: string
277
- }
278
-
279
- export function menuCard(opts: MenuOpts): object {
280
- const { question, options, requestId } = opts
281
- return {
282
- schema: '2.0',
283
- config: { update_multi: true },
284
- header: {
285
- title: { tag: 'plain_text', content: '📋 等待选择' },
286
- template: 'turquoise',
287
- },
288
- body: {
289
- elements: [
290
- { tag: 'markdown', content: question || '_请选择一项:_' },
291
- ...options.map((opt, i) => ({
292
- tag: 'button',
293
- text: { tag: 'plain_text', content: opt },
294
- type: 'default',
295
- behaviors: [{ type: 'callback', value: { kind: 'menu', request_id: requestId, choice: i } }],
296
- })),
297
- ],
298
- },
299
- }
300
- }
301
-
302
- export const STREAMING_OFF_SETTINGS = {
303
- config: { streaming_mode: false, summary: { content: '✅ Lodestar 完成' } },
304
- }