@leviyuan/lodestar 0.1.11 → 0.1.12
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 +5 -2
- package/package.json +1 -1
- package/src/claude-process.ts +19 -0
- package/src/session.ts +50 -18
package/README.md
CHANGED
|
@@ -16,10 +16,12 @@ AI 不是帮手,是倍率。它放大的不是体力,是你——你的直觉、
|
|
|
16
16
|
|
|
17
17
|
## 你会得到什么
|
|
18
18
|
|
|
19
|
-
- 🌊 **真·流式卡片** — 飞书 Card Kit v1 streaming,Claude 一个 token 一个 token
|
|
19
|
+
- 🌊 **真·流式卡片** — 飞书 Card Kit v1 streaming,Claude 一个 token 一个 token 地打在同一张卡片里,不是发一堆零碎消息刷屏。每张 turn 卡片 footer 自带 `✅ ⏱ 12.3s · 📊 47% · 💰 $0.45`,本轮上下文占用 / 实付成本一眼可见。
|
|
20
20
|
- 🧠 **思考过程透明** — `thinking` 流式渲染,turn 结束后自动收起为可展开面板。每次工具调用也是一格折叠面板:折起是概述,展开看完整 input/output。
|
|
21
21
|
- 🔐 **权限审批就地完成** — 需要授权的工具调用,**原地**升级为 🔐 等审批状态,三颗按钮 `允许 / 始终允许 / 拒绝` 直接嵌在面板里。不弹独立卡片,不破坏时序。点完按钮,后续 output 接在同一条线上继续往下走。
|
|
22
22
|
- ❓ **结构化追问** — Claude 的 `AskUserQuestion` 在群里呈现为可点击选项行;不满意?直接在群里**打字回答**,daemon 会把自由文本当作 custom answer 发回去。多题串行,有进度计数和"已答 N 题"折叠历史。
|
|
23
|
+
- ⌨️ **Type-ahead 不打断** — Claude 跑着你继续连珠炮,daemon 全部接住排队,排队消息打 `⏳` 反应,消化后清空(`stop` 取消则换 `❌`)。daemon 还会给每条合并消息前面注 `[#N]\n` 序号,模型一眼分得清"这是 5 条独立消息"而不是一个长字符串。turn 中途有新消息进来 + 下一个 tool_use 边界 → 旧卡 `📨 转交新卡` 收尾(既不是 done 也不是打断),新卡续写,边界跟语义对齐。
|
|
24
|
+
- ⏰ **定时唤醒可见化** — Claude 用 `CronCreate` / `ScheduleWakeup` 自己安排周期任务,到点子进程在 idle 间隙 fire,daemon 检测"非首次 init"自动开一张 `⏰ 定时唤醒` 卡片承接;这种自发 turn 不响加急(凌晨 3 点自检不该震你)。
|
|
23
25
|
- 📦 **状态面板一键唤出** — 发 `hi` 弹一张控制台:model、上下文占用 %、累计 tokens/cost、上一轮 delta、session id、订阅额度(5h / 7d 真实 utilization,直读 Anthropic 官方 OAuth Usage API,凭据走 `~/.claude/.credentials.json`,token 过期自动 refresh)、本机所有活跃项目并列展示。
|
|
24
26
|
- 📎 **图片 / 文件双向互传** — 用户在群里发图/文件,Claude 通过消息里的 `[file: /abs/path]` 提示就能读;Claude 在回复里写 `[[send: /abs/path]]`,标记被剥离,文件以独立消息发回群里。出站路径走 realpath + 白名单校验,只允许工作目录、`/tmp/lodestar-*`、inbox 三块,`/etc`、`~/.ssh`、`~/.config` 即使被符号链接绕也拒绝。
|
|
25
27
|
- 📲 **加急锁屏推送** — 需要你回答问题、需要你批准操作、一轮跑完了——三种关键时刻自动触发飞书"应用内加急",直接打穿勿扰、亮屏推送。卡片摘要会同步改写成具体待办("🔐 等审批: Bash · rm -rf …"、"❓ 待回答 3 题: …"),锁屏一瞥就知道发生了什么。
|
|
@@ -42,11 +44,12 @@ AI 不是帮手,是倍率。它放大的不是体力,是你——你的直觉、
|
|
|
42
44
|
| 指令 | 行为 |
|
|
43
45
|
| --- | --- |
|
|
44
46
|
| `hi` | 未运行时启动;运行中弹一张**状态卡片** |
|
|
47
|
+
| `stop` | 软打断当前 turn + 清空 type-ahead 排队;子进程保活,刚排队中的消息会被打 `CrossMark` 反应表示取消 |
|
|
45
48
|
| `kill` | 优雅关闭 Claude 进程;记住 `sessionId`,下次 `restart` 还能 resume |
|
|
46
49
|
| `restart` | 用上一次的 `sessionId` 重启会话(保留上下文) |
|
|
47
50
|
| `clear` | 杀掉进程并启动一个全新 session(等价于 Claude Code 的 `/clear`) |
|
|
48
51
|
|
|
49
|
-
>
|
|
52
|
+
> 这五个词被全局保留:在群里发 "hi" 当问候也会触发控制台卡片,不会到 Claude 那边。换来的是手机上单手打字的便利。
|
|
50
53
|
|
|
51
54
|
## 安装
|
|
52
55
|
|
package/package.json
CHANGED
package/src/claude-process.ts
CHANGED
|
@@ -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
|
|
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
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
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
|
-
|
|
989
|
-
|
|
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
|
-
//
|
|
1307
|
-
//
|
|
1308
|
-
//
|
|
1309
|
-
|
|
1310
|
-
const
|
|
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).
|