@leviyuan/lodestar 0.2.8 → 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/src/cardkit.ts DELETED
@@ -1,349 +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
- interface SummaryState {
39
- latest: string
40
- lastSent: string
41
- timer: ReturnType<typeof setTimeout> | null
42
- }
43
- const summaryStates = new Map<string, SummaryState>()
44
- const SUMMARY_FLUSH_MS = 1500
45
-
46
- function state(cardId: string): CardState {
47
- let s = cards.get(cardId)
48
- if (!s) {
49
- s = {
50
- sequence: 0,
51
- queue: Promise.resolve(),
52
- buffer: new Map(),
53
- lastSent: new Map(),
54
- flushTimer: null,
55
- }
56
- cards.set(cardId, s)
57
- }
58
- return s
59
- }
60
-
61
- function nextSeq(cardId: string): number {
62
- const s = state(cardId)
63
- s.sequence += 1
64
- return s.sequence
65
- }
66
-
67
- async function call(method: string, path: string, body?: object): Promise<any> {
68
- const token = await getTenantToken()
69
- const res = await fetch(`${BASE}${path}`, {
70
- method,
71
- headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
72
- ...(body ? { body: JSON.stringify(body) } : {}),
73
- })
74
- const json = await res.json() as any
75
- if (json?.code && json.code !== 0) {
76
- const e = new Error(`cardkit ${method} ${path}: code=${json.code} msg=${json.msg}`) as Error & { code: number }
77
- e.code = json.code
78
- throw e
79
- }
80
- return json?.data
81
- }
82
-
83
- function isStreamingClosed(e: unknown): boolean {
84
- if (typeof e !== 'object' || e === null) return false
85
- const code = (e as any).code
86
- // 300309 "streaming mode is closed" — TTL already fired before our write.
87
- // 200850 "card streaming timeout" — TTL fired exactly during our write.
88
- // Both mean the streaming session is gone and a reopen will unstick the card.
89
- return code === 300309 || code === 200850
90
- }
91
-
92
- /** Reopen streaming_mode on a card that Feishu auto-closed after its
93
- * 10-minute streaming TTL (no keepalive, no idle reset — the timer
94
- * starts when streaming is opened and fires regardless of activity).
95
- * Called from inside the per-card queue's catch path, so it allocates
96
- * its own sequence and runs inline without re-enqueueing. */
97
- async function reopenStreaming(cardId: string): Promise<void> {
98
- const seq = nextSeq(cardId)
99
- await call('PATCH', `/cards/${cardId}/settings`, {
100
- settings: JSON.stringify({ config: { streaming_mode: true } }),
101
- sequence: seq,
102
- })
103
- }
104
-
105
- /** Run `op` inside the per-card queue. If it fails with code=300309
106
- * or 200850 (Feishu auto-closed / timed-out streaming after the 10-
107
- * minute TTL), reopen streaming inline and retry `op` exactly once.
108
- * Anything else — other failure, reopen failure, retry failure — is
109
- * logged and swallowed, matching the fire-and-forget contract every
110
- * cardkit op already has at the call sites. */
111
- async function withReopenOnStreamingClosed(
112
- cardId: string,
113
- label: string,
114
- op: () => Promise<void>,
115
- onFailure?: () => void,
116
- ): Promise<void> {
117
- try {
118
- await op()
119
- return
120
- } catch (e) {
121
- if (!isStreamingClosed(e)) {
122
- log(`cardkit ${label} ${cardId}: ${e}`)
123
- if (onFailure) onFailure()
124
- return
125
- }
126
- log(`cardkit ${label} ${cardId}: streaming closed (code=${(e as any).code}) — reopening`)
127
- }
128
- try {
129
- await reopenStreaming(cardId)
130
- } catch (re) {
131
- log(`cardkit STREAMING_REOPEN_FAILED ${cardId}: ${re}`)
132
- if (onFailure) onFailure()
133
- return
134
- }
135
- try {
136
- await op()
137
- } catch (e2) {
138
- log(`cardkit ${label} ${cardId} retry-after-reopen: ${e2}`)
139
- if (onFailure) onFailure()
140
- }
141
- }
142
-
143
- /** Convert a sent interactive message into a card entity. */
144
- export async function convertMessageToCard(messageId: string): Promise<string> {
145
- const data = await call('POST', '/cards/id_convert', { message_id: messageId })
146
- return data.card_id
147
- }
148
-
149
- /** Create a card entity from raw schema-2.0 card JSON. */
150
- export async function createCardEntity(card: object): Promise<string> {
151
- const data = await call('POST', '/cards', {
152
- type: 'card_json',
153
- data: JSON.stringify(card),
154
- })
155
- return data.card_id
156
- }
157
-
158
- /** PUT element content (full text) — triggers typewriter on prefix-match.
159
- *
160
- * NOTE: CardKit rejects empty-string content with code 99992402 ("field
161
- * validation failed"); we drop empty/whitespace-only writes here so callers
162
- * can stream naively without per-call empty checks. */
163
- export function streamText(cardId: string, elementId: string, content: string): Promise<void> {
164
- if (!content || !content.trim()) return Promise.resolve()
165
- const s = state(cardId)
166
- s.queue = s.queue.then(() => withReopenOnStreamingClosed(
167
- cardId,
168
- `streamText ${elementId}`,
169
- async () => {
170
- const seq = nextSeq(cardId)
171
- await call('PUT', `/cards/${cardId}/elements/${elementId}/content`, {
172
- content, sequence: seq,
173
- })
174
- s.lastSent.set(elementId, content)
175
- },
176
- ))
177
- return s.queue
178
- }
179
-
180
- /** Throttled streaming: buffer + auto-flush every FLUSH_INTERVAL_MS or
181
- * when the buffered delta crosses FLUSH_MIN_DELTA characters. */
182
- export function streamTextThrottled(cardId: string, elementId: string, fullContent: string): void {
183
- if (!fullContent || !fullContent.trim()) return
184
- const s = state(cardId)
185
- s.buffer.set(elementId, fullContent)
186
-
187
- const last = s.lastSent.get(elementId) ?? ''
188
- const delta = fullContent.length - last.length
189
- if (delta >= FLUSH_MIN_DELTA) {
190
- flush(cardId).catch(e => log(`cardkit flush(min-delta) ${cardId}: ${e}`))
191
- return
192
- }
193
- if (!s.flushTimer) {
194
- s.flushTimer = setTimeout(() => {
195
- flush(cardId).catch(e => log(`cardkit flush(timer) ${cardId}: ${e}`))
196
- }, FLUSH_INTERVAL_MS)
197
- }
198
- }
199
-
200
- /** Force an immediate flush of the buffered streams for a card. */
201
- export async function flush(cardId: string): Promise<void> {
202
- const s = cards.get(cardId)
203
- if (!s) return
204
- if (s.flushTimer) { clearTimeout(s.flushTimer); s.flushTimer = null }
205
- const pending = [...s.buffer.entries()]
206
- s.buffer.clear()
207
- for (const [eid, text] of pending) {
208
- if (s.lastSent.get(eid) === text) continue
209
- await streamText(cardId, eid, text)
210
- }
211
- }
212
-
213
- /** Add a new element to the card body or relative to a sibling.
214
- *
215
- * `onFailure` fires asynchronously (after promise queue settles) if the
216
- * element was NOT created — either the first attempt failed with a non-
217
- * 300309 error, or the retry-after-reopen also failed. Use it to invalidate
218
- * any daemon-side reference to the element you tried to add (e.g. a segment
219
- * id), so subsequent writes don't keep PUTting content to a phantom element
220
- * that Feishu will silently reject. Default (no callback) preserves the
221
- * legacy fire-and-forget swallow behavior. */
222
- export function addElement(
223
- cardId: string,
224
- element: object,
225
- opts: { type?: 'append' | 'insert_before' | 'insert_after'; targetElementId?: string } = {},
226
- onFailure?: () => void,
227
- ): Promise<void> {
228
- const s = state(cardId)
229
- s.queue = s.queue.then(() => withReopenOnStreamingClosed(
230
- cardId,
231
- `addElement`,
232
- async () => {
233
- const seq = nextSeq(cardId)
234
- await call('POST', `/cards/${cardId}/elements`, {
235
- type: opts.type ?? 'append',
236
- ...(opts.targetElementId ? { target_element_id: opts.targetElementId } : {}),
237
- elements: JSON.stringify([element]),
238
- sequence: seq,
239
- })
240
- },
241
- onFailure,
242
- ))
243
- return s.queue
244
- }
245
-
246
- /** Replace an entire element (used to swap a tool placeholder with its result). */
247
- export function replaceElement(cardId: string, elementId: string, element: object): Promise<void> {
248
- const s = state(cardId)
249
- s.queue = s.queue.then(() => withReopenOnStreamingClosed(
250
- cardId,
251
- `replaceElement ${elementId}`,
252
- async () => {
253
- const seq = nextSeq(cardId)
254
- await call('PUT', `/cards/${cardId}/elements/${elementId}`, {
255
- element: JSON.stringify(element),
256
- sequence: seq,
257
- })
258
- },
259
- ))
260
- return s.queue
261
- }
262
-
263
- /** Delete an element by id. */
264
- export function deleteElement(cardId: string, elementId: string): Promise<void> {
265
- const s = state(cardId)
266
- s.queue = s.queue.then(() => withReopenOnStreamingClosed(
267
- cardId,
268
- `deleteElement ${elementId}`,
269
- async () => {
270
- const seq = nextSeq(cardId)
271
- await call('DELETE', `/cards/${cardId}/elements/${elementId}`, {
272
- sequence: seq,
273
- })
274
- },
275
- ))
276
- return s.queue
277
- }
278
-
279
- /** Throttled card-summary update. The summary text is what Feishu shows
280
- * in the chat list as the message preview. We coalesce writes on a
281
- * SUMMARY_FLUSH_MS window so streaming assistant deltas don't blow up
282
- * the settings-PATCH endpoint. Whitespace is collapsed and the input
283
- * is trimmed; empty content is ignored. */
284
- export function patchSummaryThrottled(cardId: string, content: string): void {
285
- const trimmed = (content ?? '').replace(/\s+/g, ' ').trim()
286
- if (!trimmed) return
287
- let s = summaryStates.get(cardId)
288
- if (!s) {
289
- s = { latest: trimmed, lastSent: '', timer: null }
290
- summaryStates.set(cardId, s)
291
- } else {
292
- s.latest = trimmed
293
- }
294
- if (s.timer) return
295
- s.timer = setTimeout(() => {
296
- const st = summaryStates.get(cardId)
297
- if (!st) return
298
- st.timer = null
299
- if (st.latest === st.lastSent) return
300
- const toSend = st.latest
301
- st.lastSent = toSend
302
- void patchSettings(cardId, { config: { summary: { content: toSend } } })
303
- }, SUMMARY_FLUSH_MS)
304
- }
305
-
306
- /** Cancel any pending throttled summary write. Call before emitting
307
- * a terminal summary (e.g. "✅ ⏱ 12.3s · 4.2K tokens") so a stale
308
- * mid-stream tail can't fire after and clobber the final preview. */
309
- export function cancelSummary(cardId: string): void {
310
- const s = summaryStates.get(cardId)
311
- if (!s) return
312
- if (s.timer) { clearTimeout(s.timer); s.timer = null }
313
- summaryStates.delete(cardId)
314
- }
315
-
316
- /** Patch settings — used to flip streaming_mode off when a turn finishes.
317
- *
318
- * `nextSeq` is called inside the queued task (not at enqueue time) to
319
- * match streamText/addElement/replaceElement/deleteElement above. Mixing
320
- * call-time and execution-time seq allocation interleaves badly: a
321
- * patchSettings enqueued right after a replaceElement would grab the
322
- * smaller seq number, but the replaceElement's then-block would grab
323
- * the larger one when it ran first, so the patchSettings PATCH lands
324
- * with a stale seq and Feishu rejects 300317 "sequence number compare
325
- * failed". Keeping all writes on execution-time allocation makes the
326
- * seq order match the queue order. */
327
- export function patchSettings(cardId: string, settings: object): Promise<void> {
328
- const s = state(cardId)
329
- s.queue = s.queue.then(async () => {
330
- try {
331
- const seq = nextSeq(cardId)
332
- await call('PATCH', `/cards/${cardId}/settings`, {
333
- settings: JSON.stringify(settings),
334
- sequence: seq,
335
- })
336
- } catch (e) { log(`cardkit patchSettings ${cardId}: ${e}`) }
337
- })
338
- return s.queue
339
- }
340
-
341
- /** Drop in-memory bookkeeping for a finished card. */
342
- export async function dispose(cardId: string): Promise<void> {
343
- const s = cards.get(cardId)
344
- if (!s) return
345
- await flush(cardId)
346
- await s.queue
347
- cards.delete(cardId)
348
- cancelSummary(cardId)
349
- }