@leviyuan/lodestar 0.1.11 → 0.2.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/README.md CHANGED
@@ -16,16 +16,24 @@ AI 不是帮手,是倍率。它放大的不是体力,是你——你的直觉、
16
16
 
17
17
  ## 你会得到什么
18
18
 
19
- - 🌊 **真·流式卡片** — 飞书 Card Kit v1 streaming,Claude 一个 token 一个 token 地打在同一张卡片里,不是发一堆零碎消息刷屏。
20
- - 🧠 **思考过程透明** `thinking` 流式渲染,turn 结束后自动收起为可展开面板。每次工具调用也是一格折叠面板:折起是概述,展开看完整 input/output。
21
- - 🔐 **权限审批就地完成** — 需要授权的工具调用,**原地**升级为 🔐 等审批状态,三颗按钮 `允许 / 始终允许 / 拒绝` 直接嵌在面板里。不弹独立卡片,不破坏时序。点完按钮,后续 output 接在同一条线上继续往下走。
22
- - **结构化追问** — Claude 的 `AskUserQuestion` 在群里呈现为可点击选项行;不满意?直接在群里**打字回答**,daemon 会把自由文本当作 custom answer 发回去。多题串行,有进度计数和"已答 N 题"折叠历史。
23
- - 📦 **状态面板一键唤出** `hi` 弹一张控制台:model、上下文占用 %、累计 tokens/cost、上一轮 delta、session id、订阅额度(5h / 7d 真实 utilization,直读 Anthropic 官方 OAuth Usage API,凭据走 `~/.claude/.credentials.json`,token 过期自动 refresh)、本机所有活跃项目并列展示。
24
- - 📎 **图片 / 文件双向互传** — 用户在群里发图/文件,Claude 通过消息里的 `[file: /abs/path]` 提示就能读;Claude 在回复里写 `[[send: /abs/path]]`,标记被剥离,文件以独立消息发回群里。出站路径走 realpath + 白名单校验,只允许工作目录、`/tmp/lodestar-*`、inbox 三块,`/etc`、`~/.ssh`、`~/.config` 即使被符号链接绕也拒绝。
25
- - 📲 **加急锁屏推送** — 需要你回答问题、需要你批准操作、一轮跑完了——三种关键时刻自动触发飞书"应用内加急",直接打穿勿扰、亮屏推送。卡片摘要会同步改写成具体待办("🔐 等审批: Bash · rm -rf …"、"❓ 待回答 3 题: …"),锁屏一瞥就知道发生了什么。
26
- - 🗂 **多项目并发** 一个 daemon 同时持有 N 个飞书群 ↔ N 个 Claude session。状态面板能跨群看到所有活跃项目和它们的 uptime,在群 A 里就能查群 B 在干嘛。
27
- - 🔄 **不丢上下文** 每次 `system/init` 落盘 SDK session_id;daemon 被 systemd 重启、机器断电、手抖 kill 进程,下次 `restart` 或自动复活都 `--resume` 到同一段对话,Claude 不知道你离开过。
28
- - 🛡 **后台守护级稳定性** PID 锁、WS pong watchdog(180s 无心跳自杀,交给 systemd 拉起)、5s 重投 stale 消息丢弃、200 条 message_id 去重、SIGTERM 优雅写盘、`alive marker` 区分"我自己挂的"和"被用户主动 kill 的"——后者不会被复活。
19
+ - 🌊 **真·流式卡片**:token 级渲染同一张卡,不刷屏
20
+ - 🧠 **思考透明**:thinking 流式 + turn 后自动收起
21
+ - 🔧 **工具调用折叠**:每次工具一格面板,折起概述/展开细节
22
+ - 🔐 **审批就地完成**:工具卡上三按钮,不破坏时序
23
+ - **结构化追问**:Ask 选项行 + 自由文本回答 + 多题翻页
24
+ - ⌨️ **Type-ahead 不打断**:连珠炮全收,排队下一轮合并处理
25
+ - 🔢 **合并消息加序号**:`[#N]\n` 前缀让模型看清独立边界
26
+ - **排队反应可见**:消息进队列加 ⏳,消化/取消自动清/换
27
+ - 📨 **mid-turn 切新卡**:中途新消息 下一 tool 边界切新卡续写
28
+ - **定时唤醒可见化**:Cron / ScheduleWakeup 到点自开新卡
29
+ - 📊 **footer 实时指标**:`✅ ⏱时长 · 📊上下文% · 💰本轮成本`
30
+ - 📦 **`hi` 弹控制台**:跨群项目、上下文%、订阅额度一屏看完
31
+ - 📎 **图文双向互传**:`[file:]` 进、`[[send:]]` 出,路径白名单
32
+ - 📲 **关键时刻加急**:Ask / 审批 / done 锁屏推送,定时不打扰
33
+ - 🛑 **`stop` 软打断**:取消当前 turn + 清队列,子进程保活
34
+ - 🗂 **多项目并发**:一个 daemon 持 N 群 ↔ N session
35
+ - 🔄 **自动 resume**:重启自动续接,session_id 落盘不丢
36
+ - 🛡 **systemd 守护级**:WS watchdog + 单 PID + alive marker
29
37
 
30
38
  ## 怎么用
31
39
 
@@ -42,25 +50,37 @@ AI 不是帮手,是倍率。它放大的不是体力,是你——你的直觉、
42
50
  | 指令 | 行为 |
43
51
  | --- | --- |
44
52
  | `hi` | 未运行时启动;运行中弹一张**状态卡片** |
53
+ | `stop` | 软打断当前 turn + 清空 type-ahead 排队;子进程保活,刚排队中的消息会被打 `CrossMark` 反应表示取消 |
45
54
  | `kill` | 优雅关闭 Claude 进程;记住 `sessionId`,下次 `restart` 还能 resume |
46
55
  | `restart` | 用上一次的 `sessionId` 重启会话(保留上下文) |
47
56
  | `clear` | 杀掉进程并启动一个全新 session(等价于 Claude Code 的 `/clear`) |
48
57
 
49
- > 这四个词被全局保留:在群里发 "hi" 当问候也会触发控制台卡片,不会到 Claude 那边。换来的是手机上单手打字的便利。
58
+ > 这五个词被全局保留:在群里发 "hi" 当问候也会触发控制台卡片,不会到 Claude 那边。换来的是手机上单手打字的便利。
50
59
 
51
60
  ## 安装
52
61
 
53
62
  ### 1. 准备
54
63
 
55
- - 一台能常跑后台进程的机器(自家服务器、闲置 NAS、树莓派均可)
56
- - [Bun](https://bun.sh) 运行时(≥ 1.0)
57
- - [Claude Code](https://docs.anthropic.com/en/docs/claude-code) 装好且能跑(怎么认证、走官方账号还是第三方网关,你自己看着办)
58
- - 一个飞书自建应用 (`cli_xxx`),开通:
59
- - `im:message:send_as_bot` / `im:message` / `im:chat:readonly` / `im:resource`
60
- - `im:message.urgent`(加急推送)
61
- - `cardkit:card:read` `cardkit:card:write`
62
- `cardkit:card.element:read` `cardkit:card.element:write`
63
- `cardkit:card.settings:read` `cardkit:card.settings:write`
64
+ **机器**:能常跑后台进程的 Linux/macOS(自家服务器、闲置 NAS、树莓派均可)
65
+
66
+ **运行时**:[Bun](https://bun.sh) ≥ 1.0。
67
+
68
+ **Claude Code**:装好且能跑 —— 详见[官方文档](https://docs.anthropic.com/en/docs/claude-code)。**强烈建议用 claude.ai 账号 OAuth 登录**(`claude auth login`),而不是 `ANTHROPIC_API_KEY`:Cron / ScheduleWakeup / `/schedule` 等定时唤醒工具只在 OAuth 模式下注册。
69
+
70
+ **飞书自建应用**:去[飞书开放平台](https://open.feishu.cn/app)→ 创建企业自建应用,然后:
71
+
72
+ 1. **添加机器人能力**:左侧"添加应用能力"→"机器人"启用。
73
+ 2. **配置权限**(权限管理 → API 权限):
74
+ - 消息:`im:message:send_as_bot` `im:message` `im:chat:readonly` `im:resource`
75
+ - 加急:`im:message.urgent`(锁屏推送)
76
+ - 卡片:`cardkit:card:read` `cardkit:card:write` `cardkit:card.element:read` `cardkit:card.element:write` `cardkit:card.settings:read` `cardkit:card.settings:write`
77
+ 3. **订阅事件**(事件与回调 → 事件订阅):
78
+ - 订阅方式选 **长连接**(WebSocket,不需要公网回调地址)
79
+ - 添加事件 `im.message.receive_v1`(接收群消息)
80
+ - 添加事件 `card.action.trigger`(卡片按钮回调)
81
+ 4. **发布版本**(版本管理与发布)→ 创建版本 → 审批通过 / 自审通过 → 上线。**没发版的应用不会收到事件**,这一步常被忘记。
82
+ 5. **拿凭据**:凭据与基础信息页拷 `App ID`(`cli_xxxxxxxxxx`)和 `App Secret`,下一步写到 `config.toml`。
83
+ 6. **拉机器人进群**:想用的飞书群 → 群设置 → 群机器人 → 添加机器人 → 选你的应用。**群名要等于 `~/` 下的项目目录名**,daemon 用这个绑定群 ↔ Claude session。
64
84
 
65
85
  ### 2. 配置
66
86
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leviyuan/lodestar",
3
- "version": "0.1.11",
3
+ "version": "0.2.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -93,6 +93,12 @@ export class ClaudeProcess extends EventEmitter {
93
93
  lastResult: ClaudeResultMeta = {
94
94
  cost_usd: null, duration_ms: null, num_turns: null, usage: null,
95
95
  }
96
+ /** Context-window capacity of the model that ran the latest turn —
97
+ * lifted from `result.modelUsage[model].contextWindow` so we don't
98
+ * have to hardcode `[1m]` vs stock variants. 200K is the safe
99
+ * default if no result has landed yet (e.g. between spawn and the
100
+ * first turn close). */
101
+ lastContextWindow: number = 200_000
96
102
 
97
103
  constructor(opts: SpawnOpts) {
98
104
  super()
@@ -242,6 +248,19 @@ export class ClaudeProcess extends EventEmitter {
242
248
  num_turns: typeof msg.num_turns === 'number' ? msg.num_turns : null,
243
249
  usage: msg.usage ?? null,
244
250
  }
251
+ // modelUsage maps "<model id>" → { contextWindow, maxOutputTokens, … }.
252
+ // For mixed-model runs the SDK reports one entry per model used in
253
+ // the turn; we take the one matching `lastModel` (the assistant's
254
+ // latest model id) and fall back to any single entry if it's the
255
+ // only one — covers the common single-model case.
256
+ const mu = msg.modelUsage
257
+ if (mu && typeof mu === 'object') {
258
+ const entry = (this.lastModel && mu[this.lastModel])
259
+ || (Object.keys(mu).length === 1 ? mu[Object.keys(mu)[0]!] : null)
260
+ if (entry && typeof entry.contextWindow === 'number' && entry.contextWindow > 0) {
261
+ this.lastContextWindow = entry.contextWindow
262
+ }
263
+ }
245
264
  this.emit('result', msg)
246
265
  return
247
266
  }
package/src/session.ts CHANGED
@@ -511,7 +511,15 @@ export class Session {
511
511
  const wasBusy = this.currentTurn !== null || this.openingTurn
512
512
  this.pendingUserMessageCount++
513
513
  this.lastUserOpenId = userOpenId
514
- this.proc!.sendUserText(text, files)
514
+ // When this msg will be merged with siblings into a multi-content
515
+ // user turn (i.e. the SDK queued it because the daemon was busy),
516
+ // prepend a `[#N]\n` ordinal so the model can tell the merged
517
+ // blocks apart. Without it the harness renders multi-content text
518
+ // back-to-back ("1"+"2"+"5"+"56"+"89" → "1255689") and the model
519
+ // can't see the original boundaries — surfaced 2026-05-16 when a
520
+ // 5-msg accumulator test got mis-summed as one big number.
521
+ const wireText = wasBusy ? `[#${this.pendingUserMessageCount}]\n${text}` : text
522
+ this.proc!.sendUserText(wireText, files)
515
523
  if (wasBusy && msgId) {
516
524
  // Hold the slot in the map even if the API call hasn't returned
517
525
  // yet — empty string is a sentinel meaning "we tried to react;
@@ -883,6 +891,16 @@ export class Session {
883
891
  return this.lastTurnDelta?.inputTokens ?? 0
884
892
  }
885
893
 
894
+ /** Context-window capacity for the model the subprocess is currently
895
+ * running — sourced authoritatively from `result.modelUsage[model]
896
+ * .contextWindow` captured by ClaudeProcess on each turn close, so
897
+ * the daemon doesn't have to enumerate model ids itself (was the
898
+ * source of a "560K/200K" display bug — model id didn't include
899
+ * `[1m]` so the hardcoded fallback won). */
900
+ private contextWindowMax(): number {
901
+ return this.proc?.lastContextWindow ?? 200_000
902
+ }
903
+
886
904
  private async openTurnCard(userText: string, userOpenId: string, trigger: 'user_message' | 'scheduled'): Promise<void> {
887
905
  const turn = ++this.turnCounter
888
906
  const card = cards.mainConversationCard({
@@ -950,12 +968,13 @@ export class Session {
950
968
  if (this.openingTurn) this.rotationBuffer.push({ kind: 'assistant', delta })
951
969
  return
952
970
  }
953
- if (this.wantsRotation) {
954
- this.wantsRotation = false
955
- this.rotationBuffer.push({ kind: 'assistant', delta })
956
- void this.rotateCard()
957
- return
958
- }
971
+ // Note: assistant text DOES NOT trigger rotation, even if a mid-turn
972
+ // user message landed and set `wantsRotation`. Rotating mid-segment
973
+ // would chop the model's in-progress reply (often a response to the
974
+ // ORIGINAL prompt that started this card) onto a fresh card,
975
+ // visually associating it with the queued msg — which is the bug
976
+ // the user surfaced 2026-05-16. The rotation defers to the next
977
+ // tool_use, which is a clean section boundary.
959
978
  if (!this.currentTurn.currentAssistantSegmentId) {
960
979
  const i = this.currentTurn.assistantSegmentCount++
961
980
  const segId = cards.ELEMENTS.assistant(i)
@@ -985,12 +1004,8 @@ export class Session {
985
1004
  if (this.openingTurn) this.rotationBuffer.push({ kind: 'thinking', delta })
986
1005
  return
987
1006
  }
988
- if (this.wantsRotation) {
989
- this.wantsRotation = false
990
- this.rotationBuffer.push({ kind: 'thinking', delta })
991
- void this.rotateCard()
992
- return
993
- }
1007
+ // Thinking, like assistant text, doesn't trigger rotation — it's
1008
+ // preamble to the same response, not a section break.
994
1009
  this.currentTurn.thinkingText += delta
995
1010
  cardkit.streamTextThrottled(
996
1011
  this.currentTurn.cardId,
@@ -1303,11 +1318,28 @@ export class Session {
1303
1318
  await cardkit.replaceElement(cardId, cards.ELEMENTS.thinking, cards.thinkingCollapsedPanel(thinkingText))
1304
1319
  }
1305
1320
  const sendNote = sendPaths.length ? ` · 📎 ${sendPaths.length}` : ''
1306
- // Suffix REPLACES the trailing `✅ done` it represents a terminal
1307
- // state distinct from natural completion (e.g. `📨 转交新卡` for a
1308
- // mid-turn rotation). When absent, the turn ended cleanly.
1309
- const stateMark = suffix ? ` · ${suffix}` : ' · ✅ done'
1310
- const footer = `⏱ ${elapsed}s${sendNote}${stateMark}`
1321
+ // State marker leads the footer (✅ for natural completion, or the
1322
+ // suffix verbatim for non-natural states like `📨 转交新卡`). The
1323
+ // trailing "done" word is gone — the already carries that
1324
+ // meaning. User-confirmed footer order 2026-05-16.
1325
+ const stateMark = suffix ? suffix : '✅'
1326
+ // Per-turn metrics: context-window occupancy (as a real percentage,
1327
+ // not a token count) and dollar cost. Only meaningful on a clean
1328
+ // close — suffix-tagged turns (rotation / interrupt) didn't fire
1329
+ // the `result` event that populates `lastTurnDelta`, so these
1330
+ // numbers would be stale and misleading.
1331
+ let metrics = ''
1332
+ if (!suffix) {
1333
+ const ctxTokens = this.currentContextTokens()
1334
+ const ctxMax = this.contextWindowMax()
1335
+ if (ctxTokens > 0 && ctxMax > 0) {
1336
+ const pct = Math.round((ctxTokens / ctxMax) * 100)
1337
+ metrics += ` · 📊 ${pct}%`
1338
+ }
1339
+ const cost = this.lastTurnDelta?.costUsd ?? 0
1340
+ if (cost > 0) metrics += ` · 💰 $${cost.toFixed(3)}`
1341
+ }
1342
+ const footer = `${stateMark} ⏱ ${elapsed}s${metrics}${sendNote}`
1311
1343
  await cardkit.streamText(cardId, cards.ELEMENTS.footer, footer)
1312
1344
  // Final chat-list preview: clean finish shows "⏱ Xs · NK tokens";
1313
1345
  // interrupted shows the suffix instead (no usage event landed).