@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 +40 -20
- package/package.json +1 -1
- package/src/claude-process.ts +19 -0
- package/src/session.ts +50 -18
package/README.md
CHANGED
|
@@ -16,16 +16,24 @@ AI 不是帮手,是倍率。它放大的不是体力,是你——你的直觉、
|
|
|
16
16
|
|
|
17
17
|
## 你会得到什么
|
|
18
18
|
|
|
19
|
-
- 🌊
|
|
20
|
-
- 🧠
|
|
21
|
-
-
|
|
22
|
-
-
|
|
23
|
-
-
|
|
24
|
-
-
|
|
25
|
-
-
|
|
26
|
-
-
|
|
27
|
-
-
|
|
28
|
-
-
|
|
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
|
-
>
|
|
58
|
+
> 这五个词被全局保留:在群里发 "hi" 当问候也会触发控制台卡片,不会到 Claude 那边。换来的是手机上单手打字的便利。
|
|
50
59
|
|
|
51
60
|
## 安装
|
|
52
61
|
|
|
53
62
|
### 1. 准备
|
|
54
63
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
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).
|