@leviyuan/lodestar 0.2.0 → 0.2.2
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 +1 -2
- package/package.json +1 -1
- package/src/cards.ts +1 -1
- package/src/instructions.ts +9 -18
- package/src/session.ts +28 -99
package/README.md
CHANGED
|
@@ -24,7 +24,6 @@ AI 不是帮手,是倍率。它放大的不是体力,是你——你的直觉、
|
|
|
24
24
|
- ⌨️ **Type-ahead 不打断**:连珠炮全收,排队下一轮合并处理
|
|
25
25
|
- 🔢 **合并消息加序号**:`[#N]\n` 前缀让模型看清独立边界
|
|
26
26
|
- ⏳ **排队反应可见**:消息进队列加 ⏳,消化/取消自动清/换 ❌
|
|
27
|
-
- 📨 **mid-turn 切新卡**:中途新消息 → 下一 tool 边界切新卡续写
|
|
28
27
|
- ⏰ **定时唤醒可见化**:Cron / ScheduleWakeup 到点自开新卡
|
|
29
28
|
- 📊 **footer 实时指标**:`✅ ⏱时长 · 📊上下文% · 💰本轮成本`
|
|
30
29
|
- 📦 **`hi` 弹控制台**:跨群项目、上下文%、订阅额度一屏看完
|
|
@@ -65,7 +64,7 @@ AI 不是帮手,是倍率。它放大的不是体力,是你——你的直觉、
|
|
|
65
64
|
|
|
66
65
|
**运行时**:[Bun](https://bun.sh) ≥ 1.0。
|
|
67
66
|
|
|
68
|
-
**Claude Code**:装好且能跑 —— 详见[官方文档](https://docs.anthropic.com/en/docs/claude-code)
|
|
67
|
+
**Claude Code**:装好且能跑 —— 详见[官方文档](https://docs.anthropic.com/en/docs/claude-code)。
|
|
69
68
|
|
|
70
69
|
**飞书自建应用**:去[飞书开放平台](https://open.feishu.cn/app)→ 创建企业自建应用,然后:
|
|
71
70
|
|
package/package.json
CHANGED
package/src/cards.ts
CHANGED
|
@@ -668,7 +668,7 @@ export function consoleCard(opts: ConsoleOpts): object {
|
|
|
668
668
|
lines.push(` · ${dot} \`${p.name}\`${up}${mark}`)
|
|
669
669
|
}
|
|
670
670
|
}
|
|
671
|
-
if (contextTokens != null) {
|
|
671
|
+
if (contextTokens != null && contextTokens > 0) {
|
|
672
672
|
const limit = contextLimit ?? 1_000_000
|
|
673
673
|
const pct = limit > 0 ? Math.round((contextTokens / limit) * 100) : 0
|
|
674
674
|
lines.push(`**📦 上下文** ${fmtTokens(contextTokens)} / ${fmtTokens(limit)} (${pct}%)`)
|
package/src/instructions.ts
CHANGED
|
@@ -1,22 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
2
|
+
* Daemon ↔ model I/O contracts. Appended to claude's system prompt on
|
|
3
|
+
* every headless launch via `--append-system-prompt`. Three rules:
|
|
4
|
+
* inbound file marker, multi-content boundary marker, outbound file
|
|
5
|
+
* marker. Anything beyond pure I/O semantics (environment description,
|
|
6
|
+
* UX conventions, identity binding) was stripped 2026-05-16 — the
|
|
7
|
+
* model handles conversational flow natively, doesn't need to be told.
|
|
7
8
|
*/
|
|
8
9
|
export const CHANNEL_INSTRUCTIONS = [
|
|
9
|
-
'
|
|
10
|
-
'
|
|
11
|
-
'',
|
|
12
|
-
'Conventions for every turn:',
|
|
13
|
-
'- Open with one short acknowledgement so the user sees you started.',
|
|
14
|
-
'- Stream your conclusion before the turn ends; never end on a silent tool call. The card is your voice.',
|
|
15
|
-
'- For long work, drop progress sentences between tool calls so the user is not staring at a loading dot.',
|
|
16
|
-
'',
|
|
17
|
-
'Inbound user messages may carry a [file: /abs/path] hint when the user sent an image or attachment in Feishu. Read those files when relevant.',
|
|
18
|
-
'',
|
|
19
|
-
'To send a local file or image back to the user in this Feishu group, write the marker `[[send: /abs/path]]` (absolute path) anywhere in your reply, preferably on its own line at the end. The daemon strips every marker from the displayed card and posts the file as a separate Feishu message. Emit the marker only when the user asked for a file or when delivering a generated artifact (screenshot, diagram, exported doc) — not for arbitrary paths.',
|
|
20
|
-
'',
|
|
21
|
-
'The group name equals the working directory under $HOME and equals the Lodestar session name. Treat that binding as load-bearing — do not rename or move the directory.',
|
|
10
|
+
'- Text prefixed with `[file: /abs/path]` means a file is attached at that path; read it when relevant.',
|
|
11
|
+
'- A content block wrapped in `<u>...</u>` is an independent message — treat each `<u>` element in a multi-content turn as a separate input, even when their texts concatenate visually (e.g. `<u>1</u><u>45</u>` is two messages, not the number `145`).',
|
|
12
|
+
'- Write `[[send: /abs/path]]` anywhere in your reply (preferably on its own line) to deliver that file as a separate message. The marker is stripped from the displayed text. Emit only when the user asked for a file or you are delivering a generated artifact.',
|
|
22
13
|
].join('\n')
|
package/src/session.ts
CHANGED
|
@@ -137,25 +137,6 @@ export class Session {
|
|
|
137
137
|
* to clear (via deleteReaction). Empty for eager-opened solo turns
|
|
138
138
|
* and for scheduled wakeups (no user messages went into those). */
|
|
139
139
|
private currentBatchReactionIds = new Map<string, string>()
|
|
140
|
-
/** Set the moment a mid-turn user message lands. Tells the next
|
|
141
|
-
* content-adding event (assistant text delta or fresh tool_use) to
|
|
142
|
-
* rotate the card before applying its update — closes the in-flight
|
|
143
|
-
* card with a `📨 转交新卡` footer and opens a fresh card, so the
|
|
144
|
-
* continuation has a visible boundary instead of piling up under
|
|
145
|
-
* one card. Reset to false after the rotation fires (or on
|
|
146
|
-
* stop/restart/exit). User feedback (2026-05-15): the prior
|
|
147
|
-
* everything-in-one-card behavior made the order feel jumbled. */
|
|
148
|
-
private wantsRotation = false
|
|
149
|
-
/** Holds assistant / thinking / tool_use events that arrive while a
|
|
150
|
-
* card rotation is mid-flight (close-old → open-new straddles a
|
|
151
|
-
* Feishu API await window during which `currentTurn` is transiently
|
|
152
|
-
* null). Replayed onto the new card the moment rotation completes
|
|
153
|
-
* so no streamed token is lost across the boundary. */
|
|
154
|
-
private rotationBuffer: Array<
|
|
155
|
-
| { kind: 'assistant'; delta: string }
|
|
156
|
-
| { kind: 'thinking'; delta: string }
|
|
157
|
-
| { kind: 'tool_use'; id: string; name: string; input: any }
|
|
158
|
-
> = []
|
|
159
140
|
/** Count of `system/init` events seen this subprocess. The first one is
|
|
160
141
|
* the boot init (claimed by whichever user message lands first); all
|
|
161
142
|
* subsequent ones mark the start of an SDK-initiated turn (queued
|
|
@@ -311,8 +292,6 @@ export class Session {
|
|
|
311
292
|
this.lastUserOpenId = ''
|
|
312
293
|
this.pendingReactionIds = new Map()
|
|
313
294
|
this.currentBatchReactionIds = new Map()
|
|
314
|
-
this.wantsRotation = false
|
|
315
|
-
this.rotationBuffer = []
|
|
316
295
|
this.initCount = 0
|
|
317
296
|
this.openingTurn = false
|
|
318
297
|
this.pendingPermissions.clear()
|
|
@@ -333,8 +312,6 @@ export class Session {
|
|
|
333
312
|
this.lastUserOpenId = ''
|
|
334
313
|
this.pendingReactionIds = new Map()
|
|
335
314
|
this.currentBatchReactionIds = new Map()
|
|
336
|
-
this.wantsRotation = false
|
|
337
|
-
this.rotationBuffer = []
|
|
338
315
|
this.initCount = 0
|
|
339
316
|
this.openingTurn = false
|
|
340
317
|
this.pendingPermissions.clear()
|
|
@@ -416,7 +393,6 @@ export class Session {
|
|
|
416
393
|
this.lastUserOpenId = ''
|
|
417
394
|
this.pendingReactionIds = new Map()
|
|
418
395
|
this.currentBatchReactionIds = new Map()
|
|
419
|
-
this.wantsRotation = false
|
|
420
396
|
this.interrupt()
|
|
421
397
|
return true
|
|
422
398
|
case 'kill':
|
|
@@ -453,6 +429,7 @@ export class Session {
|
|
|
453
429
|
// ~5s; not worth blocking the panel on it).
|
|
454
430
|
usage: undefined,
|
|
455
431
|
contextTokens: this.currentContextTokens(),
|
|
432
|
+
contextLimit: this.contextWindowMax(),
|
|
456
433
|
cumStats: this.cumStats,
|
|
457
434
|
lastTurn: this.lastTurnDelta
|
|
458
435
|
? {
|
|
@@ -511,14 +488,17 @@ export class Session {
|
|
|
511
488
|
const wasBusy = this.currentTurn !== null || this.openingTurn
|
|
512
489
|
this.pendingUserMessageCount++
|
|
513
490
|
this.lastUserOpenId = userOpenId
|
|
514
|
-
// When
|
|
515
|
-
// user turn
|
|
516
|
-
//
|
|
517
|
-
//
|
|
518
|
-
//
|
|
519
|
-
//
|
|
520
|
-
//
|
|
521
|
-
|
|
491
|
+
// When the SDK will merge this msg with siblings into a multi-
|
|
492
|
+
// content user turn, wrap it in `<u>...</u>` so the model sees a
|
|
493
|
+
// structural boundary it actually attends to. Tried U+001E
|
|
494
|
+
// (ASCII Record Separator) first — invisible and theoretically
|
|
495
|
+
// perfect, but Anthropic's tokenizer effectively drops control
|
|
496
|
+
// chars and `<u>1</u><u>45</u>` became "145" to the model
|
|
497
|
+
// (2026-05-16 accumulator test). HTML-tag wrap is visible but
|
|
498
|
+
// models parse `<tag>` boundaries very reliably from training.
|
|
499
|
+
// Solo (eager-open) msgs don't get wrapped — no sibling, no
|
|
500
|
+
// merge, no need. Contract declared in CHANNEL_INSTRUCTIONS.
|
|
501
|
+
const wireText = wasBusy ? `<u>${text}</u>` : text
|
|
522
502
|
this.proc!.sendUserText(wireText, files)
|
|
523
503
|
if (wasBusy && msgId) {
|
|
524
504
|
// Hold the slot in the map even if the API call hasn't returned
|
|
@@ -533,9 +513,6 @@ export class Session {
|
|
|
533
513
|
this.pendingReactionIds.set(msgId, rid)
|
|
534
514
|
}
|
|
535
515
|
})()
|
|
536
|
-
// Rotation hint: a mid-turn user msg means the next assistant /
|
|
537
|
-
// tool event should split the visual into a new card.
|
|
538
|
-
this.wantsRotation = true
|
|
539
516
|
}
|
|
540
517
|
if (!this.currentTurn && !this.openingTurn && this.initCount >= 1) {
|
|
541
518
|
// Eager open: this message is going to be processed solo (no current
|
|
@@ -850,7 +827,6 @@ export class Session {
|
|
|
850
827
|
this.lastUserOpenId = ''
|
|
851
828
|
this.pendingReactionIds = new Map()
|
|
852
829
|
this.currentBatchReactionIds = new Map()
|
|
853
|
-
this.wantsRotation = false
|
|
854
830
|
this.initCount = 0
|
|
855
831
|
this.openingTurn = false
|
|
856
832
|
this.status = 'stopped'
|
|
@@ -881,14 +857,17 @@ export class Session {
|
|
|
881
857
|
|
|
882
858
|
/** Current context-window occupancy estimate — uses the most recent
|
|
883
859
|
* assistant `usage` (input + caches), since each assistant reply replays
|
|
884
|
-
* the full conversation.
|
|
885
|
-
*
|
|
860
|
+
* the full conversation. Returns 0 when no per-call usage is available
|
|
861
|
+
* (process dead, or fresh spawn before first assistant message);
|
|
862
|
+
* `lastTurnDelta.inputTokens` is the CUMULATIVE turn input across all
|
|
863
|
+
* API calls in the turn (sum of cache_read across N steps) — using it
|
|
864
|
+
* here would inflate the percentage by Nx after a heavy multi-step
|
|
865
|
+
* turn (observed bug 2026-05-16: 417% in the `hi` panel after killing
|
|
866
|
+
* the proc with a long turn's delta still on file). */
|
|
886
867
|
private currentContextTokens(): number {
|
|
887
868
|
const u = this.proc?.lastUsage as ClaudeUsage | null | undefined
|
|
888
|
-
if (u)
|
|
889
|
-
|
|
890
|
-
}
|
|
891
|
-
return this.lastTurnDelta?.inputTokens ?? 0
|
|
869
|
+
if (!u) return 0
|
|
870
|
+
return (u.input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0) + (u.cache_read_input_tokens ?? 0)
|
|
892
871
|
}
|
|
893
872
|
|
|
894
873
|
/** Context-window capacity for the model the subprocess is currently
|
|
@@ -937,44 +916,8 @@ export class Session {
|
|
|
937
916
|
// forget here and rely on enqueue source order — that way no `await`
|
|
938
917
|
// can yield mid-handler and let `closeTurnCard` (or another event) race
|
|
939
918
|
// and mutate `this.currentTurn` underfoot.
|
|
940
|
-
/** Rotate to a fresh card mid-turn: close the in-flight card with a
|
|
941
|
-
* `📨 转交新卡` footer (distinct from `✅ done` and `🛑 打断`) and
|
|
942
|
-
* open a new card so the post-user-message continuation has a
|
|
943
|
-
* visible boundary. Streams that land during the rotation's await
|
|
944
|
-
* windows are buffered in `rotationBuffer` and replayed onto the
|
|
945
|
-
* new card the moment it's ready, so no tokens are lost across the
|
|
946
|
-
* cut. Caller guarantees `wantsRotation` was true sync-immediately
|
|
947
|
-
* before. */
|
|
948
|
-
private async rotateCard(): Promise<void> {
|
|
949
|
-
this.openingTurn = true
|
|
950
|
-
try {
|
|
951
|
-
await this.closeTurnCard('📨 转交新卡')
|
|
952
|
-
await this.openTurnCard('', this.lastUserOpenId, 'user_message')
|
|
953
|
-
} finally {
|
|
954
|
-
this.openingTurn = false
|
|
955
|
-
}
|
|
956
|
-
if (this.rotationBuffer.length === 0) return
|
|
957
|
-
const buf = this.rotationBuffer
|
|
958
|
-
this.rotationBuffer = []
|
|
959
|
-
for (const e of buf) {
|
|
960
|
-
if (e.kind === 'assistant') this.appendAssistant(e.delta)
|
|
961
|
-
else if (e.kind === 'thinking') this.appendThinking(e.delta)
|
|
962
|
-
else if (e.kind === 'tool_use') this.addTool(e.id, e.name, e.input)
|
|
963
|
-
}
|
|
964
|
-
}
|
|
965
|
-
|
|
966
919
|
private appendAssistant(delta: string): void {
|
|
967
|
-
if (!this.currentTurn)
|
|
968
|
-
if (this.openingTurn) this.rotationBuffer.push({ kind: 'assistant', delta })
|
|
969
|
-
return
|
|
970
|
-
}
|
|
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.
|
|
920
|
+
if (!this.currentTurn) return
|
|
978
921
|
if (!this.currentTurn.currentAssistantSegmentId) {
|
|
979
922
|
const i = this.currentTurn.assistantSegmentCount++
|
|
980
923
|
const segId = cards.ELEMENTS.assistant(i)
|
|
@@ -1000,12 +943,7 @@ export class Session {
|
|
|
1000
943
|
}
|
|
1001
944
|
|
|
1002
945
|
private appendThinking(delta: string): void {
|
|
1003
|
-
if (!this.currentTurn)
|
|
1004
|
-
if (this.openingTurn) this.rotationBuffer.push({ kind: 'thinking', delta })
|
|
1005
|
-
return
|
|
1006
|
-
}
|
|
1007
|
-
// Thinking, like assistant text, doesn't trigger rotation — it's
|
|
1008
|
-
// preamble to the same response, not a section break.
|
|
946
|
+
if (!this.currentTurn) return
|
|
1009
947
|
this.currentTurn.thinkingText += delta
|
|
1010
948
|
cardkit.streamTextThrottled(
|
|
1011
949
|
this.currentTurn.cardId,
|
|
@@ -1023,16 +961,7 @@ export class Session {
|
|
|
1023
961
|
}
|
|
1024
962
|
|
|
1025
963
|
private addTool(toolUseId: string, name: string, input: any): void {
|
|
1026
|
-
if (!this.currentTurn)
|
|
1027
|
-
if (this.openingTurn) this.rotationBuffer.push({ kind: 'tool_use', id: toolUseId, name, input })
|
|
1028
|
-
return
|
|
1029
|
-
}
|
|
1030
|
-
if (this.wantsRotation) {
|
|
1031
|
-
this.wantsRotation = false
|
|
1032
|
-
this.rotationBuffer.push({ kind: 'tool_use', id: toolUseId, name, input })
|
|
1033
|
-
void this.rotateCard()
|
|
1034
|
-
return
|
|
1035
|
-
}
|
|
964
|
+
if (!this.currentTurn) return
|
|
1036
965
|
// Close current assistant segment (if any) so the tool panel renders
|
|
1037
966
|
// AFTER it in card body order. Flush queues the segment's last
|
|
1038
967
|
// buffered delta before the tool element is inserted.
|
|
@@ -1319,15 +1248,15 @@ export class Session {
|
|
|
1319
1248
|
}
|
|
1320
1249
|
const sendNote = sendPaths.length ? ` · 📎 ${sendPaths.length}` : ''
|
|
1321
1250
|
// State marker leads the footer (✅ for natural completion, or the
|
|
1322
|
-
// suffix verbatim for non-natural states like
|
|
1251
|
+
// suffix verbatim for non-natural states like `🛑 打断`). The
|
|
1323
1252
|
// trailing "done" word is gone — the ✅ already carries that
|
|
1324
1253
|
// meaning. User-confirmed footer order 2026-05-16.
|
|
1325
1254
|
const stateMark = suffix ? suffix : '✅'
|
|
1326
1255
|
// Per-turn metrics: context-window occupancy (as a real percentage,
|
|
1327
1256
|
// not a token count) and dollar cost. Only meaningful on a clean
|
|
1328
|
-
// close — suffix-tagged turns (
|
|
1329
|
-
//
|
|
1330
|
-
//
|
|
1257
|
+
// close — suffix-tagged turns (interrupt) didn't fire the `result`
|
|
1258
|
+
// event that populates `lastTurnDelta`, so these numbers would be
|
|
1259
|
+
// stale and misleading.
|
|
1331
1260
|
let metrics = ''
|
|
1332
1261
|
if (!suffix) {
|
|
1333
1262
|
const ctxTokens = this.currentContextTokens()
|