@leviyuan/lodestar 0.2.6 → 0.2.7
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/package.json +1 -1
- package/src/cardkit.ts +15 -1
- package/src/session.ts +98 -44
package/package.json
CHANGED
package/src/cardkit.ts
CHANGED
|
@@ -107,6 +107,7 @@ async function withReopenOnStreamingClosed(
|
|
|
107
107
|
cardId: string,
|
|
108
108
|
label: string,
|
|
109
109
|
op: () => Promise<void>,
|
|
110
|
+
onFailure?: () => void,
|
|
110
111
|
): Promise<void> {
|
|
111
112
|
try {
|
|
112
113
|
await op()
|
|
@@ -114,6 +115,7 @@ async function withReopenOnStreamingClosed(
|
|
|
114
115
|
} catch (e) {
|
|
115
116
|
if (!isStreamingClosed(e)) {
|
|
116
117
|
log(`cardkit ${label} ${cardId}: ${e}`)
|
|
118
|
+
if (onFailure) onFailure()
|
|
117
119
|
return
|
|
118
120
|
}
|
|
119
121
|
log(`cardkit ${label} ${cardId}: streaming closed (300309) — reopening`)
|
|
@@ -122,12 +124,14 @@ async function withReopenOnStreamingClosed(
|
|
|
122
124
|
await reopenStreaming(cardId)
|
|
123
125
|
} catch (re) {
|
|
124
126
|
log(`cardkit STREAMING_REOPEN_FAILED ${cardId}: ${re}`)
|
|
127
|
+
if (onFailure) onFailure()
|
|
125
128
|
return
|
|
126
129
|
}
|
|
127
130
|
try {
|
|
128
131
|
await op()
|
|
129
132
|
} catch (e2) {
|
|
130
133
|
log(`cardkit ${label} ${cardId} retry-after-reopen: ${e2}`)
|
|
134
|
+
if (onFailure) onFailure()
|
|
131
135
|
}
|
|
132
136
|
}
|
|
133
137
|
|
|
@@ -201,11 +205,20 @@ export async function flush(cardId: string): Promise<void> {
|
|
|
201
205
|
}
|
|
202
206
|
}
|
|
203
207
|
|
|
204
|
-
/** Add a new element to the card body or relative to a sibling.
|
|
208
|
+
/** Add a new element to the card body or relative to a sibling.
|
|
209
|
+
*
|
|
210
|
+
* `onFailure` fires asynchronously (after promise queue settles) if the
|
|
211
|
+
* element was NOT created — either the first attempt failed with a non-
|
|
212
|
+
* 300309 error, or the retry-after-reopen also failed. Use it to invalidate
|
|
213
|
+
* any daemon-side reference to the element you tried to add (e.g. a segment
|
|
214
|
+
* id), so subsequent writes don't keep PUTting content to a phantom element
|
|
215
|
+
* that Feishu will silently reject. Default (no callback) preserves the
|
|
216
|
+
* legacy fire-and-forget swallow behavior. */
|
|
205
217
|
export function addElement(
|
|
206
218
|
cardId: string,
|
|
207
219
|
element: object,
|
|
208
220
|
opts: { type?: 'append' | 'insert_before' | 'insert_after'; targetElementId?: string } = {},
|
|
221
|
+
onFailure?: () => void,
|
|
209
222
|
): Promise<void> {
|
|
210
223
|
const s = state(cardId)
|
|
211
224
|
s.queue = s.queue.then(() => withReopenOnStreamingClosed(
|
|
@@ -220,6 +233,7 @@ export function addElement(
|
|
|
220
233
|
sequence: seq,
|
|
221
234
|
})
|
|
222
235
|
},
|
|
236
|
+
onFailure,
|
|
223
237
|
))
|
|
224
238
|
return s.queue
|
|
225
239
|
}
|
package/src/session.ts
CHANGED
|
@@ -91,11 +91,14 @@ interface LastTurnDelta {
|
|
|
91
91
|
inputTokens: number // input + cache_* (excludes output) — context-window estimate
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
-
/** Cumulative session counters. Reset on full restart (`clear`),
|
|
95
|
-
* across resume
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
94
|
+
/** Cumulative session counters. Reset on full restart (`clear`),
|
|
95
|
+
* preserved across `restart`/resume and daemon-restart so the `hi`
|
|
96
|
+
* panel reflects the user's total spend in this conversation
|
|
97
|
+
* regardless of how many times the underlying ClaudeProcess has been
|
|
98
|
+
* respawned. Resumed conversations start counting from the resume
|
|
99
|
+
* point onward — the SDK doesn't replay historical usage on resume,
|
|
100
|
+
* so a long pre-resume conversation shows up as zero here until the
|
|
101
|
+
* first new turn lands. */
|
|
99
102
|
interface CumStats {
|
|
100
103
|
tokens: number
|
|
101
104
|
costUsd: number
|
|
@@ -294,6 +297,27 @@ export class Session {
|
|
|
294
297
|
return true
|
|
295
298
|
}
|
|
296
299
|
|
|
300
|
+
/** Drop every ⏳ OneSecond reaction this session is currently holding
|
|
301
|
+
* on user chat messages, then empty the two tracking maps. Used by
|
|
302
|
+
* every tear-down path (proc exit, kill, restart) so reactions don't
|
|
303
|
+
* outlive the conversation that placed them — without this, a Claude
|
|
304
|
+
* crash / daemon SIGTERM leaves orphan ⏳ stuck on user messages until
|
|
305
|
+
* Feishu's UI eventually GCs them (which it doesn't, in practice).
|
|
306
|
+
* closeTurnCard has its own release pass (with the slightly-early
|
|
307
|
+
* merged-batch trade-off documented there); this is the catastrophic-
|
|
308
|
+
* exit pass. Direct `deleteReaction` calls are fire-and-forget and
|
|
309
|
+
* swallow their own failures (see feishu.deleteReaction). */
|
|
310
|
+
private releaseAllReactions(): void {
|
|
311
|
+
for (const [msgId, rid] of [
|
|
312
|
+
...this.pendingReactionIds.entries(),
|
|
313
|
+
...this.currentBatchReactionIds.entries(),
|
|
314
|
+
]) {
|
|
315
|
+
if (rid) void feishu.deleteReaction(msgId, rid)
|
|
316
|
+
}
|
|
317
|
+
this.pendingReactionIds = new Map()
|
|
318
|
+
this.currentBatchReactionIds = new Map()
|
|
319
|
+
}
|
|
320
|
+
|
|
297
321
|
async stop(reason = '已终止'): Promise<void> {
|
|
298
322
|
if (!this.proc) {
|
|
299
323
|
this.status = 'stopped'
|
|
@@ -315,8 +339,7 @@ export class Session {
|
|
|
315
339
|
this.pendingUserMessageCount = 0
|
|
316
340
|
this.pendingMidTurnMsgs = []
|
|
317
341
|
this.lastUserOpenId = ''
|
|
318
|
-
this.
|
|
319
|
-
this.currentBatchReactionIds = new Map()
|
|
342
|
+
this.releaseAllReactions()
|
|
320
343
|
this.initCount = 0
|
|
321
344
|
this.openingTurn = false
|
|
322
345
|
this.pendingPermissions.clear()
|
|
@@ -336,8 +359,7 @@ export class Session {
|
|
|
336
359
|
this.pendingUserMessageCount = 0
|
|
337
360
|
this.pendingMidTurnMsgs = []
|
|
338
361
|
this.lastUserOpenId = ''
|
|
339
|
-
this.
|
|
340
|
-
this.currentBatchReactionIds = new Map()
|
|
362
|
+
this.releaseAllReactions()
|
|
341
363
|
this.initCount = 0
|
|
342
364
|
this.openingTurn = false
|
|
343
365
|
this.pendingPermissions.clear()
|
|
@@ -619,25 +641,37 @@ export class Session {
|
|
|
619
641
|
// - first message after spawn (init not yet fired)
|
|
620
642
|
// - bootstrap race (sibling msgs landing before init#1)
|
|
621
643
|
// - solo message after a prior turn has fully closed
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
644
|
+
// Eager-open path: open the card BEFORE feeding SDK, so a card-open
|
|
645
|
+
// failure doesn't strand the daemon with SDK processing a turn we
|
|
646
|
+
// have nowhere to render. `!openingTurn` means no sibling is mid-
|
|
647
|
+
// open; `initCount >= 1` means SDK boot init has fired (otherwise
|
|
648
|
+
// the init handler owns turn opening and we just feed the queue
|
|
649
|
+
// below). On failure openTurnCard surfaces a red banner via
|
|
650
|
+
// sendTextRaw; SDK was idle so no interrupt needed.
|
|
629
651
|
if (!this.openingTurn && this.initCount >= 1) {
|
|
630
|
-
// Eager open: SDK is healthy and idle, open card now. Any extra
|
|
631
|
-
// messages arriving during the open's Feishu API await pile up in
|
|
632
|
-
// the count and the init handler batches them.
|
|
633
652
|
this.openingTurn = true
|
|
634
|
-
this.pendingUserMessageCount--
|
|
635
653
|
try {
|
|
636
654
|
await this.openTurnCard(userOpenId, 'user_message')
|
|
655
|
+
if (!this.currentTurn) return
|
|
656
|
+
this.proc!.sendUserText(wireText, files)
|
|
657
|
+
this.pendingUserMessageCount++
|
|
637
658
|
this.status = 'working'
|
|
638
659
|
} finally {
|
|
639
660
|
this.openingTurn = false
|
|
640
661
|
}
|
|
662
|
+
return
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
// Non-eager path: either init hasn't fired yet (cold start) or a
|
|
666
|
+
// sibling onUserMessage is already opening. Feed SDK directly; the
|
|
667
|
+
// init handler / sibling card-opener will batch this message in.
|
|
668
|
+
this.proc!.sendUserText(wireText, files)
|
|
669
|
+
this.pendingUserMessageCount++
|
|
670
|
+
if (wasBusy && msgId) {
|
|
671
|
+
// Bootstrap race / sibling-opening race: until a card is open,
|
|
672
|
+
// the OneSecond ⏳ is the only ack the user gets. The init handler
|
|
673
|
+
// inherits these via currentBatchReactionIds when it opens.
|
|
674
|
+
trackReaction(msgId)
|
|
641
675
|
}
|
|
642
676
|
}
|
|
643
677
|
|
|
@@ -873,7 +907,19 @@ export class Session {
|
|
|
873
907
|
void (async () => {
|
|
874
908
|
try {
|
|
875
909
|
await this.openTurnCard(userOpenId, isUserBatch ? 'user_message' : 'scheduled')
|
|
876
|
-
this.
|
|
910
|
+
if (!this.currentTurn) {
|
|
911
|
+
// SDK already started this turn (its `init` is what got us
|
|
912
|
+
// here) but we have no card to render into. Interrupt so
|
|
913
|
+
// assistant/tool events aren't silently dropped while the
|
|
914
|
+
// model burns tokens. Release the reactions this batch
|
|
915
|
+
// inherited (init handler moved them above) — otherwise
|
|
916
|
+
// they stay ⏳ forever on the user's chat messages.
|
|
917
|
+
log(`session "${this.sessionName}": init-path openTurnCard failed — sendInterrupt + release reactions`)
|
|
918
|
+
this.proc?.sendInterrupt()
|
|
919
|
+
this.releaseAllReactions()
|
|
920
|
+
} else {
|
|
921
|
+
this.status = 'working'
|
|
922
|
+
}
|
|
877
923
|
} finally {
|
|
878
924
|
this.openingTurn = false
|
|
879
925
|
}
|
|
@@ -923,8 +969,7 @@ export class Session {
|
|
|
923
969
|
this.pendingUserMessageCount = 0
|
|
924
970
|
this.pendingMidTurnMsgs = []
|
|
925
971
|
this.lastUserOpenId = ''
|
|
926
|
-
this.
|
|
927
|
-
this.currentBatchReactionIds = new Map()
|
|
972
|
+
this.releaseAllReactions()
|
|
928
973
|
this.initCount = 0
|
|
929
974
|
this.openingTurn = false
|
|
930
975
|
this.status = 'stopped'
|
|
@@ -1031,11 +1076,10 @@ export class Session {
|
|
|
1031
1076
|
this.chatId,
|
|
1032
1077
|
'❌ 创建对话卡片失败 (Feishu SDK 重试 3 次后仍连不上)。你这条消息没能送到 Claude,请稍后重发。',
|
|
1033
1078
|
)
|
|
1034
|
-
//
|
|
1035
|
-
//
|
|
1036
|
-
//
|
|
1037
|
-
//
|
|
1038
|
-
this.proc?.sendInterrupt()
|
|
1079
|
+
// currentTurn left null as the failure signal. Caller decides
|
|
1080
|
+
// whether to sendInterrupt: onUserMessage's eager-open path
|
|
1081
|
+
// hasn't fed SDK yet so doesn't need to; the init handler has
|
|
1082
|
+
// (SDK started the turn itself) and must.
|
|
1039
1083
|
return
|
|
1040
1084
|
}
|
|
1041
1085
|
let cardId: string
|
|
@@ -1066,32 +1110,42 @@ export class Session {
|
|
|
1066
1110
|
// and mutate `this.currentTurn` underfoot.
|
|
1067
1111
|
private appendAssistant(delta: string): void {
|
|
1068
1112
|
if (!this.currentTurn) return
|
|
1069
|
-
|
|
1113
|
+
const turn = this.currentTurn
|
|
1114
|
+
if (!turn.currentAssistantSegmentId) {
|
|
1070
1115
|
// New assistant segment opens a visual break — any prior Read run
|
|
1071
1116
|
// is now visually separated from future Reads, so close the batch
|
|
1072
1117
|
// window. Future Reads will start a fresh batch at a new i.
|
|
1073
|
-
|
|
1074
|
-
const i =
|
|
1118
|
+
turn.openReadBatchI = null
|
|
1119
|
+
const i = turn.assistantSegmentCount++
|
|
1075
1120
|
const segId = cards.ELEMENTS.assistant(i)
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
void cardkit.addElement(
|
|
1121
|
+
turn.currentAssistantSegmentId = segId
|
|
1122
|
+
turn.currentAssistantText = ''
|
|
1123
|
+
void cardkit.addElement(turn.cardId, cards.assistantSegmentElement(i), {
|
|
1079
1124
|
type: 'insert_before', targetElementId: cards.ELEMENTS.footer,
|
|
1125
|
+
}, () => {
|
|
1126
|
+
// addElement永久失败:reset segmentId 让下次 delta 重新创建
|
|
1127
|
+
// segment,否则后续 streamText 全都 PUT 到不存在的 element,
|
|
1128
|
+
// 整段 assistant text 在用户那看不到。守 segId 不变以防 turn
|
|
1129
|
+
// rotation 或 addTool 已经清掉了它(每次 addElement 闭包带的
|
|
1130
|
+
// 是自己创建那次的 segId,只清自己的)。
|
|
1131
|
+
if (turn.currentAssistantSegmentId === segId) {
|
|
1132
|
+
log(`session "${this.sessionName}": assistant segment ${segId} addElement failed — will retry on next delta`)
|
|
1133
|
+
turn.currentAssistantSegmentId = null
|
|
1134
|
+
turn.currentAssistantText = ''
|
|
1135
|
+
turn.segmentTexts.delete(segId)
|
|
1136
|
+
}
|
|
1080
1137
|
})
|
|
1081
1138
|
}
|
|
1082
|
-
|
|
1083
|
-
const segId =
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
segId,
|
|
1088
|
-
this.currentTurn.currentAssistantText,
|
|
1089
|
-
)
|
|
1139
|
+
turn.currentAssistantText += delta
|
|
1140
|
+
const segId = turn.currentAssistantSegmentId
|
|
1141
|
+
if (!segId) return // addElement 已失败 reset,等下一次 delta 重建
|
|
1142
|
+
turn.segmentTexts.set(segId, turn.currentAssistantText)
|
|
1143
|
+
cardkit.streamTextThrottled(turn.cardId, segId, turn.currentAssistantText)
|
|
1090
1144
|
// Chat-list preview: tail of the latest assistant text. Feishu
|
|
1091
1145
|
// truncates anyway; ~60 chars is what shows on a typical phone
|
|
1092
1146
|
// preview line. patchSummaryThrottled is rate-limited on its own.
|
|
1093
|
-
const tail =
|
|
1094
|
-
cardkit.patchSummaryThrottled(
|
|
1147
|
+
const tail = turn.currentAssistantText.slice(-60)
|
|
1148
|
+
cardkit.patchSummaryThrottled(turn.cardId, tail)
|
|
1095
1149
|
}
|
|
1096
1150
|
|
|
1097
1151
|
private appendThinking(delta: string): void {
|