agent-loft 0.2.32 → 0.2.34

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.
@@ -143,6 +143,8 @@ type Client = {
143
143
  /** 页面在前台,且正看着哪条会话(前端在切会话、切前后台时上报) */
144
144
  visible: boolean;
145
145
  viewing: string | null;
146
+ /** 最后一次收到这个页面的消息(含 ping) */
147
+ seen: number;
146
148
  };
147
149
 
148
150
  /** 每次进程启动换一个:客户端发现变了,就知道 seq 已经从头计数,得整页重拉 */
@@ -187,16 +189,22 @@ function broadcastTasks(owner: string): void {
187
189
  for (const client of clients) if (client.user.id === owner) send(client, { t: "tasks" });
188
190
  }
189
191
 
190
- /** 技能变了:技能广场谁都可能在看,通知所有人重新拉(列表很小) */
192
+ /** 技能变了:以前装过别人技能的人也受影响,通知所有人重新拉(列表很小) */
191
193
  function broadcastSkills(): void {
192
194
  for (const client of clients) send(client, { t: "skills" });
193
195
  }
194
196
 
195
197
  // ---------- 系统通知(Web Push) ----------
196
198
 
197
- /** 有页面正在前台看着这条会话:人就在眼前,不用推 */
199
+ /**
200
+ * 有页面正在前台看着这条会话:人就在眼前,不用推。
201
+ * 页面每 25 秒发一次 ping;40 秒没收到它的任何消息就不算(手机切走后网络断了,连接还没被心跳清掉,
202
+ * 最后报的还是「在看」)
203
+ */
204
+ const SEEN_MS = 40_000;
198
205
  function watching(key: string): boolean {
199
- for (const client of clients) if (client.visible && client.viewing === key) return true;
206
+ const now = Date.now();
207
+ for (const client of clients) if (client.visible && client.viewing === key && now - client.seen < SEEN_MS) return true;
200
208
  return false;
201
209
  }
202
210
 
@@ -275,6 +283,7 @@ function handleClientMessage(client: Client, raw: string): void {
275
283
  // 每条消息都复查登录态:握手时有效不代表现在还有效(改了密码、被踢了)
276
284
  const user = checkClient(client);
277
285
  if (!user) return;
286
+ client.seen = Date.now();
278
287
  let msg: Record<string, unknown>;
279
288
  try {
280
289
  const parsed: unknown = JSON.parse(raw);
@@ -448,7 +457,7 @@ export async function startWeb(): Promise<void> {
448
457
  }
449
458
  const token = cookies(req)[COOKIE] ?? "";
450
459
  wss!.handleUpgrade(req, socket, head, (ws) => {
451
- const client: Client = { ws, subs: new Set(), alive: true, token, user, visible: false, viewing: null };
460
+ const client: Client = { ws, subs: new Set(), alive: true, token, user, visible: false, viewing: null, seen: Date.now() };
452
461
  clients.add(client);
453
462
  ws.on("pong", () => (client.alive = true));
454
463
  ws.on("message", (data) => {
package/src/core/hub.ts CHANGED
@@ -11,6 +11,7 @@ import crypto from "node:crypto";
11
11
  import { EventEmitter } from "node:events";
12
12
  import { config, onConfigChange, resolveWorkspacePath } from "./config.js";
13
13
  import { attachFooters, copyFooters, formatDuration, recordFooter } from "./footers.js";
14
+ import { flushUsage, recordUsage, usageOf, type UsageTotals } from "./usage.js";
14
15
  import { interactions, SETTLEMENT_LABEL } from "./interactions.js";
15
16
  import {
16
17
  agentBinding,
@@ -65,7 +66,15 @@ export type Origin = {
65
66
  };
66
67
 
67
68
  export type ConvEvent =
68
- | { type: "user"; text: string; attachments: string[]; files?: AttachmentInfo[]; origin: Origin }
69
+ /** id:这条消息的编号,后面的 queue 事件按它对上;queued:收到时前面还有在跑 / 在等的,要排队 */
70
+ | { type: "user"; text: string; attachments: string[]; files?: AttachmentInfo[]; origin: Origin; id?: string; queued?: boolean }
71
+ /** 排队的消息:轮到它了(started,或者被当成提问的回答用掉了),或者没发出去(dropped:停止、会话切换…) */
72
+ | { type: "queue"; id: string; state: "started" | "dropped" }
73
+ /**
74
+ * 这里弹出过一张授权 / 提问卡(内容和状态在 interactions 里按 id 查)。只是给卡片在对话里占个位置:
75
+ * 刷新页面时进行中的这一轮靠重放事件画出来,没有它卡片就丢了
76
+ */
77
+ | { type: "ix"; id: string }
69
78
  | { type: "turn_start" }
70
79
  | { type: "step"; kind: StepKind; text: string }
71
80
  | { type: "delta"; text: string }
@@ -141,6 +150,8 @@ export type ConversationInfo = {
141
150
  runtimeEffort?: string;
142
151
  /** 当前 agent 会话的上下文占用(这次启动以来跑过至少一轮才有) */
143
152
  context?: ContextUsage;
153
+ /** 当前 agent 会话累计消耗的 token(升级后跑过至少一轮才有) */
154
+ usage?: UsageTotals;
144
155
  busy: boolean;
145
156
  activeAt: number;
146
157
  createdAt: number;
@@ -190,6 +201,14 @@ class Hub extends EventEmitter<{ event: [EventEnvelope]; task: [TaskTurnEnd] }>
190
201
  private seq = 0;
191
202
  /** 每条会话还没收到结果的轮数,> 0 即忙 */
192
203
  private inflight = new Map<string, number>();
204
+ /**
205
+ * 已经交给 agent 会话、还没出结果的消息,和 inflight、turnTags 一一对应:第一条在跑,后面的在排队。
206
+ * 用来告诉页面哪条消息轮到了 / 被丢了,以及「打断并发送」时把哪条挪到前面
207
+ */
208
+ private turnFifo = new Map<string, { id: string; input: UserInput; queued: boolean }[]>();
209
+ /** 收到了、还在串行队列里没交给 agent 的消息数:这时再来的消息也算排队 */
210
+ private waitingSends = new Map<string, number>();
211
+ private msgSeq = 0;
193
212
  private announced = new Set<string>();
194
213
  /** 每条会话因缓冲区溢出被丢掉的最后一个 seq */
195
214
  private truncated = new Map<string, number>();
@@ -229,6 +248,7 @@ class Hub extends EventEmitter<{ event: [EventEnvelope]; task: [TaskTurnEnd] }>
229
248
  constructor() {
230
249
  super();
231
250
  this.setMaxListeners(100);
251
+ interactions.on("created", (i) => this.emitEvent(i.convKey, { type: "ix", id: i.id }));
232
252
  // 技能装了、改了、停用了、变量改了:受影响的人空闲的会话换上新的
233
253
  skillEvents.on("changed", (users) => this.personalChanged(new Set(users)));
234
254
  // workspaceRoot 改了:store 会把没显式 /cd 过的会话目录重置,正在跑的旧目录会话要作废
@@ -461,6 +481,10 @@ class Hub extends EventEmitter<{ event: [EventEnvelope]; task: [TaskTurnEnd] }>
461
481
  const ctx = binding.sessionId ? this.contextBySession.get(binding.sessionId) : undefined;
462
482
  return ctx ? { context: ctx } : {};
463
483
  })(),
484
+ ...(() => {
485
+ const usage = usageOf(binding.sessionId);
486
+ return usage ? { usage } : {};
487
+ })(),
464
488
  busy: this.isBusy(key),
465
489
  activeAt: conv.activeAt,
466
490
  createdAt: conv.createdAt,
@@ -581,6 +605,7 @@ class Hub extends EventEmitter<{ event: [EventEnvelope]; task: [TaskTurnEnd] }>
581
605
  const conv = getConversation(key);
582
606
  recordFooter(holder.session?.sessionId ?? (conv ? agentBinding(conv, agent).sessionId : undefined), result.text, footer);
583
607
  this.setInflight(key, (this.inflight.get(key) ?? 1) - 1);
608
+ this.finishTurn(key);
584
609
  this.refreshRuntimeOnce(key, liveId);
585
610
  this.syncTitle(key, agent);
586
611
  this.dropIfStale(key);
@@ -607,6 +632,7 @@ class Hub extends EventEmitter<{ event: [EventEnvelope]; task: [TaskTurnEnd] }>
607
632
  this.emitEvent(key, { type: "error", message: diagnosis.chatMessage });
608
633
  }
609
634
  this.setInflight(key, (this.inflight.get(key) ?? 1) - 1);
635
+ this.finishTurn(key);
610
636
  this.dropIfStale(key);
611
637
  },
612
638
  onContext: (ctx) => {
@@ -622,6 +648,14 @@ class Hub extends EventEmitter<{ event: [EventEnvelope]; task: [TaskTurnEnd] }>
622
648
  // Codex 一轮里会报好几次,数字没变就不打扰前端
623
649
  if (prev?.used !== ctx.used || prev?.window !== ctx.window) this.emitEvent(key, { type: "meta" });
624
650
  },
651
+ // 不用另发 meta:一轮结束的 result / error 会把会话信息(含用量)推给页面
652
+ onUsage: (usage, cumulative) => {
653
+ if (!alive()) return;
654
+ // 模型用来按单价估算没报费用的 agent(Codex)的金额
655
+ const current = getConversation(key);
656
+ const model = current ? this.displayRuntime(current, agentBinding(current, agent)).model : undefined;
657
+ recordUsage(holder.session?.sessionId, usage, cumulative, { owner: current?.owner, agent, model });
658
+ },
625
659
  onDropped: (n) => {
626
660
  if (!alive() || n <= 0) return;
627
661
  // 丢的是排在最后、还没开始的那 n 条(Codex 先报正在跑的那轮出错、再报丢弃,那时队头已经取走了)
@@ -630,6 +664,10 @@ class Hub extends EventEmitter<{ event: [EventEnvelope]; task: [TaskTurnEnd] }>
630
664
  if (tag) this.emit("task", { ...tag, key, status: "interrupted", text: "排队时被停止了" });
631
665
  }
632
666
  this.setInflight(key, (this.inflight.get(key) ?? n) - n);
667
+ // 第一条是在跑的那一轮(它自己会报结果),丢的只会是后面排队的
668
+ const fifo = this.turnFifo.get(key) ?? [];
669
+ const k = Math.min(n, Math.max(0, fifo.length - 1));
670
+ for (const e of fifo.splice(fifo.length - k, k)) this.emitEvent(key, { type: "queue", id: e.id, state: "dropped" });
633
671
  },
634
672
  onSessionId: (sessionId) => {
635
673
  // 切走之后旧会话晚到的回调不该覆盖新绑定
@@ -703,6 +741,7 @@ class Hub extends EventEmitter<{ event: [EventEnvelope]; task: [TaskTurnEnd] }>
703
741
  this.live.delete(key);
704
742
  for (const tag of this.turnTags.get(key) ?? []) if (tag) this.emit("task", { ...tag, key, status: "interrupted", text: note });
705
743
  this.turnTags.delete(key);
744
+ this.clearFifo(key);
706
745
  this.setInflight(key, 0);
707
746
  if (!existing) return;
708
747
  // dispose 卡住(CLI 不响应)不能把这条会话的操作队列永远堵死
@@ -831,6 +870,8 @@ class Hub extends EventEmitter<{ event: [EventEnvelope]; task: [TaskTurnEnd] }>
831
870
  attachments: input.attachments.map((a) => `${KIND_LABEL[a.kind]} ${a.name}`),
832
871
  files: input.attachments.map(attachmentInfo),
833
872
  origin,
873
+ id: `m${++this.msgSeq}`,
874
+ queued: false,
834
875
  };
835
876
  const by = `${origin.name ?? "用户"}以消息作答`;
836
877
 
@@ -865,29 +906,42 @@ class Hub extends EventEmitter<{ event: [EventEnvelope]; task: [TaskTurnEnd] }>
865
906
  updateConversation(key, { title: shown.text.trim().replace(/\s+/g, " ").slice(0, 40), titleAuto: true });
866
907
  this.emitEvent(key, { type: "meta" });
867
908
  }
909
+ // 前面有在跑的、或者还在串行队列里等着的:这条要排队
910
+ shown.queued = this.isBusy(key) || (this.waitingSends.get(key) ?? 0) > 0;
868
911
  this.emitEvent(key, shown);
912
+ const dropped = () => this.emitEvent(key, { type: "queue", id: shown.id, state: "dropped" });
869
913
  const gone = (text: string) => {
914
+ dropped();
870
915
  if (task) this.emit("task", { ...task, key, status: "interrupted", text });
871
916
  };
917
+ this.waitingSends.set(key, (this.waitingSends.get(key) ?? 0) + 1);
872
918
  void this.serial(key, async () => {
873
- if (cancelled()) return gone("没有发出(停止了或者会话被切换了)");
874
- if (questionAtEntry && tryAnswer(questionAtEntry)) return;
875
- // 收到之后发生过显式切换(恢复 / 分叉 / 新会话…):用户已经明确指定了会话,不再按旧的闲置时间作废它。
876
- // 定时任务也不按闲置过期:每天跑一次的任务要接着上次的上下文
877
- const ttl = config().sessionTtlMs;
878
- const switchedAfterEntry = (this.lastSwitch.get(key)?.at ?? 0) >= g.at;
879
- const expired = !task && !switchedAfterEntry && ttl > 0 && Date.now() - lastActive > ttl;
880
- if (expired) {
881
- await this.expireNow(key);
882
- // 清理要等旧会话 dispose,期间可能又点了停止
883
- if (cancelled()) return;
884
- }
885
- const blocked = await this.blockedSlash(key, input.text);
886
- if (blocked) {
887
- this.emitEvent(key, { type: "notice", text: blocked });
888
- return gone(blocked);
919
+ try {
920
+ if (cancelled()) return gone("没有发出(停止了或者会话被切换了)");
921
+ if (questionAtEntry && tryAnswer(questionAtEntry)) {
922
+ // 被当成提问的回答用掉了:不再排队
923
+ if (shown.queued) this.emitEvent(key, { type: "queue", id: shown.id, state: "started" });
924
+ return;
925
+ }
926
+ // 收到之后发生过显式切换(恢复 / 分叉 / 新会话…):用户已经明确指定了会话,不再按旧的闲置时间作废它。
927
+ // 定时任务也不按闲置过期:每天跑一次的任务要接着上次的上下文
928
+ const ttl = config().sessionTtlMs;
929
+ const switchedAfterEntry = (this.lastSwitch.get(key)?.at ?? 0) >= g.at;
930
+ const expired = !task && !switchedAfterEntry && ttl > 0 && Date.now() - lastActive > ttl;
931
+ if (expired) {
932
+ await this.expireNow(key);
933
+ // 清理要等旧会话 dispose,期间可能又点了停止
934
+ if (cancelled()) return dropped();
935
+ }
936
+ const blocked = await this.blockedSlash(key, input.text);
937
+ if (blocked) {
938
+ this.emitEvent(key, { type: "notice", text: blocked });
939
+ return gone(blocked);
940
+ }
941
+ this.sendNow(key, this.withMentions(key, input), task ?? null, { id: shown.id, queued: shown.queued });
942
+ } finally {
943
+ this.waitingSends.set(key, Math.max(0, (this.waitingSends.get(key) ?? 1) - 1));
889
944
  }
890
- this.sendNow(key, this.withMentions(key, input), task ?? null);
891
945
  });
892
946
  }
893
947
 
@@ -946,7 +1000,8 @@ class Hub extends EventEmitter<{ event: [EventEnvelope]; task: [TaskTurnEnd] }>
946
1000
  this.emitEvent(key, { type: "meta" });
947
1001
  }
948
1002
 
949
- private sendNow(key: string, input: UserInput, tag: TaskTurn | null): void {
1003
+ /** msg:页面上那条消息的编号和收到时是否排队(见 send 里的 shown) */
1004
+ private sendNow(key: string, input: UserInput, tag: TaskTurn | null, msg: { id: string; queued: boolean }): void {
950
1005
  const fail = (err: unknown) => {
951
1006
  const message = err instanceof Error ? err.message : String(err);
952
1007
  this.emitEvent(key, { type: "error", message });
@@ -963,12 +1018,22 @@ class Hub extends EventEmitter<{ event: [EventEnvelope]; task: [TaskTurnEnd] }>
963
1018
  const tags = this.turnTags.get(key) ?? [];
964
1019
  tags.push(tag);
965
1020
  this.turnTags.set(key, tags);
1021
+ const fifo = this.turnFifo.get(key) ?? [];
1022
+ fifo.push({ id: msg.id, input, queued: msg.queued });
1023
+ this.turnFifo.set(key, fifo);
966
1024
  try {
967
1025
  session.send(input);
968
1026
  } catch (err) {
969
1027
  tags.pop();
1028
+ fifo.pop();
970
1029
  this.setInflight(key, (this.inflight.get(key) ?? 1) - 1);
971
1030
  fail(err);
1031
+ return;
1032
+ }
1033
+ // 收到时在排队(前面的在串行队列里等着),交出去时前面已经空了:这就开始了
1034
+ if (fifo.length === 1 && msg.queued) {
1035
+ fifo[0]!.queued = false;
1036
+ this.emitEvent(key, { type: "queue", id: msg.id, state: "started" });
972
1037
  }
973
1038
  }
974
1039
 
@@ -1247,10 +1312,51 @@ class Hub extends EventEmitter<{ event: [EventEnvelope]; task: [TaskTurnEnd] }>
1247
1312
  // 和 inflight 一起清零:被打断的那一轮不一定还会报结果,留着的话下一轮的结果会被当成定时任务的
1248
1313
  for (const tag of this.turnTags.get(key) ?? []) if (tag) this.emit("task", { ...tag, key, status: "interrupted", text: "被停止了" });
1249
1314
  this.turnTags.delete(key);
1315
+ this.clearFifo(key);
1250
1316
  this.setInflight(key, 0);
1251
1317
  return "已请求中断。";
1252
1318
  }
1253
1319
 
1320
+ /**
1321
+ * 打断并发送:停掉正在跑的这一轮,排着队的 id 这条挪到最前面,马上处理它;其余排着的照常接在后面。
1322
+ * 和停止不同,不丢排队的消息、也不清计数 —— 被打断的这一轮会正常报结果(已中断),之后就轮到它
1323
+ */
1324
+ async jump(key: string, id: string): Promise<string> {
1325
+ const fifo = this.turnFifo.get(key) ?? [];
1326
+ const idx = fifo.findIndex((e) => e.id === id);
1327
+ if (idx === 0) return "这条已经在处理了。";
1328
+ const live = this.live.get(key);
1329
+ if (idx < 0 || !live) return "这条消息已经不在排队了。";
1330
+ const [entry] = fifo.splice(idx, 1);
1331
+ fifo.splice(1, 0, entry!);
1332
+ // turnTags 和 fifo 一一对应,一起挪
1333
+ const tags = this.turnTags.get(key);
1334
+ if (tags && tags.length === fifo.length) tags.splice(1, 0, ...tags.splice(idx, 1));
1335
+ this.stoppedAt.set(key, Date.now());
1336
+ interactions.cancelAll(key, "本轮已中断");
1337
+ await live.session.interrupt({ keepQueue: true, first: entry!.input });
1338
+ return "已打断,马上处理这条。";
1339
+ }
1340
+
1341
+ /** 一轮出结果了:队首出队,下一条要是排过队的,告诉页面轮到它了 */
1342
+ private finishTurn(key: string): void {
1343
+ const fifo = this.turnFifo.get(key);
1344
+ if (!fifo) return;
1345
+ fifo.shift();
1346
+ const next = fifo[0];
1347
+ if (next?.queued) {
1348
+ next.queued = false;
1349
+ this.emitEvent(key, { type: "queue", id: next.id, state: "started" });
1350
+ }
1351
+ if (fifo.length === 0) this.turnFifo.delete(key);
1352
+ }
1353
+
1354
+ /** 停止 / 会话丢了:在跑的那条不管(它的结果或中断自己会报),排着队的都没发出 */
1355
+ private clearFifo(key: string): void {
1356
+ for (const e of (this.turnFifo.get(key) ?? []).slice(1)) this.emitEvent(key, { type: "queue", id: e.id, state: "dropped" });
1357
+ this.turnFifo.delete(key);
1358
+ }
1359
+
1254
1360
  private async cdNow(key: string, dir: string, op: SwitchOp): Promise<string> {
1255
1361
  const resolved = resolveWorkspacePath(dir);
1256
1362
  if (!resolved) return `不允许:目录必须存在,且在 ${config().workspaceRoot} 之内(不能经符号链接跳出去)。`;
@@ -1515,6 +1621,7 @@ class Hub extends EventEmitter<{ event: [EventEnvelope]; task: [TaskTurnEnd] }>
1515
1621
  async shutdown(): Promise<void> {
1516
1622
  for (const key of [...this.live.keys()]) await this.drop(key, "服务正在重启");
1517
1623
  for (const driver of this.drivers.values()) await driver.stop().catch(() => {});
1624
+ flushUsage();
1518
1625
  }
1519
1626
  }
1520
1627
 
@@ -0,0 +1,103 @@
1
+ {
2
+ "chat-latest": [5,0.5,30],
3
+ "computer-use-preview": [3,null,12],
4
+ "computer-use-preview-2025-03-11": [3,null,12],
5
+ "daybreak-blue-latest": [4,0.4,20],
6
+ "daybreak-red-latest": [12.5,1.25,75],
7
+ "ft:gpt-3.5-turbo": [3,null,6],
8
+ "ft:gpt-3.5-turbo-0125": [3,null,6],
9
+ "ft:gpt-3.5-turbo-0613": [3,null,6],
10
+ "ft:gpt-3.5-turbo-1106": [3,null,6],
11
+ "ft:gpt-4-0613": [30,null,60],
12
+ "ft:gpt-4.1-2025-04-14": [3,0.75,12],
13
+ "ft:gpt-4.1-mini-2025-04-14": [0.8,0.2,3.2],
14
+ "ft:gpt-4.1-nano-2025-04-14": [0.2,0.05,0.8],
15
+ "ft:gpt-4o-2024-08-06": [3.75,1.875,15],
16
+ "ft:gpt-4o-2024-11-20": [3.75,null,15],
17
+ "ft:gpt-4o-mini-2024-07-18": [0.3,0.15,1.2],
18
+ "ft:o4-mini-2025-04-16": [4,1,16],
19
+ "gpt-3.5-turbo": [0.5,null,1.5],
20
+ "gpt-3.5-turbo-0125": [0.5,null,1.5],
21
+ "gpt-3.5-turbo-1106": [1,null,2],
22
+ "gpt-3.5-turbo-16k": [3,null,4],
23
+ "gpt-4": [30,null,60],
24
+ "gpt-4-0613": [30,null,60],
25
+ "gpt-4-1106-preview": [10,null,30],
26
+ "gpt-4-turbo": [10,null,30],
27
+ "gpt-4-turbo-2024-04-09": [10,null,30],
28
+ "gpt-4.1": [2,0.5,8],
29
+ "gpt-4.1-2025-04-14": [2,0.5,8],
30
+ "gpt-4.1-mini": [0.4,0.1,1.6],
31
+ "gpt-4.1-mini-2025-04-14": [0.4,0.1,1.6],
32
+ "gpt-4.1-nano": [0.1,0.025,0.4],
33
+ "gpt-4.1-nano-2025-04-14": [0.1,0.025,0.4],
34
+ "gpt-4o": [2.5,1.25,10],
35
+ "gpt-4o-2024-05-13": [5,null,15],
36
+ "gpt-4o-2024-08-06": [2.5,1.25,10],
37
+ "gpt-4o-2024-11-20": [2.5,1.25,10],
38
+ "gpt-4o-mini": [0.15,0.075,0.6],
39
+ "gpt-4o-mini-2024-07-18": [0.15,0.075,0.6],
40
+ "gpt-5": [1.25,0.125,10],
41
+ "gpt-5-2025-08-07": [1.25,0.125,10],
42
+ "gpt-5-chat": [1.25,0.125,10],
43
+ "gpt-5-chat-latest": [1.25,0.125,10],
44
+ "gpt-5-codex": [1.25,0.125,10],
45
+ "gpt-5-mini": [0.25,0.025,2],
46
+ "gpt-5-mini-2025-08-07": [0.25,0.025,2],
47
+ "gpt-5-nano": [0.05,0.005,0.4],
48
+ "gpt-5-nano-2025-08-07": [0.05,0.005,0.4],
49
+ "gpt-5-pro": [15,null,120],
50
+ "gpt-5-pro-2025-10-06": [15,null,120],
51
+ "gpt-5.1": [1.25,0.125,10],
52
+ "gpt-5.1-2025-11-13": [1.25,0.125,10],
53
+ "gpt-5.1-chat-latest": [1.25,0.125,10],
54
+ "gpt-5.1-codex": [1.25,0.125,10],
55
+ "gpt-5.1-codex-max": [1.25,0.125,10],
56
+ "gpt-5.1-codex-mini": [0.25,0.025,2],
57
+ "gpt-5.2": [1.75,0.175,14],
58
+ "gpt-5.2-2025-12-11": [1.75,0.175,14],
59
+ "gpt-5.2-chat-latest": [1.75,0.175,14],
60
+ "gpt-5.2-codex": [1.75,0.175,14],
61
+ "gpt-5.2-pro": [21,null,168],
62
+ "gpt-5.2-pro-2025-12-11": [21,null,168],
63
+ "gpt-5.3-chat-latest": [1.75,0.175,14],
64
+ "gpt-5.3-codex": [1.75,0.175,14],
65
+ "gpt-5.4": [2.5,0.25,15],
66
+ "gpt-5.4-2026-03-05": [2.5,0.25,15],
67
+ "gpt-5.4-mini": [0.75,0.075,4.5],
68
+ "gpt-5.4-mini-2026-03-17": [0.75,0.075,4.5],
69
+ "gpt-5.4-nano": [0.2,0.02,1.25],
70
+ "gpt-5.4-nano-2026-03-17": [0.2,0.02,1.25],
71
+ "gpt-5.4-pro": [30,null,180],
72
+ "gpt-5.4-pro-2026-03-05": [30,null,180],
73
+ "gpt-5.5": [5,0.5,30],
74
+ "gpt-5.5-2026-04-23": [5,0.5,30],
75
+ "gpt-5.5-cyber": [12.5,1.25,75],
76
+ "gpt-5.5-cyber-preview": [12.5,1.25,75],
77
+ "gpt-5.5-pro": [30,null,180],
78
+ "gpt-5.5-pro-2026-04-23": [30,null,180],
79
+ "gpt-5.6": [4,0.4,20],
80
+ "gpt-5.6-cyber": [12.5,1.25,75],
81
+ "gpt-5.6-luna": [0.2,0.02,1.2],
82
+ "gpt-5.6-sol": [4,0.4,20],
83
+ "gpt-5.6-terra": [2,0.2,12],
84
+ "gpt-6-astra": [10,1,50],
85
+ "gpt-6-luna": [0.1,0.01,0.5],
86
+ "gpt-6-sol": [2,0.2,10],
87
+ "gpt-6.1-sol": [2,0.1,10],
88
+ "gpt-daybreak-blue-latest": [4,0.4,20],
89
+ "gpt-daybreak-red-latest": [12.5,1.25,75],
90
+ "gpt-rosalind-discovery": [5,0.5,25],
91
+ "o1": [15,7.5,60],
92
+ "o1-2024-12-17": [15,7.5,60],
93
+ "o1-pro": [150,null,600],
94
+ "o1-pro-2025-03-19": [150,null,600],
95
+ "o3": [2,0.5,8],
96
+ "o3-2025-04-16": [2,0.5,8],
97
+ "o3-mini": [1.1,0.55,4.4],
98
+ "o3-mini-2025-01-31": [1.1,0.55,4.4],
99
+ "o3-pro": [20,null,80],
100
+ "o3-pro-2025-06-10": [20,null,80],
101
+ "o4-mini": [1.1,0.275,4.4],
102
+ "o4-mini-2025-04-16": [1.1,0.275,4.4]
103
+ }
@@ -0,0 +1,93 @@
1
+ // 按单价估算没报费用的 agent(Codex)的金额:Token × 单价。
2
+ //
3
+ // 单价来自 LiteLLM 公开维护的价格表(model_prices_and_context_window.json),只取 OpenAI 的对话模型。
4
+ // 内置一份快照(model-prices.json,[输入, 缓存读取, 输出],美元 / 百万 token),连得上 GitHub 时
5
+ // 每天更新一次存到 data/model-prices.json,连不上就一直用快照。
6
+ // 只按基础单价算:长上下文加价、priority / flex 档位都不管,所以是估算。
7
+ import fs from "node:fs";
8
+ import path from "node:path";
9
+ import { dataDir, readJson, writeJsonAtomic } from "../home.js";
10
+ import type { TokenUsage } from "./types.js";
11
+
12
+ /** [输入, 缓存读取(null 表示按输入算), 输出],美元 / 百万 token */
13
+ type Price = [number, number | null, number];
14
+ type Table = Record<string, Price>;
15
+ type Cache = { at: number; prices: Table };
16
+
17
+ const SOURCE = "https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json";
18
+ const CACHE_FILE = () => path.join(dataDir, "model-prices.json");
19
+ const MAX_AGE = 24 * 3600_000;
20
+ /** 拉失败了隔多久再试 */
21
+ const RETRY = 3600_000;
22
+
23
+ const snapshot: Table = JSON.parse(fs.readFileSync(new URL("./model-prices.json", import.meta.url), "utf8"));
24
+ let cache: Cache | null = null;
25
+ let fetching = false;
26
+ let lastTry = 0;
27
+
28
+ function loadCache(): Cache {
29
+ return (cache ??= readJson<Cache>(CACHE_FILE(), { at: 0, prices: {} }));
30
+ }
31
+
32
+ /** 从 LiteLLM 的整张表里挑出 OpenAI 对话模型,换成每百万 token 的价格 */
33
+ export function pickOpenAiPrices(raw: Record<string, unknown>): Table {
34
+ const perM = (v: unknown): number | null =>
35
+ typeof v === "number" && Number.isFinite(v) && v >= 0 ? Math.round(v * 1e12) / 1e6 : null;
36
+ const out: Table = {};
37
+ for (const [name, entry] of Object.entries(raw)) {
38
+ if (!entry || typeof entry !== "object") continue;
39
+ const e = entry as Record<string, unknown>;
40
+ if (e.litellm_provider !== "openai" || (e.mode !== "chat" && e.mode !== "responses")) continue;
41
+ const input = perM(e.input_cost_per_token);
42
+ const output = perM(e.output_cost_per_token);
43
+ if (input === null || output === null) continue;
44
+ out[name] = [input, perM(e.cache_read_input_token_cost), output];
45
+ }
46
+ return out;
47
+ }
48
+
49
+ async function refresh(): Promise<void> {
50
+ fetching = true;
51
+ lastTry = Date.now();
52
+ try {
53
+ const res = await fetch(SOURCE, { signal: AbortSignal.timeout(30_000) });
54
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
55
+ const prices = pickOpenAiPrices((await res.json()) as Record<string, unknown>);
56
+ if (Object.keys(prices).length === 0) throw new Error("价格表里没有 OpenAI 模型");
57
+ cache = { at: Date.now(), prices };
58
+ writeJsonAtomic(CACHE_FILE(), cache);
59
+ } catch (err) {
60
+ console.warn("[pricing] 更新模型单价失败,先用内置的:", err instanceof Error ? err.message : err);
61
+ } finally {
62
+ fetching = false;
63
+ }
64
+ }
65
+
66
+ /** 过期了就在后台更新一次(不等它,这次先用手上的) */
67
+ function refreshIfStale(): void {
68
+ // 跑测试时不联网
69
+ if (process.env.NODE_TEST_CONTEXT) return;
70
+ if (fetching || Date.now() - loadCache().at < MAX_AGE || Date.now() - lastTry < RETRY) return;
71
+ void refresh();
72
+ }
73
+
74
+ /** 模型的单价。认不出来返回 undefined。名字带 openai/ 前缀、日期后缀(-2026-03-05)的也认 */
75
+ export function priceOf(model: string | undefined): Price | undefined {
76
+ if (!model) return undefined;
77
+ refreshIfStale();
78
+ const table = loadCache().prices;
79
+ const base = model.replace(/^openai\//, "");
80
+ for (const name of [base, base.replace(/-\d{4}-\d{2}-\d{2}$/, "")]) {
81
+ const p = table[name] ?? snapshot[name];
82
+ if (p) return p;
83
+ }
84
+ return undefined;
85
+ }
86
+
87
+ /** 按单价估算的金额(美元)。output 已经含推理,不再单算 reasoning;认不出模型返回 undefined */
88
+ export function estimateCost(model: string | undefined, usage: TokenUsage): number | undefined {
89
+ const p = priceOf(model);
90
+ if (!p) return undefined;
91
+ const [input, cacheRead, output] = p;
92
+ return (usage.input * input + usage.cacheRead * (cacheRead ?? input) + usage.cacheWrite * input + usage.output * output) / 1e6;
93
+ }
@@ -345,7 +345,7 @@ export function skillsText(owner: string | undefined): string {
345
345
  if (loft.length) {
346
346
  lines.push(`**能用的技能(${loft.length} 个)**`, "", ...loft.map((t) => `- \`@${t.name}\` ${t.description}`));
347
347
  } else {
348
- lines.push("还没有启用任何技能。可以在网页侧栏「技能」里新建、上传,或者从技能广场安装;也可以直接让 agent「把刚才的做法存成技能」。");
348
+ lines.push("还没有启用任何技能。可以在网页「设置 → 技能」里新建、上传;也可以直接让 agent「把刚才的做法存成技能」。");
349
349
  }
350
350
  if (machine.length) lines.push("", `**本机技能(只有管理员能用,${machine.length} 个)**:${machine.map((t) => `\`@${t.name}\``).join(" ")}`);
351
351
  lines.push("", "在消息里写 @名字 点名使用;工作模式下 agent 也会自己挑合适的用。");
@@ -1,7 +1,8 @@
1
1
  // 技能(skill):一份 SKILL.md 加上它附带的脚本、资料,agent 需要时照着做。和 Claude Code / Codex 自己的
2
2
  // skill 是同一种格式,只是由 agent-loft 按人管理:
3
3
  // - 谁都能建(网页上写、上传技能包,或者让 agent 在对话里生成),建的人是作者;
4
- // - 作者决定谁看得到:只有自己 / 指定的几个人 / 所有人。看得到的人在「技能广场」里安装;
4
+ // - 作者决定谁看得到:只有自己 / 指定的几个人 / 所有人,看得到的人可以安装。网页上已经不再提供分享和技能广场
5
+ // (新建的都是仅自己),这套数据留着是为了以前分享、安装过的照常能用;
5
6
  // - 安装的是作者那一份本身,不是副本:作者改了,装了的人下一条消息起就用上新的;
6
7
  // - 技能可以声明要用的环境变量(API 地址、密钥之类),值由每个用户自己填,只注入到他自己的会话里。
7
8
  //
package/src/core/types.ts CHANGED
@@ -100,11 +100,22 @@ export type AgentEvents = {
100
100
  onDropped?: (n: number) => void;
101
101
  /** 上下文窗口占用更新了(一般在每轮结束后) */
102
102
  onContext?: (ctx: ContextUsage) => void;
103
+ /**
104
+ * token 用量更新了。cumulative = 报的是这个会话到目前的累计值(Claude,每轮结束报一次);
105
+ * 否则是新增的量(Codex,每次模型请求报一次),由核心累加
106
+ */
107
+ onUsage?: (usage: TokenUsage, cumulative?: boolean) => void;
103
108
  };
104
109
 
105
110
  /** 上下文窗口占用:used / window 都是 token 数,percent 是 0-100(可能略超 100) */
106
111
  export type ContextUsage = { used: number; window: number; percent: number };
107
112
 
113
+ /**
114
+ * token 用量。input 是没命中缓存的输入;cacheRead 是命中缓存读的;cacheWrite 是写进缓存的(Claude 单独计费);
115
+ * output 含推理,reasoning 是其中推理的部分(Codex 才有)。costUsd 是 agent 自己算的费用(Claude 才有)
116
+ */
117
+ export type TokenUsage = { input: number; cacheRead: number; cacheWrite: number; output: number; reasoning?: number; costUsd?: number };
118
+
108
119
  /** 账号额度(订阅的用量窗口)。没有订阅额度(API key 之类)时 driver 返回 null */
109
120
  export type QuotaInfo = {
110
121
  plan?: string;
@@ -148,8 +159,13 @@ export type SessionSummary = {
148
159
  };
149
160
 
150
161
  export type HistoryMessage = {
151
- role: "user" | "assistant";
162
+ /** question:agent 问过的问题和你的回答(答过的提问卡,刷新后照样显示) */
163
+ role: "user" | "assistant" | "question";
152
164
  text: string;
165
+ /** 助手消息:被提问卡打断的前半段(只显示过程,不带复制 / 分叉 / 脚注) */
166
+ segment?: boolean;
167
+ /** 提问:问了什么、选了哪些(按问题下标)、自由作答的内容;skipped = 没作答(跳过 / 超时) */
168
+ question?: { id: string; title: string; questions: QuestionSpec[]; selections: number[][]; freeText: Record<number, string>; skipped?: boolean };
153
169
  /** 助手这一条之前的过程摘要 */
154
170
  steps?: string[];
155
171
  /** 毫秒时间戳;助手消息是这一轮结束的时间 */
@@ -213,10 +229,16 @@ export type SessionSkills = {
213
229
 
214
230
  export type ConversationMode = "chat" | "work";
215
231
 
232
+ /**
233
+ * 中断的方式。默认(停止):这一轮和排着队的都不要了。
234
+ * keepQueue(打断并发送):只停这一轮,排着的留着;给了 first 就把它挪到最前面,这一轮一结束就轮到它
235
+ */
236
+ export type InterruptOptions = { keepQueue?: boolean; first?: UserInput };
237
+
216
238
  export interface AgentSession {
217
239
  readonly sessionId: string | undefined;
218
240
  send(input: UserInput): void;
219
- interrupt(): Promise<void>;
241
+ interrupt(opts?: InterruptOptions): Promise<void>;
220
242
  setModel(model?: string, effort?: string): Promise<void>;
221
243
  describeRuntime(): Promise<RuntimeInfo>;
222
244
  /** 手上已知的模型 / 思考程度,同步返回:不拉起进程、不发请求 */