@leviyuan/lodestar 0.2.8 → 0.2.9

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.
@@ -0,0 +1,233 @@
1
+ /**
2
+ * Tool-tracking helpers split out of session.ts. Free functions taking
3
+ * a Session; fields they touch are package-internal (no `private`
4
+ * modifier on the class side). Cross-file boundary lets the main
5
+ * session.ts stay under Claude Code's per-read token budget.
6
+ */
7
+
8
+ import type { Session } from './session'
9
+ import * as cardkit from './cardkit'
10
+ import * as cards from './cards'
11
+ import * as feishu from './feishu'
12
+
13
+ export function isTaskWorkflow(name: string): boolean {
14
+ return name.startsWith('Task') && name !== 'Task'
15
+ }
16
+
17
+ export function todosArray(s: Session): cards.Todo[] {
18
+ return [...s.currentTodos.values()]
19
+ }
20
+
21
+ export function addTool(s: Session, toolUseId: string, name: string, input: any): void {
22
+ if (!s.currentTurn) return
23
+ // Close current assistant segment (if any) so the tool panel renders
24
+ // AFTER it in card body order. Flush queues the segment's last
25
+ // buffered delta before the tool element is inserted.
26
+ if (s.currentTurn.currentAssistantSegmentId) {
27
+ void cardkit.flush(s.currentTurn.cardId)
28
+ s.currentTurn.currentAssistantSegmentId = null
29
+ s.currentTurn.currentAssistantText = ''
30
+ }
31
+ // Consecutive Read merger: if a Read run is already open, append to
32
+ // its batch and re-render the panel instead of inserting a new one.
33
+ // Any other tool name closes the run (handled below).
34
+ if (name === 'Read' && s.currentTurn.openReadBatchI !== null) {
35
+ const batchI = s.currentTurn.openReadBatchI
36
+ const batch = s.currentTurn.readBatches.get(batchI)!
37
+ const slot = batch.items.length
38
+ batch.items.push({ toolUseId, input, output: null, isError: false })
39
+ s.currentTurn.toolByUseId.set(toolUseId, { i: batchI, name, input, readBatchSlot: slot })
40
+ const el = cards.readBatchElement(batchI, batch.items)
41
+ void cardkit.replaceElement(s.currentTurn.cardId, cards.ELEMENTS.tool(batchI), el)
42
+ return
43
+ }
44
+ if (name !== 'Read') s.currentTurn.openReadBatchI = null
45
+ const i = s.currentTurn.toolCount++
46
+ if (name === 'Read') {
47
+ // First Read of a potential run — render the existing single-tool
48
+ // panel (which keeps the full file-contents dump on completion). If
49
+ // a second Read arrives, completeTool/addTool will switch it to
50
+ // `readBatchElement`.
51
+ s.currentTurn.openReadBatchI = i
52
+ s.currentTurn.readBatches.set(i, {
53
+ items: [{ toolUseId, input, output: null, isError: false }],
54
+ })
55
+ s.currentTurn.toolByUseId.set(toolUseId, { i, name, input, readBatchSlot: 0 })
56
+ const el = cards.toolCallElement(i, name, input, null, '⏳', undefined, undefined)
57
+ void cardkit.addElement(s.currentTurn.cardId, el, {
58
+ type: 'insert_before', targetElementId: cards.ELEMENTS.footer,
59
+ })
60
+ return
61
+ }
62
+ s.currentTurn.toolByUseId.set(toolUseId, { i, name, input })
63
+ // AskUserQuestion is a client-side tool — daemon renders the choice
64
+ // UI in-line and supplies the tool_result itself once the user
65
+ // clicks. Branch BEFORE the generic toolCallElement so we never
66
+ // fall through to a JSON dump or, worse, get clobbered by the
67
+ // permission flow (which would render 🔐 three-button buttons that
68
+ // don't match the actual N options).
69
+ if (name === 'AskUserQuestion') {
70
+ const questions = Array.isArray(input?.questions) ? input.questions as cards.AskQuestion[] : []
71
+ const startIdx = questions.length > 0 ? 0 : undefined
72
+ const answered = new Map<number, cards.AskAnswered>()
73
+ s.pendingAsks.set(toolUseId, {
74
+ questions,
75
+ i,
76
+ answers: {},
77
+ answered,
78
+ currentIdx: startIdx,
79
+ })
80
+ const el = cards.askUserQuestionElement(i, toolUseId, questions, '🤔', {
81
+ currentIdx: startIdx,
82
+ answered,
83
+ })
84
+ void cardkit.addElement(s.currentTurn.cardId, el, {
85
+ type: 'insert_before',
86
+ targetElementId: cards.ELEMENTS.footer,
87
+ })
88
+ // Phone push — user has to come back and answer before Claude can
89
+ // continue. Set summary to the question text so the lock-screen
90
+ // notification preview shows what the user needs to answer.
91
+ if (s.currentTurn.userOpenId && s.currentTurn.messageId) {
92
+ const turn = s.currentTurn
93
+ const q0 = questions[0]?.question?.trim() ?? ''
94
+ const truncated = q0.length > 40 ? q0.slice(0, 40) + '…' : q0
95
+ const summary = questions.length > 1
96
+ ? `❓ 待回答 ${questions.length} 题${truncated ? `: ${truncated}` : ''}`
97
+ : truncated
98
+ ? `❓ ${truncated}`
99
+ : '❓ 等你回答问题'
100
+ void (async () => {
101
+ cardkit.cancelSummary(turn.cardId)
102
+ await cardkit.patchSettings(turn.cardId, { config: { summary: { content: summary } } })
103
+ await feishu.urgentApp(turn.messageId, [turn.userOpenId])
104
+ })()
105
+ }
106
+ return
107
+ }
108
+ // Pending Task* panels still show the *pre-op* todo mirror so users
109
+ // can read the current state immediately, without waiting for the
110
+ // tool to return.
111
+ const todos = isTaskWorkflow(name) ? todosArray(s) : undefined
112
+ const el = cards.toolCallElement(i, name, input, null, '⏳', undefined, todos)
113
+ void cardkit.addElement(s.currentTurn.cardId, el, {
114
+ type: 'insert_before',
115
+ targetElementId: cards.ELEMENTS.footer,
116
+ })
117
+ }
118
+
119
+ export function completeTool(s: Session, toolUseId: string, content: any, isError: boolean): void {
120
+ if (!s.currentTurn) return
121
+ const meta = s.currentTurn.toolByUseId.get(toolUseId)
122
+ if (!meta) return
123
+ const output = typeof content === 'string'
124
+ ? content
125
+ : Array.isArray(content)
126
+ ? content.map((c: any) => c?.text ?? JSON.stringify(c)).join('\n')
127
+ : JSON.stringify(content)
128
+ // Stash on the meta — every Task* op coming after this point may
129
+ // need to re-render this panel with a fresher todo footer, so we
130
+ // can't discard the output after the first paint.
131
+ meta.output = output
132
+ meta.isError = isError
133
+ // AskUserQuestion already had its final panel painted by resolveAsk
134
+ // (✅ + the chosen option marked, others dimmed). The tool_result
135
+ // arriving here is just the SDK's synthesised echo — re-rendering
136
+ // via toolCallElement would clobber the nice option-row layout
137
+ // with a generic JSON dump. Bail out; the panel is done.
138
+ if (meta.name === 'AskUserQuestion') return
139
+ // Read batch path: update this row's status in the shared batch then
140
+ // re-render. Single-item batches keep the original full-output panel
141
+ // (file-contents dump); 2+ items switch to the compact `Read · N 次`
142
+ // listing, which overwrites whatever was last drawn at this i.
143
+ if (meta.name === 'Read' && meta.readBatchSlot != null) {
144
+ const batch = s.currentTurn.readBatches.get(meta.i)
145
+ if (batch) {
146
+ const row = batch.items[meta.readBatchSlot]
147
+ if (row) { row.output = output; row.isError = isError }
148
+ const el = batch.items.length >= 2
149
+ ? cards.readBatchElement(meta.i, batch.items)
150
+ : cards.toolCallElement(meta.i, meta.name, meta.input, output, isError ? '❌' : '✅', meta.resolvedNote, undefined)
151
+ void cardkit.replaceElement(s.currentTurn.cardId, cards.ELEMENTS.tool(meta.i), el)
152
+ }
153
+ return
154
+ }
155
+ // Update the local todo mirror BEFORE rendering so the just-
156
+ // completed panel shows the new state too (e.g. a TaskCreate panel
157
+ // already lists the task it just created).
158
+ if (!isError && isTaskWorkflow(meta.name)) {
159
+ updateTodosFromTask(s, meta.name, meta.input, output)
160
+ }
161
+ const todos = isTaskWorkflow(meta.name) ? todosArray(s) : undefined
162
+ const el = cards.toolCallElement(meta.i, meta.name, meta.input, output, isError ? '❌' : '✅', meta.resolvedNote, todos)
163
+ void cardkit.replaceElement(s.currentTurn.cardId, cards.ELEMENTS.tool(meta.i), el)
164
+ // Cascade the new mirror into every prior Task* panel in this turn
165
+ // so any expanded panel reflects the latest state, not the snapshot
166
+ // captured when that op ran.
167
+ if (!isError && isTaskWorkflow(meta.name)) {
168
+ refreshOtherTaskPanels(s, toolUseId)
169
+ }
170
+ }
171
+
172
+ /** Roll a single Task* op into the local mirror — best-effort. Output
173
+ * parsing is regex-based (the SDK returns plain text like "Task #7
174
+ * created successfully: …"), so unexpected variants are skipped
175
+ * silently rather than blowing up the panel render. */
176
+ export function updateTodosFromTask(s: Session, name: string, input: any, output: string): void {
177
+ switch (name) {
178
+ case 'TaskCreate': {
179
+ const m = output.match(/Task #(\d+) created/)
180
+ if (!m) return
181
+ const id = Number(m[1])
182
+ s.currentTodos.set(id, {
183
+ id,
184
+ subject: input.subject,
185
+ description: input.description,
186
+ activeForm: input.activeForm,
187
+ status: 'pending',
188
+ })
189
+ return
190
+ }
191
+ case 'TaskUpdate': {
192
+ const id = Number(input.taskId)
193
+ if (!Number.isFinite(id)) return
194
+ // status=deleted is the SDK's tombstone — drop from the mirror
195
+ // so the readout doesn't carry it forever. Server still keeps
196
+ // it; the mirror is just for the panel footer.
197
+ if (input.status === 'deleted') { s.currentTodos.delete(id); return }
198
+ const cur = s.currentTodos.get(id) ?? { id, status: 'pending' as const }
199
+ if (input.status) cur.status = input.status
200
+ if (input.subject) cur.subject = input.subject
201
+ if (input.description) cur.description = input.description
202
+ if (input.owner) cur.owner = input.owner
203
+ if (input.activeForm) cur.activeForm = input.activeForm
204
+ s.currentTodos.set(id, cur)
205
+ return
206
+ }
207
+ // TaskList / TaskGet / TaskStop / TaskOutput / TaskDelete:
208
+ // read-only or parse-heavy — skip mirror update. The panel will
209
+ // still render the SDK's textual result below the operation
210
+ // block, which is enough to disambiguate.
211
+ }
212
+ }
213
+
214
+ /** Re-render every Task* panel in the current turn (except the one
215
+ * that just landed — already up-to-date) so they all show the latest
216
+ * todo mirror in their footers. Cheap: ELEMENTS.tool(i) replace is
217
+ * queued through the per-card Promise chain like any other op. */
218
+ export function refreshOtherTaskPanels(s: Session, skipToolUseId: string): void {
219
+ if (!s.currentTurn) return
220
+ const todos = todosArray(s)
221
+ for (const [id, meta] of s.currentTurn.toolByUseId) {
222
+ if (id === skipToolUseId) continue
223
+ if (!isTaskWorkflow(meta.name)) continue
224
+ const status: '⏳' | '✅' | '❌' = meta.output === undefined
225
+ ? '⏳'
226
+ : (meta.isError ? '❌' : '✅')
227
+ const el = cards.toolCallElement(
228
+ meta.i, meta.name, meta.input, meta.output ?? null,
229
+ status, meta.resolvedNote, todos,
230
+ )
231
+ void cardkit.replaceElement(s.currentTurn.cardId, cards.ELEMENTS.tool(meta.i), el)
232
+ }
233
+ }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Shared types split out of session.ts so the main file stays under
3
+ * Claude Code's per-read token budget (~25K). Pure type-only — no
4
+ * runtime imports here. Companion modules: session-tools.ts,
5
+ * session-ask.ts, session-permission.ts.
6
+ */
7
+
8
+ export interface TurnState {
9
+ cardId: string
10
+ /** Feishu message_id of the card — needed for urgent_app push on clean
11
+ * turn close. Kept separate from cardId because cardkit's stream APIs
12
+ * operate on card_id but the urgent_app endpoint takes message_id. */
13
+ messageId: string
14
+ /** open_id of the user who started this turn. Used to scope the
15
+ * urgent_app push so only the initiator gets pinged (in case there
16
+ * are other members in the group). Empty string → skip the ping. */
17
+ userOpenId: string
18
+ /** What kicked off this turn. Only `'user_message'` turns fire the
19
+ * end-of-turn urgent_app push — scheduled / cron / loop wakeups
20
+ * finish on their own time and pinging the user would be noise,
21
+ * not signal. Ask / permission urgents inside the turn still fire
22
+ * regardless (those genuinely need attention even mid-schedule). */
23
+ trigger: 'user_message' | 'scheduled'
24
+ thinkingText: string
25
+ toolCount: number
26
+ /** `output` / `isError` are filled in by completeTool — kept on the
27
+ * meta (instead of being thrown away after the first render) so a
28
+ * later Task* op can re-render every prior Task* panel with the
29
+ * latest todo mirror appended. */
30
+ toolByUseId: Map<string, {
31
+ i: number
32
+ name: string
33
+ input: any
34
+ resolvedNote?: string
35
+ output?: string
36
+ isError?: boolean
37
+ /** Set when this tool is part of a merged Read batch — points to the
38
+ * batch's slot in `readBatches[i].items`. completeTool uses it to
39
+ * update the right row instead of rendering a standalone panel. */
40
+ readBatchSlot?: number
41
+ }>
42
+ /** Consecutive `Read` calls collapse into a single panel rendered by
43
+ * `cards.readBatchElement`. Keyed by element index `i` so completeTool
44
+ * can find the batch after its open-window closed (a non-Read tool or
45
+ * new assistant segment has since arrived).
46
+ *
47
+ * `openReadBatchI` is the i of the batch currently accepting new Reads;
48
+ * null once the run ends. Subsequent Read calls open a fresh batch at a
49
+ * new i. */
50
+ readBatches: Map<number, {
51
+ items: Array<{ toolUseId: string; input: any; output: string | null; isError: boolean }>
52
+ }>
53
+ openReadBatchI: number | null
54
+ assistantSegmentCount: number
55
+ currentAssistantSegmentId: string | null
56
+ currentAssistantText: string
57
+ // Per-assistant-segment cumulative text — used at turn close to strip
58
+ // [[send: /path]] markers and replace each segment with a cleaned
59
+ // version, then post the files as separate Feishu messages.
60
+ segmentTexts: Map<string, string>
61
+ startedAt: number
62
+ }
63
+
64
+ export type Status = 'idle' | 'working' | 'awaiting_permission' | 'starting' | 'stopped'
65
+
66
+ export interface SessionOpts {
67
+ permissionMode?: 'default' | 'acceptEdits' | 'plan' | 'bypassPermissions'
68
+ }
69
+
70
+ /** Per-turn delta extracted from the SDK `result` message — feeds the
71
+ * "上一轮" line in the console panel. */
72
+ export interface LastTurnDelta {
73
+ tokens: number // input + cache_* + output for that turn
74
+ costUsd: number
75
+ durationMs: number
76
+ inputTokens: number // input + cache_* (excludes output) — context-window estimate
77
+ }
78
+
79
+ /** Cumulative session counters. Reset on full restart (`clear`),
80
+ * preserved across `restart`/resume and daemon-restart so the `hi`
81
+ * panel reflects the user's total spend in this conversation
82
+ * regardless of how many times the underlying ClaudeProcess has been
83
+ * respawned. Resumed conversations start counting from the resume
84
+ * point onward — the SDK doesn't replay historical usage on resume,
85
+ * so a long pre-resume conversation shows up as zero here until the
86
+ * first new turn lands. */
87
+ export interface CumStats {
88
+ tokens: number
89
+ costUsd: number
90
+ turns: number
91
+ }