@leviyuan/lodestar 0.1.4 → 0.1.6

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/daemon.ts CHANGED
@@ -108,6 +108,18 @@ async function handleMessage(data: any): Promise<void> {
108
108
  if (await session.runCommand(text)) return
109
109
  }
110
110
 
111
+ // Pending AskUserQuestion: route the message as a custom answer
112
+ // instead of opening a new turn. This is how custom-text answers
113
+ // work in this version — Feishu schema 2.0 doesn't support form/
114
+ // input elements, so the chat box itself is the input. Only applies
115
+ // to text-only messages (an image attachment opens a new turn as
116
+ // usual). Bare-word commands have already been intercepted above.
117
+ if (msgType === 'text' && text && session.hasPendingAsk()) {
118
+ const userId = message.sender?.sender_id?.open_id ?? ''
119
+ await session.onAskMessageAnswer(text, userId)
120
+ return
121
+ }
122
+
111
123
  let filePath: string | undefined
112
124
  if (msgType === 'image' && contentObj.image_key) {
113
125
  filePath = await feishu.downloadAttachment(message.message_id, contentObj.image_key, 'image')
@@ -140,9 +152,20 @@ async function handleCardAction(data: any): Promise<any> {
140
152
  case 'menu':
141
153
  await session.onUserMessage(`(menu choice ${value.choice + 1})`)
142
154
  return { toast: { type: 'success', content: 'OK' } }
143
- case 'ask':
155
+ case 'ask': {
156
+ // Custom-text branch: form submit packages the input under
157
+ // `form_value`. Try a couple of plausible keys since the exact
158
+ // shape can drift between Feishu schema versions; fall back to
159
+ // empty (onAskCustomAnswer ignores blank).
160
+ if (value.custom) {
161
+ const fv = action?.form_value ?? action?.input ?? {}
162
+ const customText: string = fv?.custom_answer ?? action?.input_value ?? ''
163
+ await session.onAskCustomAnswer(value.tool_use_id, value.question_idx ?? 0, customText, userId)
164
+ return { toast: { type: customText.trim() ? 'success' : 'error', content: customText.trim() ? '已回答' : '请输入答案' } }
165
+ }
144
166
  await session.onAskAnswer(value.tool_use_id, value.question_idx ?? 0, value.option_idx, userId)
145
167
  return { toast: { type: 'success', content: '已回答' } }
168
+ }
146
169
  }
147
170
  return { toast: { type: 'info', content: 'unknown action' } }
148
171
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leviyuan/lodestar",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
package/src/cards.ts CHANGED
@@ -332,56 +332,57 @@ export interface AskQuestion {
332
332
  * the first question only and a text-only listing for the rest (an
333
333
  * acceptable limitation — these are rare in practice and we can lift
334
334
  * it once the UX is validated). */
335
+ /** Final-state info for an AskUserQuestion panel. Mutually-exclusive
336
+ * branches inside: either the user picked one of the SDK-provided
337
+ * options (`optionIdx`), or they typed a free-form answer
338
+ * (`customText`). When both are absent the panel is still pending. */
339
+ export interface AskAnswered {
340
+ optionIdx?: number
341
+ customText?: string
342
+ user?: string
343
+ }
344
+
335
345
  export function askUserQuestionElement(
336
346
  i: number,
337
347
  toolUseId: string,
338
348
  questions: AskQuestion[],
339
349
  status: '🤔' | '✅' | '❌' = '🤔',
340
- /** When set, only this option is rendered as "picked" — every other
341
- * option turns into plain text (no click target). Used after the
342
- * user has answered so the panel freezes in a sensible terminal
343
- * state instead of inviting another click. */
344
- pickedOptionIdx?: number,
345
- resolvedNote?: string,
350
+ answered?: AskAnswered,
346
351
  ): object {
347
352
  const primary = questions[0]
353
+ const isAnswered = !!answered && (answered.optionIdx !== undefined || !!answered.customText)
348
354
  const headerTag = primary?.header ? ` · ${primary.header}` : ''
349
- const headerText = `${status} 🤔 AskUserQuestion${headerTag}`
355
+ let headerText: string
356
+ if (isAnswered) {
357
+ // Surface the picked answer in the header so a collapsed panel
358
+ // still tells the reader what was chosen.
359
+ const picked = answered!.customText
360
+ ?? (answered!.optionIdx !== undefined ? primary?.options[answered!.optionIdx]?.label : undefined)
361
+ ?? '?'
362
+ headerText = `${status} 已回答${headerTag}:${picked}`
363
+ } else {
364
+ headerText = `${status} 🤔 AskUserQuestion${headerTag}`
365
+ }
350
366
  const bodyElements: any[] = []
351
367
  if (primary) {
352
368
  bodyElements.push({ tag: 'markdown', content: `**${primary.question}**` })
353
- // One row per option, each a full-width interactive_container so
354
- // the entire row (label + description) is the click target. Looks
355
- // cleaner than a row of squat buttons and matches the way IM
356
- // quick-replies usually present themselves. After the user picks,
357
- // we still render the same row layout (no JSON dump) but strip
358
- // the callbacks — selected option marked ✅, others dimmed.
369
+ // One row per option. While pending, each row is a full-width
370
+ // interactive_container so the entire row is the click target.
371
+ // Once answered, rows degrade to plain markdown (Feishu has no
372
+ // generic non-interactive container `div` doesn't exist, it
373
+ // rejects the PUT with code 300121).
359
374
  for (let optIdx = 0; optIdx < primary.options.length; optIdx++) {
360
375
  const opt = primary.options[optIdx]
361
- const isPicked = pickedOptionIdx === optIdx
362
- const isAnswered = pickedOptionIdx !== undefined
363
- const labelLine = isPicked
364
- ? `**✅ ${opt.label}**`
365
- : isAnswered
366
- ? `~~${opt.label}~~`
367
- : `**${opt.label}**`
368
- const descLine = opt.description ? `\n${opt.description}` : ''
369
- const rowContent = { tag: 'markdown', content: `${labelLine}${descLine}` }
370
- if (isAnswered) {
371
- // Frozen — no behaviors, plain container so it stops looking
372
- // clickable.
373
- bodyElements.push({
374
- tag: 'div',
375
- elements: [rowContent],
376
- })
377
- } else {
376
+ const isPicked = answered?.optionIdx === optIdx
377
+ const desc = opt.description ? ` · ${opt.description}` : ''
378
+ if (!isAnswered) {
378
379
  bodyElements.push({
379
380
  tag: 'interactive_container',
380
381
  background_style: 'default',
381
382
  has_border: true,
382
383
  corner_radius: '6px',
383
384
  padding: '8px 12px',
384
- margin: '4px 0',
385
+ margin: '4px 0px 4px 0px',
385
386
  behaviors: [{
386
387
  type: 'callback',
387
388
  value: {
@@ -391,13 +392,40 @@ export function askUserQuestionElement(
391
392
  option_idx: optIdx,
392
393
  },
393
394
  }],
394
- elements: [rowContent],
395
+ elements: [{ tag: 'markdown', content: `**${opt.label}**${desc}` }],
396
+ })
397
+ } else if (isPicked) {
398
+ bodyElements.push({
399
+ tag: 'markdown',
400
+ content: `✅ **${opt.label}**${desc}`,
401
+ })
402
+ } else {
403
+ bodyElements.push({
404
+ tag: 'markdown',
405
+ content: `~~◯ ${opt.label}${desc}~~`,
395
406
  })
396
407
  }
397
408
  }
409
+ // Custom-answer hint (pending state only). Feishu schema 2.0
410
+ // doesn't actually have `form` / `input` elements — `tag: form`
411
+ // gets rejected with code 300315. Instead we route custom
412
+ // answers through a plain chat message: daemon.handleMessage
413
+ // detects a pending ask and forwards the next inbound text as
414
+ // the answer, no new turn opened. This is also more Feishu-
415
+ // native: the chat input is right there, no extra widget.
416
+ if (!isAnswered) {
417
+ bodyElements.push({
418
+ tag: 'markdown',
419
+ content: '_💬 也可以直接在群里回复你的答案(裸词命令 `hi`/`kill`/`restart`/`clear` 仍然优先)_',
420
+ })
421
+ } else if (answered?.customText) {
422
+ bodyElements.push({
423
+ tag: 'markdown',
424
+ content: `✏️ **自定义回答**:${answered.customText}`,
425
+ })
426
+ }
398
427
  }
399
- // Secondary questions get text-only treatment (TODO: multi-question
400
- // panels when actually requested by a real prompt).
428
+ // Secondary questions (rare): text-only listing. TODO when needed.
401
429
  for (let qi = 1; qi < questions.length; qi++) {
402
430
  const q = questions[qi]
403
431
  const opts = q.options.map(o => ` - ${o.label}${o.description ? ` — ${o.description}` : ''}`).join('\n')
@@ -406,12 +434,19 @@ export function askUserQuestionElement(
406
434
  content: `\n---\n**(其他问题 #${qi + 1}, 暂未支持回答)** ${q.question}\n${opts}`,
407
435
  })
408
436
  }
409
- if (resolvedNote) bodyElements.push({ tag: 'markdown', content: resolvedNote })
437
+ if (isAnswered && answered?.user) {
438
+ bodyElements.push({
439
+ tag: 'markdown',
440
+ content: `\n*— 由 ${answered.user} 回答*`,
441
+ })
442
+ }
410
443
  return {
411
444
  tag: 'collapsible_panel',
412
445
  element_id: ELEMENTS.tool(i),
413
446
  header: { title: { tag: 'plain_text', content: headerText } },
414
- expanded: true,
447
+ // Collapse once answered: header carries the picked answer, body
448
+ // is just history at that point.
449
+ expanded: !isAnswered,
415
450
  elements: bodyElements,
416
451
  }
417
452
  }
package/src/session.ts CHANGED
@@ -79,8 +79,9 @@ export class Session {
79
79
  /** Open AskUserQuestion tool calls — keyed by tool_use_id. The SDK
80
80
  * routes AskUserQuestion through the can_use_tool flow even under
81
81
  * bypass; we have to thread the permission `requestId` through here
82
- * so onAskAnswer can answer it with `updatedInput.answers` populated.
83
- * `deferredAnswer` covers the race where the user clicks a button
82
+ * so the answer (option click OR custom text submit) can resolve
83
+ * the permission with `updatedInput.answers` populated.
84
+ * `deferredAnswer` covers the race where the user clicks/submits
84
85
  * BEFORE can_use_tool arrives (addTool fires on the assistant
85
86
  * message; can_use_tool is a separate control_request that lands
86
87
  * slightly later). */
@@ -88,7 +89,12 @@ export class Session {
88
89
  questions: cards.AskQuestion[]
89
90
  i: number
90
91
  requestId?: string
91
- deferredAnswer?: { questionIdx: number; optionIdx: number; user: string }
92
+ deferredAnswer?: {
93
+ questionIdx: number
94
+ optionIdx?: number
95
+ customText?: string
96
+ user: string
97
+ }
92
98
  }>()
93
99
  private turnCounter = 0
94
100
  // Last seen sessionId — preserved across `kill`/`stop` so a later
@@ -326,43 +332,96 @@ export class Session {
326
332
  }
327
333
  }
328
334
 
329
- /** Click handler for an AskUserQuestion option button. The actual
330
- * "send answer to Claude" plumbing lives in `resolveAsk` this
331
- * wrapper just chooses between answering immediately (the common
332
- * case: can_use_tool has already arrived and parked a requestId on
333
- * the pendingAsk record) versus deferring (the rare race where the
334
- * user clicks before can_use_tool lands). */
335
+ /** True iff there's at least one open AskUserQuestion awaiting an
336
+ * answer in this session. `daemon.handleMessage` uses this to
337
+ * decide whether an inbound chat message should be a custom answer
338
+ * (routed to onAskMessageAnswer) instead of opening a new turn. */
339
+ hasPendingAsk(): boolean {
340
+ return this.pendingAsks.size > 0
341
+ }
342
+
343
+ /** Funnel an arbitrary chat message into the oldest pending ask as
344
+ * a `customText` answer. Falls back to a normal turn if for some
345
+ * reason there's nothing pending (defensive — handleMessage's gate
346
+ * should prevent that). Picks the first entry by Map insertion
347
+ * order, which is the earliest unanswered ask. */
348
+ async onAskMessageAnswer(text: string, user: string): Promise<void> {
349
+ const firstEntry = this.pendingAsks.entries().next()
350
+ if (firstEntry.done) {
351
+ log(`session "${this.sessionName}": onAskMessageAnswer with no pending — falling back to onUserMessage`)
352
+ await this.onUserMessage(text)
353
+ return
354
+ }
355
+ const [toolUseId, _pending] = firstEntry.value
356
+ await this.onAskCustomAnswer(toolUseId, 0, text, user)
357
+ }
358
+
359
+ /** Click handler for an AskUserQuestion option button. Dispatches
360
+ * to `resolveAsk` if can_use_tool has already arrived, otherwise
361
+ * parks the click on the pendingAsk record for renderPermission
362
+ * to drain. */
335
363
  async onAskAnswer(toolUseId: string, questionIdx: number, optionIdx: number, user: string): Promise<void> {
336
364
  const pending = this.pendingAsks.get(toolUseId)
337
365
  if (!pending) { log(`session "${this.sessionName}": stray ask answer for ${toolUseId}`); return }
338
366
  if (pending.requestId) {
339
- this.resolveAsk(toolUseId, pending.requestId, questionIdx, optionIdx, user)
367
+ this.resolveAsk(toolUseId, pending.requestId, { questionIdx, optionIdx, user })
340
368
  } else {
341
- // can_use_tool hasn't landed yet — park the click. renderPermission
342
- // will drain it the moment the request arrives.
343
369
  pending.deferredAnswer = { questionIdx, optionIdx, user }
344
370
  log(`session "${this.sessionName}": ask answer deferred for ${toolUseId} (no requestId yet)`)
345
371
  }
346
372
  }
347
373
 
374
+ /** Form-submit handler for the custom-answer input on an
375
+ * AskUserQuestion panel. Same dispatch pattern as `onAskAnswer`,
376
+ * just routes a free-form string into `resolveAsk` instead of an
377
+ * option index. Empty/whitespace input is ignored (no answer
378
+ * sent, panel stays pending). */
379
+ async onAskCustomAnswer(toolUseId: string, questionIdx: number, customText: string, user: string): Promise<void> {
380
+ const pending = this.pendingAsks.get(toolUseId)
381
+ if (!pending) { log(`session "${this.sessionName}": stray ask custom for ${toolUseId}`); return }
382
+ const trimmed = (customText ?? '').trim()
383
+ if (!trimmed) { log(`session "${this.sessionName}": empty custom answer, ignoring`); return }
384
+ if (pending.requestId) {
385
+ this.resolveAsk(toolUseId, pending.requestId, { questionIdx, customText: trimmed, user })
386
+ } else {
387
+ pending.deferredAnswer = { questionIdx, customText: trimmed, user }
388
+ log(`session "${this.sessionName}": ask custom deferred for ${toolUseId} (no requestId yet)`)
389
+ }
390
+ }
391
+
348
392
  /** Settle an AskUserQuestion: emit the permission allow with the
349
- * picked option folded into `updatedInput.answers` (this is the
350
- * shape the SDK reads to synthesise the tool_result string), repaint
351
- * the panel ✅, drop bookkeeping. Single source of truth — both the
352
- * normal click path and the deferred-answer drain go through here. */
393
+ * picked option OR custom text folded into `updatedInput.answers`
394
+ * (this is the shape the SDK reads to synthesise the tool_result
395
+ * string), repaint the panel ✅, drop bookkeeping. Single source
396
+ * of truth — option-click, custom-submit, and the deferred drain
397
+ * all go through here. */
353
398
  private resolveAsk(
354
399
  toolUseId: string,
355
400
  requestId: string,
356
- questionIdx: number,
357
- optionIdx: number,
358
- user: string,
401
+ answer: { questionIdx: number; optionIdx?: number; customText?: string; user: string },
359
402
  ): void {
360
403
  const pending = this.pendingAsks.get(toolUseId)
361
404
  if (!pending) return
362
- const q = pending.questions[questionIdx]
363
- const opt = q?.options?.[optionIdx]
364
- if (!q || !opt) {
365
- log(`session "${this.sessionName}": ask answer out of range q=${questionIdx} o=${optionIdx}`)
405
+ const q = pending.questions[answer.questionIdx]
406
+ if (!q) {
407
+ log(`session "${this.sessionName}": ask answer out of range q=${answer.questionIdx}`)
408
+ return
409
+ }
410
+ // Determine the literal string that will become the SDK's
411
+ // `answers` value for this question — custom wins if both are
412
+ // somehow set (shouldn't happen, but defensive).
413
+ let answerValue: string
414
+ if (answer.customText) {
415
+ answerValue = answer.customText
416
+ } else if (answer.optionIdx !== undefined) {
417
+ const opt = q.options?.[answer.optionIdx]
418
+ if (!opt) {
419
+ log(`session "${this.sessionName}": ask option out of range o=${answer.optionIdx}`)
420
+ return
421
+ }
422
+ answerValue = opt.label
423
+ } else {
424
+ log(`session "${this.sessionName}": resolveAsk called with neither optionIdx nor customText`)
366
425
  return
367
426
  }
368
427
  const turn = this.currentTurn
@@ -370,9 +429,8 @@ export class Session {
370
429
  const originalInput = meta?.input ?? {}
371
430
  // SDK keys the answer record by the question's text — confirmed
372
431
  // by the v0.1.2 jsonl trace (empty record formatted to "User has
373
- // answered your questions: ."). Populate the key matching this
374
- // question with the chosen option's label.
375
- const answers: Record<string, string> = { [q.question]: opt.label }
432
+ // answered your questions: .").
433
+ const answers: Record<string, string> = { [q.question]: answerValue }
376
434
  this.proc?.sendPermissionResponse(requestId, 'allow', {
377
435
  updatedInput: { ...originalInput, answers },
378
436
  })
@@ -382,8 +440,11 @@ export class Session {
382
440
  if (turn && meta) {
383
441
  meta.output = JSON.stringify({ answers })
384
442
  meta.isError = false
385
- const resolvedNote = `\n\n*已由 ${user || '匿名'} 回答*`
386
- const el = cards.askUserQuestionElement(meta.i, toolUseId, pending.questions, '✅', optionIdx, resolvedNote)
443
+ const el = cards.askUserQuestionElement(meta.i, toolUseId, pending.questions, '', {
444
+ optionIdx: answer.optionIdx,
445
+ customText: answer.customText,
446
+ user: answer.user || '匿名',
447
+ })
387
448
  void cardkit.replaceElement(turn.cardId, cards.ELEMENTS.tool(meta.i), el)
388
449
  }
389
450
 
@@ -747,9 +808,14 @@ export class Session {
747
808
  ask.requestId = req.request_id
748
809
  this.pendingPermissions.set(req.request_id, { toolUseId })
749
810
  if (ask.deferredAnswer) {
750
- const { questionIdx, optionIdx, user } = ask.deferredAnswer
811
+ const d = ask.deferredAnswer
751
812
  ask.deferredAnswer = undefined
752
- this.resolveAsk(toolUseId, req.request_id, questionIdx, optionIdx, user)
813
+ this.resolveAsk(toolUseId, req.request_id, {
814
+ questionIdx: d.questionIdx,
815
+ optionIdx: d.optionIdx,
816
+ customText: d.customText,
817
+ user: d.user,
818
+ })
753
819
  }
754
820
  return
755
821
  }