c0de-agent 1.12.0 → 1.13.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.
Files changed (142) hide show
  1. package/README.md +51 -23
  2. package/dist/cli/commands/acp.js +26 -8
  3. package/dist/cli/commands/chat.js +70 -6
  4. package/dist/cli/commands/config.d.ts +2 -0
  5. package/dist/cli/commands/config.js +46 -0
  6. package/dist/cli/commands/serve.js +11 -2
  7. package/dist/cli/commands/sessions.js +51 -4
  8. package/dist/cli/commands/trust.d.ts +11 -0
  9. package/dist/cli/commands/trust.js +31 -0
  10. package/dist/cli/commands/update.js +4 -0
  11. package/dist/cli/deps.d.ts +9 -6
  12. package/dist/cli/deps.js +66 -19
  13. package/dist/cli/index.d.ts +4 -1
  14. package/dist/cli/index.js +92 -12
  15. package/dist/cli/modes/acp.js +39 -8
  16. package/dist/cli/modes/print.d.ts +3 -0
  17. package/dist/cli/modes/print.js +71 -8
  18. package/dist/core/agent.d.ts +1 -1
  19. package/dist/core/agent.js +9 -4
  20. package/dist/core/agents/builtin.js +2 -2
  21. package/dist/core/agents/index.d.ts +0 -1
  22. package/dist/core/agents/index.js +0 -1
  23. package/dist/core/config.d.ts +43 -1
  24. package/dist/core/config.js +163 -14
  25. package/dist/core/loop/segment.d.ts +3 -1
  26. package/dist/core/loop/segment.js +24 -1
  27. package/dist/core/loop/subagent.js +65 -32
  28. package/dist/core/loop.d.ts +7 -0
  29. package/dist/core/loop.js +90 -6
  30. package/dist/core/slash.d.ts +10 -0
  31. package/dist/core/slash.js +150 -20
  32. package/dist/core/types.d.ts +20 -1
  33. package/dist/core/workflows/context.js +44 -4
  34. package/dist/core/workflows/discovery.d.ts +2 -2
  35. package/dist/core/workflows/discovery.js +5 -7
  36. package/dist/core/workflows/index.d.ts +2 -1
  37. package/dist/core/workflows/index.js +2 -1
  38. package/dist/core/workflows/registry.d.ts +13 -3
  39. package/dist/core/workflows/registry.js +14 -9
  40. package/dist/core/workflows/resolve.d.ts +22 -0
  41. package/dist/core/workflows/resolve.js +23 -0
  42. package/dist/core/workflows/runtime.d.ts +6 -1
  43. package/dist/core/workflows/runtime.js +19 -2
  44. package/dist/db/schema.d.ts +356 -1
  45. package/dist/db/schema.js +57 -3
  46. package/dist/kanban/index.d.ts +2 -1
  47. package/dist/kanban/index.js +1 -1
  48. package/dist/kanban/store.d.ts +84 -2
  49. package/dist/kanban/store.js +358 -25
  50. package/dist/llm/registry.d.ts +7 -1
  51. package/dist/llm/registry.js +7 -1
  52. package/dist/plugins/init.d.ts +7 -0
  53. package/dist/plugins/init.js +22 -1
  54. package/dist/plugins/loader.d.ts +7 -1
  55. package/dist/plugins/loader.js +12 -4
  56. package/dist/project/index.d.ts +1 -1
  57. package/dist/project/index.js +1 -1
  58. package/dist/project/project.d.ts +23 -0
  59. package/dist/project/project.js +65 -0
  60. package/dist/project/trust.d.ts +65 -0
  61. package/dist/project/trust.js +328 -0
  62. package/dist/server/agent-manager.d.ts +6 -2
  63. package/dist/server/agent-manager.js +4 -2
  64. package/dist/server/app.js +45 -30
  65. package/dist/server/auth-manager.d.ts +15 -4
  66. package/dist/server/auth-manager.js +41 -24
  67. package/dist/server/context.js +11 -1
  68. package/dist/server/index.d.ts +1 -1
  69. package/dist/server/index.js +1 -1
  70. package/dist/server/middleware/auth.d.ts +11 -1
  71. package/dist/server/middleware/auth.js +25 -3
  72. package/dist/server/permission/interactive.d.ts +5 -0
  73. package/dist/server/permission/interactive.js +4 -1
  74. package/dist/server/permission/store.d.ts +2 -0
  75. package/dist/server/permission/store.js +8 -0
  76. package/dist/server/routes/auth.js +35 -9
  77. package/dist/server/routes/chat.d.ts +16 -7
  78. package/dist/server/routes/chat.js +483 -28
  79. package/dist/server/routes/commands.d.ts +2 -2
  80. package/dist/server/routes/commands.js +4 -6
  81. package/dist/server/routes/config.js +81 -4
  82. package/dist/server/routes/files.js +120 -24
  83. package/dist/server/routes/kanban.js +152 -22
  84. package/dist/server/routes/permissions.js +7 -0
  85. package/dist/server/routes/project.js +76 -22
  86. package/dist/server/routes/provider.js +6 -0
  87. package/dist/server/routes/session.js +177 -13
  88. package/dist/server/routes/tool.js +24 -5
  89. package/dist/server/routes/update.js +58 -2
  90. package/dist/server/routes/usage.d.ts +4 -0
  91. package/dist/server/routes/usage.js +133 -0
  92. package/dist/server/routes/workflows.js +476 -87
  93. package/dist/server/server.d.ts +12 -1
  94. package/dist/server/server.js +194 -33
  95. package/dist/server/terminal/pty-manager.d.ts +12 -0
  96. package/dist/server/terminal/pty-manager.js +63 -7
  97. package/dist/server/types.d.ts +9 -0
  98. package/dist/session/branch.d.ts +2 -1
  99. package/dist/session/branch.js +75 -10
  100. package/dist/session/context.js +28 -2
  101. package/dist/session/index.d.ts +1 -1
  102. package/dist/session/index.js +1 -1
  103. package/dist/session/session.d.ts +67 -10
  104. package/dist/session/session.js +180 -67
  105. package/dist/session/types.d.ts +2 -0
  106. package/dist/session/usage.d.ts +62 -0
  107. package/dist/session/usage.js +194 -0
  108. package/dist/shared/types/agent.d.ts +19 -3
  109. package/dist/shared/types/config.d.ts +59 -4
  110. package/dist/shared/types/kanban.d.ts +4 -2
  111. package/dist/shared/types/message.d.ts +14 -0
  112. package/dist/tools/builtin/kanban.js +13 -2
  113. package/dist/tools/index.d.ts +14 -3
  114. package/dist/tools/index.js +23 -5
  115. package/dist/tools/websearch/index.d.ts +5 -1
  116. package/dist/tools/websearch/index.js +40 -4
  117. package/dist/tools/websearch/providers/brave.js +2 -2
  118. package/dist/tools/websearch/providers/duckduckgo.js +2 -1
  119. package/dist/tools/websearch/providers/tavily.js +2 -2
  120. package/dist/tools/websearch/types.d.ts +5 -1
  121. package/dist/tools/websearch/types.js +7 -1
  122. package/dist/update/scheduler.js +5 -2
  123. package/dist/update/snapshot.d.ts +17 -3
  124. package/dist/update/snapshot.js +3 -2
  125. package/dist/update/updater.d.ts +2 -0
  126. package/dist/update/updater.js +2 -1
  127. package/dist/update/version.d.ts +3 -0
  128. package/dist/update/version.js +9 -3
  129. package/drizzle/0010_usage_events_ledger.sql +17 -0
  130. package/drizzle/0011_slow_slapstick.sql +12 -0
  131. package/drizzle/0012_chief_timeslip.sql +1 -0
  132. package/drizzle/0013_swift_zeigeist.sql +1 -0
  133. package/drizzle/0014_flimsy_legion.sql +1 -0
  134. package/drizzle/meta/0010_snapshot.json +1010 -0
  135. package/drizzle/meta/0011_snapshot.json +1055 -0
  136. package/drizzle/meta/0012_snapshot.json +1062 -0
  137. package/drizzle/meta/0013_snapshot.json +1068 -0
  138. package/drizzle/meta/0014_snapshot.json +1075 -0
  139. package/drizzle/meta/_journal.json +35 -0
  140. package/package.json +24 -23
  141. package/dist/core/agents/discovery.d.ts +0 -6
  142. package/dist/core/agents/discovery.js +0 -96
@@ -1,7 +1,8 @@
1
- import { and, eq, gt, ilike, inArray, isNull, lt, ne, notExists, or, sql } from 'drizzle-orm';
1
+ import { and, eq, gt, ilike, inArray, isNull, lt, ne, notExists, notInArray, or, sql, } from 'drizzle-orm';
2
2
  import { alias } from 'drizzle-orm/pg-core';
3
3
  import { sessionEntries, sessions } from '../db/schema.js';
4
4
  import { generateId } from '../shared/index.js';
5
+ import { getEntries } from './message.js';
5
6
  /** Convert a DB row (with Date timestamps) to the shared Session type (with number timestamps). */
6
7
  export function rowToSession(row) {
7
8
  const created = row.createdAt instanceof Date ? row.createdAt.getTime() : new Date(row.createdAt).getTime();
@@ -29,7 +30,11 @@ export function rowToSession(row) {
29
30
  /** Create a new root session. */
30
31
  async function createSession(handle, title, projectId, agentType, source,
31
32
  /** P1 会话树治理:子 agent 会话挂到父会话(树内嵌套 + 删除级联)。 */
32
- parentId) {
33
+ parentId,
34
+ /** 会话工作目录(CLI 会话必填:Web 打开时 agent 工具在该目录执行,而非 serve cwd)。 */
35
+ worktreePath,
36
+ /** 初始 metadata(如工作流运行会话记录 workflowName,供中断恢复指引展示)。 */
37
+ metadata) {
33
38
  const [row] = await handle.db
34
39
  .insert(sessions)
35
40
  .values({
@@ -38,23 +43,36 @@ parentId) {
38
43
  agentType: agentType ?? null,
39
44
  source: source ?? null,
40
45
  parentId: parentId ?? null,
46
+ worktreePath: worktreePath ?? null,
47
+ ...(metadata ? { metadata } : {}),
41
48
  })
42
49
  .returning();
43
50
  if (!row)
44
51
  throw new Error('Failed to insert session');
45
52
  return rowToSession(row);
46
53
  }
54
+ /**
55
+ * Web 会话树可见性条件:web 会话(source 为 null/web)或「持久的 CLI 会话」。
56
+ * 一次性 CLI print 会话与 CLI 来源的 workflow 会话是临时数据,隐藏;
57
+ * Web 来源的 workflow 会话(source=null)在树中可见(标题带时间戳)。
58
+ * 两类临时会话到期后统一**移入回收站**(purgeTemporarySessions),不再物理清除——
59
+ * 可见节点(含其子 agent 会话)享有 60 天可恢复承诺。
60
+ * --continue 续接后 agentType 已清除(upgradeTemporarySession),即对 Web 可见。
61
+ */
62
+ export function webVisibleSessionCondition() {
63
+ return or(isNull(sessions.source), ne(sessions.source, 'cli'), and(eq(sessions.source, 'cli'), or(isNull(sessions.agentType), notInArray(sessions.agentType, ['print', 'workflow']))));
64
+ }
47
65
  /** Get a session by id, or null if not found. */
48
66
  async function getSession(handle, id) {
49
67
  const [row] = await handle.db.select().from(sessions).where(eq(sessions.id, id));
50
68
  return row ? rowToSession(row) : null;
51
69
  }
52
- /** List all active sessions (未软删除、非 CLI 来源;source 为 NULL 的旧数据视为 web). */
70
+ /** List all active sessions(未软删除、Web 树可见:web 会话 + 持久化 CLI 会话)。 */
53
71
  async function listSessions(handle) {
54
72
  const rows = await handle.db
55
73
  .select()
56
74
  .from(sessions)
57
- .where(and(isNull(sessions.deletedAt), or(isNull(sessions.source), ne(sessions.source, 'cli'))));
75
+ .where(and(isNull(sessions.deletedAt), webVisibleSessionCondition()));
58
76
  return rows.map(rowToSession);
59
77
  }
60
78
  /** 列出所有未软删除会话(含 CLI 来源;供 ACP/CLI 等非 Web 消费者使用)。 */
@@ -84,8 +102,11 @@ async function listOrphanDeletedSessions(handle) {
84
102
  }
85
103
  /**
86
104
  * 标记回收站条目「已被用户看到」,记录 metadata.trashSeenAt = now(仅首次,不重置)。
87
- * 回收站保留期自首次看到起算(见 purgeDeletedSessions)。软删除时会清除旧标记,
88
- * 恢复后再次删除须重新看到回收站才重新起算——避免「未重新看到就被过早清空」。
105
+ * 回收站保留期自首次看到起算(见 purgeDeletedSessions)。语义为分组粒度(M1):
106
+ * 一次调用标记 scope 内全部条目——「首次看到」指用户打开该回收站分组,而非逐条
107
+ * 浏览到某个条目;分组内条目同时起算,列表按到期先后置顶展示以补偿该粒度。
108
+ * 软删除时会清除旧标记,恢复后再次删除须重新看到回收站才重新起算——
109
+ * 避免「未重新看到就被过早清空」。
89
110
  * scope.orphan=true 时仅标记未归属项目的孤儿会话(与 listOrphanDeletedSessions 对齐);
90
111
  * 孤儿标记仅在用户展开「未归属项目」分组时由前端显式调用(POST /deleted/orphans/seen),
91
112
  * 避免打开任意项目回收站连带启动无关孤儿条目的倒计时。
@@ -276,6 +297,12 @@ async function restoreSession(handle, id, opts = {}) {
276
297
  * 仍为 30 天,见 purgeTemporarySessions。
277
298
  */
278
299
  export const TRASH_RETENTION_MS = 60 * 24 * 60 * 60 * 1000;
300
+ /**
301
+ * 绝对上限(365 天):自删除起超过此时长的条目,无论是否被看到都进入宽限期清理。
302
+ * 兜底「用户从不开回收站 → trashSeenAt 永不写入 → 条目永不清理」的无界增长死角。
303
+ * 到期仍走先标记(宽限 7 天、UI 显示「即将清除」可恢复)再物理清除的两阶段流程。
304
+ */
305
+ export const TRASH_ABSOLUTE_MAX_MS = 365 * 24 * 60 * 60 * 1000;
279
306
  /**
280
307
  * A3:到期标记 → 物理清除的宽限期(默认 7 天)。
281
308
  * 条目到期后先写 metadata.purgePendingAt,UI 显示「即将清除」并可恢复;
@@ -284,13 +311,15 @@ export const TRASH_RETENTION_MS = 60 * 24 * 60 * 60 * 1000;
284
311
  export const TRASH_PURGE_GRACE_MS = 7 * 24 * 60 * 60 * 1000;
285
312
  /**
286
313
  * 回收站两阶段清理(启动时与每日定时调用):
287
- * 阶段一:trashSeenAt 超过保留期且未标记 → 标记 purgePendingAt(进入宽限期,可恢复)。
314
+ * 阶段一:进入保留期截止(首次看到 + 60 天)或绝对上限(删除 + 365 天,无论是否
315
+ * 被看到)且未标记 → 标记 purgePendingAt(进入宽限期,可恢复)。
288
316
  * 阶段二:purgePendingAt 早于宽限截止 → 物理清除(子会话先于父,自引用 FK 要求)。
289
- * 未被看到过(trashSeenAt 缺失)或恢复后重删、尚未重新看到的会话永不进入任一阶段。
317
+ * 恢复后重删、尚未重新看到的会话按「首次看到 + 60 天」计(绝对上限兜底仍生效)。
290
318
  */
291
319
  async function purgeDeletedSessions(handle, retentionMs = TRASH_RETENTION_MS, graceMs = TRASH_PURGE_GRACE_MS) {
292
320
  const retentionCutoff = Date.now() - retentionMs;
293
321
  const graceCutoff = Date.now() - graceMs;
322
+ const absoluteCutoff = Date.now() - TRASH_ABSOLUTE_MAX_MS;
294
323
  const rows = await handle.db
295
324
  .select({
296
325
  id: sessions.id,
@@ -308,8 +337,11 @@ async function purgeDeletedSessions(handle, retentionMs = TRASH_RETENTION_MS, gr
308
337
  const meta = (r.metadata ?? {});
309
338
  const seen = meta.trashSeenAt;
310
339
  const deletedMs = r.deletedAt.getTime();
311
- // 保留期自「首次看到」起算:seen 缺失或早于最近一次删除 → 未重新看到,不标记。
312
- if (typeof seen !== 'number' || seen <= deletedMs || seen >= retentionCutoff)
340
+ // 保留期自「首次看到」起算:seen 缺失或早于最近一次删除 → 未重新看到,不计。
341
+ const seenExpired = typeof seen === 'number' && seen > deletedMs && seen < retentionCutoff;
342
+ // 绝对上限兜底:删除后长期未被看到也进入宽限期,防条目永不清理的无界增长。
343
+ const absoluteExpired = deletedMs < absoluteCutoff;
344
+ if (!seenExpired && !absoluteExpired)
313
345
  continue;
314
346
  if (typeof meta.purgePendingAt === 'number')
315
347
  continue;
@@ -380,6 +412,36 @@ async function purgeEmptySessions(handle, retentionMs = EMPTY_SESSION_RETENTION_
380
412
  async function updateSessionTitle(handle, id, title) {
381
413
  await handle.db.update(sessions).set({ title, updatedAt: new Date() }).where(eq(sessions.id, id));
382
414
  }
415
+ /**
416
+ * P3-9:物理删除一个「真空会话」——无任何条目且无子会话(首条消息发送失败后
417
+ * 的前端清理路径使用)。非空会话绝不触碰(返回 false),防误删走错回收站语义。
418
+ * 软删除会话(回收站条目)不在本路径处理。
419
+ */
420
+ async function purgeEmptySession(handle, id) {
421
+ const [row] = await handle.db
422
+ .select({ id: sessions.id, deletedAt: sessions.deletedAt })
423
+ .from(sessions)
424
+ .where(eq(sessions.id, id));
425
+ if (!row || row.deletedAt)
426
+ return false;
427
+ const children = alias(sessions, 'purge_children');
428
+ const [entry] = await handle.db
429
+ .select({ one: sql `1` })
430
+ .from(sessionEntries)
431
+ .where(eq(sessionEntries.sessionId, id))
432
+ .limit(1);
433
+ if (entry)
434
+ return false;
435
+ const [child] = await handle.db
436
+ .select({ one: sql `1` })
437
+ .from(children)
438
+ .where(eq(children.parentId, id))
439
+ .limit(1);
440
+ if (child)
441
+ return false;
442
+ await handle.db.delete(sessions).where(eq(sessions.id, id));
443
+ return true;
444
+ }
383
445
  /**
384
446
  * 彻底删除某个回收站会话及其全部后代(含未软删除的 fork 后代,防御数据异常)。
385
447
  * 子会话先于父会话删除(自引用 FK RESTRICT 要求);entries/archives 经 FK cascade 清理。
@@ -466,70 +528,36 @@ async function emptyTrash(handle, projectId) {
466
528
  return deleted;
467
529
  }
468
530
  /**
469
- * 清理过期临时会话(P2 → P1 收紧:仅清理显式标记的临时会话)。
531
+ * 清理过期临时会话(P2 → P1 收紧:仅处理显式标记的临时会话)。
470
532
  * - agentType='print':CLI 一次性问答(c0de chat,非 --continue)创建的会话。
471
533
  * - agentType='workflow':工作流运行产生的会话。
472
534
  * 普通 CLI 会话(ACP)与 --continue 续接的会话(续接时已升级)永不清理——
473
535
  * 此前按 source='cli' 全删会把用户显式续接的历史静默物理删除。
474
- * 保留期默认 30 天;子条目经 FK cascade 一并删除。
475
- * 返回清除数量。启动时与每日定时调用。
536
+ * 保留期默认 30 天。
537
+ *
538
+ * P1(本轮):清理方式从物理删除改为**移入回收站**(softDeleteSession)——
539
+ * 工作流/print 会话的子 agent 会话在 Web 树中可见,此前「30 天到点即物理清除」
540
+ * 让可见节点无回收站保护地凭空消失,与「60 天可恢复」的产品承诺冲突。
541
+ * 软删除级联同批次后代;随后由 purgeDeletedSessions 的两阶段回收站机制
542
+ * (trashSeenAt 60 天 / 绝对上限 365 天 + 7 天宽限)统一兜底。
543
+ * 返回本次移入回收站的会话数。
476
544
  */
477
545
  async function purgeTemporarySessions(handle, retentionMs = 30 * 24 * 60 * 60 * 1000) {
478
546
  const cutoff = new Date(Date.now() - retentionMs);
479
- const all = await handle.db
480
- .select({
481
- id: sessions.id,
482
- parentId: sessions.parentId,
483
- agentType: sessions.agentType,
484
- updatedAt: sessions.updatedAt,
485
- })
486
- .from(sessions);
487
- const roots = all.filter((r) => (r.agentType === 'print' || r.agentType === 'workflow') &&
547
+ const rows = await handle.db
548
+ .select({ id: sessions.id, agentType: sessions.agentType, updatedAt: sessions.updatedAt })
549
+ .from(sessions)
550
+ .where(isNull(sessions.deletedAt));
551
+ const roots = rows.filter((r) => (r.agentType === 'print' || r.agentType === 'workflow') &&
488
552
  r.updatedAt != null &&
489
553
  r.updatedAt.getTime() < cutoff.getTime());
490
- if (roots.length === 0)
491
- return 0;
492
- // 临时会话可能派发过子 agent(其子会话挂 parentId)。父会话直接删除会撞自引用
493
- // FK(RESTRICT),且子会话失去父后成为游离节点——故连同全部后代一起物理清除,
494
- // 并按拓扑序「子先于父」删除(与 emptyTrash / purgeDeletedSessions 同一策略)。
495
- const childrenOf = new Map();
496
- for (const r of all) {
497
- if (r.parentId) {
498
- const list = childrenOf.get(r.parentId) ?? [];
499
- list.push(r.id);
500
- childrenOf.set(r.parentId, list);
501
- }
502
- }
503
- const ids = new Set();
504
- const collect = (sid) => {
505
- if (ids.has(sid))
506
- return;
507
- ids.add(sid);
508
- for (const c of childrenOf.get(sid) ?? [])
509
- collect(c);
510
- };
511
- for (const r of roots)
512
- collect(r.id);
513
- const remaining = new Set(ids);
514
- let deleted = 0;
515
- while (remaining.size > 0) {
516
- const leaves = Array.from(remaining).filter((sid) => !(childrenOf.get(sid) ?? []).some((c) => remaining.has(c)));
517
- if (leaves.length === 0) {
518
- // 循环引用兜底:强制逐个删除(数据异常场景)
519
- for (const sid of Array.from(remaining)) {
520
- await handle.db.delete(sessions).where(eq(sessions.id, sid));
521
- remaining.delete(sid);
522
- deleted += 1;
523
- }
524
- break;
525
- }
526
- for (const sid of leaves) {
527
- await handle.db.delete(sessions).where(eq(sessions.id, sid));
528
- remaining.delete(sid);
529
- deleted += 1;
530
- }
554
+ let trashed = 0;
555
+ for (const r of roots) {
556
+ // softDeleteSession 级联标记其后代(同 deletedBatchId),恢复父即恢复子树。
557
+ if (await softDeleteSession(handle, r.id))
558
+ trashed += 1;
531
559
  }
532
- return deleted;
560
+ return trashed;
533
561
  }
534
562
  /**
535
563
  * 把临时会话升级为持久会话(--continue 续接时调用):
@@ -640,11 +668,48 @@ async function updateSessionLastRun(handle, id, lastRun) {
640
668
  .set({ metadata: { ...meta, lastRun }, updatedAt: new Date() })
641
669
  .where(eq(sessions.id, id));
642
670
  }
671
+ /**
672
+ * P2-3:预算超支暂停时把原因写入 metadata.budgetPauseReason。
673
+ * 热更新/重启后 run 重建时由 consumeBudgetPauseMarker 消费——恢复内存
674
+ * budgetPauseTriggered 标记,避免同一超支原因二次暂停。
675
+ */
676
+ async function markBudgetPause(handle, id, reason) {
677
+ const [row] = await handle.db.select().from(sessions).where(eq(sessions.id, id));
678
+ if (!row)
679
+ return;
680
+ const meta = (row.metadata ?? {});
681
+ await handle.db
682
+ .update(sessions)
683
+ .set({ metadata: { ...meta, budgetPauseReason: reason }, updatedAt: new Date() })
684
+ .where(eq(sessions.id, id));
685
+ }
686
+ /**
687
+ * P2-3:消费预算暂停标记(新 run 启动时调用一次)。返回上次暂停原因并删除字段;
688
+ * 无标记返回 null。消费即删除——单次语义,之后 run 恢复常规预算检查。
689
+ */
690
+ async function consumeBudgetPauseMarker(handle, id) {
691
+ const [row] = await handle.db
692
+ .select({ metadata: sessions.metadata })
693
+ .from(sessions)
694
+ .where(eq(sessions.id, id));
695
+ if (!row)
696
+ return null;
697
+ const meta = (row.metadata ?? {});
698
+ const reason = typeof meta.budgetPauseReason === 'string' ? meta.budgetPauseReason : null;
699
+ if (reason === null)
700
+ return null;
701
+ const { budgetPauseReason: _omit, ...rest } = meta;
702
+ await handle.db
703
+ .update(sessions)
704
+ .set({ metadata: rest, updatedAt: new Date() })
705
+ .where(eq(sessions.id, id));
706
+ return reason;
707
+ }
643
708
  async function listSessionsByProject(handle, projectId) {
644
709
  const rows = await handle.db
645
710
  .select()
646
711
  .from(sessions)
647
- .where(and(eq(sessions.projectId, projectId), isNull(sessions.deletedAt), or(isNull(sessions.source), ne(sessions.source, 'cli'))));
712
+ .where(and(eq(sessions.projectId, projectId), isNull(sessions.deletedAt), webVisibleSessionCondition()));
648
713
  return rows.map(rowToSession);
649
714
  }
650
715
  /**
@@ -660,7 +725,7 @@ async function searchSessions(handle, query, projectId, opts = {}) {
660
725
  const pattern = `%${needle.replace(/[%_\\]/g, '\\$&')}%`;
661
726
  const baseWhere = and(opts.includeDeleted ? gt(sessions.deletedAt, new Date(0)) : isNull(sessions.deletedAt),
662
727
  // 回收站搜索与回收站列表(listDeletedSessions)一致:不过滤 source。
663
- ...(opts.includeDeleted ? [] : [or(isNull(sessions.source), ne(sessions.source, 'cli'))]), ...(projectId ? [eq(sessions.projectId, projectId)] : []));
728
+ ...(opts.includeDeleted ? [] : [webVisibleSessionCondition()]), ...(projectId ? [eq(sessions.projectId, projectId)] : []));
664
729
  // 标题命中(ILIKE 转义后按字面子串)
665
730
  const byTitle = await handle.db
666
731
  .select()
@@ -702,4 +767,52 @@ async function searchSessions(handle, query, projectId, opts = {}) {
702
767
  }
703
768
  return result;
704
769
  }
705
- export { clearTrashMarks, createSession, emptyTrash, getSession, listAllSessions, listDeletedSessions, listOrphanDeletedSessions, listSessions, listSessionsByProject, permanentlyDeleteSession, purgeDeletedSessions, purgeEmptySessions, purgeTemporarySessions, rebindSession, restoreSession, restoreSessionCore, searchSessions, softDeleteSession, touchLastOpened, touchSession, touchTrashSeen, updateSessionLastRun, updateSessionTitle, upgradeTemporarySession, };
770
+ /**
771
+ * M3:把中断 run 的半截轮次标记为「未完成」——写入 metadata 的
772
+ * (unfinishedSinceEntryId, unfinishedUntilEntryId] 区间。
773
+ * 检测条件:lastRun.status 仍为 running/paused(进程崩溃/重启或热更新交接
774
+ * 未能写入 completed;deliberate abort 由 SSE finally 正常写 completed,不误标)。
775
+ * 边界 = 最后一条含文本的 user 消息;其后的条目全部属中断轮次——
776
+ * 上下文构建剔除(context.ts),前端时间线置灰。
777
+ * 边界已是最后一条 → 无半截内容,跳过标记。
778
+ * 重复中断:区间起点不变(重发不追加 user 消息),终点推进到最新末尾,天然合并。
779
+ */
780
+ async function markUnfinishedTurn(handle, sessionId) {
781
+ const [row] = await handle.db
782
+ .select({ metadata: sessions.metadata })
783
+ .from(sessions)
784
+ .where(eq(sessions.id, sessionId));
785
+ if (!row)
786
+ return;
787
+ const meta = (row.metadata ?? {});
788
+ const status = meta.lastRun?.status;
789
+ if (status !== 'running' && status !== 'paused')
790
+ return;
791
+ const entries = await getEntries(handle, sessionId);
792
+ let lastUserIdx = -1;
793
+ for (let i = entries.length - 1; i >= 0; i--) {
794
+ const e = entries[i];
795
+ // P1-2:边界取「含任意内容(text 或 image)的 user 消息」——此前仅认 text,
796
+ // 纯图片消息不在边界判定内,中断轮次起点会错误回退到更早的文本消息。
797
+ if (e &&
798
+ !('_tag' in e) &&
799
+ e.role === 'user' &&
800
+ e.content.some((p) => p._tag === 'text' || p._tag === 'image')) {
801
+ lastUserIdx = i;
802
+ break;
803
+ }
804
+ }
805
+ if (lastUserIdx < 0 || lastUserIdx === entries.length - 1)
806
+ return;
807
+ const sinceId = entries[lastUserIdx]?.id;
808
+ const untilId = entries[entries.length - 1]?.id;
809
+ if (!sinceId || !untilId)
810
+ return;
811
+ await handle.db
812
+ .update(sessions)
813
+ .set({
814
+ metadata: { ...meta, unfinishedSinceEntryId: sinceId, unfinishedUntilEntryId: untilId },
815
+ })
816
+ .where(eq(sessions.id, sessionId));
817
+ }
818
+ export { clearTrashMarks, consumeBudgetPauseMarker, createSession, emptyTrash, getSession, listAllSessions, listDeletedSessions, listOrphanDeletedSessions, listSessions, listSessionsByProject, markBudgetPause, markUnfinishedTurn, permanentlyDeleteSession, purgeDeletedSessions, purgeEmptySession, purgeEmptySessions, purgeTemporarySessions, rebindSession, restoreSession, restoreSessionCore, searchSessions, softDeleteSession, touchLastOpened, touchSession, touchTrashSeen, updateSessionLastRun, updateSessionTitle, upgradeTemporarySession, };
@@ -98,6 +98,8 @@ type SessionUsage = {
98
98
  outputTokens: number;
99
99
  cacheRead: number;
100
100
  cost: number;
101
+ /** 价格未知(cost=null)的调用数:成本按 $0 计入,UI 需提示低估。 */
102
+ unknownCostCalls: number;
101
103
  calls: number;
102
104
  };
103
105
  /** A node in the session tree. */
@@ -0,0 +1,62 @@
1
+ import type { DB } from '../db/client.js';
2
+ import type { UsageConfig } from '../shared/types/config.js';
3
+ /**
4
+ * 把 sessions.metadata.segments 中全部调用补齐进 usage_events(幂等)。
5
+ * 覆盖:升级前历史数据、fork 复制的 segments(display 用途)、导入会话携带的
6
+ * segments。callId 冲突(已入账)静默跳过。
7
+ * 返回本次新增行数。
8
+ */
9
+ export declare function backfillUsageEvents(handle: DB): Promise<number>;
10
+ /** 当月用量汇总(金额 + token),预算护栏判定用。
11
+ * cost:已发生成本——价格未知(cost=null)的调用按 $0 计入,故金额口径对自建
12
+ * 网关/未登记模型会系统性低估;tokens:input+output+cacheRead tokens 之和,
13
+ * 与价格无关,作为价格独立的兜底口径(缓存读取也按用量计费,须计入)。
14
+ * unknownCostCalls:当月价格未知的调用数——金额预算轴据此识别「口径低估」,
15
+ * 无 token 预算兜底时并入护栏告警文案(P2-2:预算静默失效不再无提示)。
16
+ */
17
+ export declare function monthUsage(handle: DB, opts: {
18
+ projectId?: string | null;
19
+ sinceMs: number;
20
+ }): Promise<{
21
+ cost: number;
22
+ tokens: number;
23
+ unknownCostCalls: number;
24
+ }>;
25
+ /** 当月累计用量(本月 1 日 00:00 本地时区起算)。
26
+ * 已知限制:跨时区/换机时「本月」边界随本地时区漂移,成本护栏阈值随之平移——
27
+ * 本地单机(服务端与用户同机)场景无影响。 */
28
+ export declare function currentMonthUsage(handle: DB, projectId?: string | null, now?: Date): Promise<{
29
+ cost: number;
30
+ tokens: number;
31
+ unknownCostCalls: number;
32
+ }>;
33
+ /** 预算护栏判定结果:parts 为超支描述片段,action 为实际超支轴中最严格的动作
34
+ * ('abort' > 'pause';'warn' 不阻断、不会进入本判定)。
35
+ * warnings:非阻断的告警片段(P2-2)——金额轴启用但当月存在价格未知调用且
36
+ * 无 token 预算兜底时,「金额口径低估」不再静默;消费方并入暂停/中止文案。 */
37
+ export type BudgetOverage = {
38
+ parts: string[];
39
+ action: 'pause' | 'abort';
40
+ warnings: string[];
41
+ };
42
+ /** 预算护栏判定:金额(USD)+ token 双口径,返回超支描述 + 阻断动作(空 parts =
43
+ * 无需阻断——可能超支但动作仅 warn)。
44
+ * 金额口径受 budgetAction 控制;token 口径受 tokenBudgetAction 控制(缺省回退
45
+ * budgetAction,向后兼容)。warn 动作只走前端徽标/面板告警,不进本判定。
46
+ * 供 Web loop 暂停/中止、CLI 拒绝、子 agent 提前中止共用。
47
+ * 项目口径仅在 projectId 可归属时检查(未归属会话只受全局口径兜底)。
48
+ * token 口径独立于价格,兜底自建网关/未登记模型(cost 恒 $0)的场景。
49
+ * P1:金额/token 两轴动作各自求值,多轴同时超支取更严格者(abort > pause),
50
+ * 不再被单一布尔预算标志坍缩降级。 */
51
+ export declare function budgetOverageParts(handle: DB, usage: UsageConfig, projectId: string | null | undefined, now?: Date): Promise<BudgetOverage>;
52
+ /** 本地时区的 YYYY-MM 月份键(与 usage 聚合、前端徽标同口径)。 */
53
+ export declare function localMonthKey(ts: number): string;
54
+ /** 一次性 backfill 标记键(app_meta)。 */
55
+ export declare const USAGE_BACKFILL_MARKER = "usage_backfill_v1";
56
+ /**
57
+ * P3-8:backfill 只在「升级后的首次启动」执行一次,之后跳过——
58
+ * backfill 的真实消费者只有升级前遗留 segments(fork 复制的 segments 经 callId
59
+ * 唯一约束去重本就是 no-op;导入会话的 segments 已剥离 call id 不产生账本行),
60
+ * 每次启动全表扫描是纯浪费。标记写入与 backfill 同事务,失败不落标记可重试。
61
+ */
62
+ export declare function backfillUsageEventsOnce(handle: DB): Promise<number>;
@@ -0,0 +1,194 @@
1
+ // src/session/usage.ts
2
+ // 成本账本工具:usage_events 的 backfill 与月度成本查询。
3
+ //
4
+ // usage_events(append-only,与 session 生命周期解耦)是成本聚合的权威数据源。
5
+ // 会话物理清除(回收站到期/彻底删除/临时会话清理)不触及它——成本是账本。
6
+ // 写路径在 loop manageSegment(每个 LLM 调用落一行);本模块负责两条辅助路径:
7
+ // 1. backfillUsageEvents:把 sessions.metadata.segments 中已有调用补齐进账本
8
+ // (升级前产生的历史数据 + fork 复制的 segments + 导入的会话)。
9
+ // 幂等:callId 唯一约束 + onConflictDoNothing,可安全重复执行。
10
+ // 2. budgetOverageParts:预算暂停/拒绝(usage.budgetAction='pause')用的当月
11
+ // 金额 + token 双口径超支判定(token 兜底价格未知的自建网关/未登记模型)。
12
+ import { and, eq, gte } from 'drizzle-orm';
13
+ import { appMeta, sessions, usageEvents } from '../db/schema.js';
14
+ /**
15
+ * 把 sessions.metadata.segments 中全部调用补齐进 usage_events(幂等)。
16
+ * 覆盖:升级前历史数据、fork 复制的 segments(display 用途)、导入会话携带的
17
+ * segments。callId 冲突(已入账)静默跳过。
18
+ * 返回本次新增行数。
19
+ */
20
+ export async function backfillUsageEvents(handle) {
21
+ const rows = await handle.db
22
+ .select({ id: sessions.id, projectId: sessions.projectId, metadata: sessions.metadata })
23
+ .from(sessions);
24
+ let added = 0;
25
+ for (const row of rows) {
26
+ const meta = (row.metadata ?? {});
27
+ for (const seg of meta.segments ?? []) {
28
+ if (typeof seg.provider !== 'string' || typeof seg.model !== 'string')
29
+ continue;
30
+ for (const call of seg.calls ?? []) {
31
+ if (typeof call.id !== 'string' || call.id.length === 0)
32
+ continue;
33
+ const ts = typeof call.timestamp === 'number' && call.timestamp > 0 ? call.timestamp : 0;
34
+ if (ts === 0)
35
+ continue;
36
+ const input = typeof call.usage?.input === 'number' ? call.usage.input : 0;
37
+ const output = typeof call.usage?.output === 'number' ? call.usage.output : 0;
38
+ const cacheRead = typeof call.usage?.cacheRead === 'number' ? call.usage.cacheRead : 0;
39
+ const cost = typeof call.cost === 'number' ? call.cost : null;
40
+ const result = await handle.db
41
+ .insert(usageEvents)
42
+ .values({
43
+ callId: call.id,
44
+ sessionId: row.id,
45
+ projectId: row.projectId,
46
+ provider: seg.provider,
47
+ model: seg.model,
48
+ inputTokens: input,
49
+ outputTokens: output,
50
+ cacheRead,
51
+ cost,
52
+ timestamp: ts,
53
+ })
54
+ .onConflictDoNothing();
55
+ if (result.rowCount && result.rowCount > 0)
56
+ added += result.rowCount;
57
+ }
58
+ }
59
+ }
60
+ return added;
61
+ }
62
+ /** 当月用量汇总(金额 + token),预算护栏判定用。
63
+ * cost:已发生成本——价格未知(cost=null)的调用按 $0 计入,故金额口径对自建
64
+ * 网关/未登记模型会系统性低估;tokens:input+output+cacheRead tokens 之和,
65
+ * 与价格无关,作为价格独立的兜底口径(缓存读取也按用量计费,须计入)。
66
+ * unknownCostCalls:当月价格未知的调用数——金额预算轴据此识别「口径低估」,
67
+ * 无 token 预算兜底时并入护栏告警文案(P2-2:预算静默失效不再无提示)。
68
+ */
69
+ export async function monthUsage(handle, opts) {
70
+ const conds = [gte(usageEvents.timestamp, opts.sinceMs)];
71
+ if (opts.projectId)
72
+ conds.push(eq(usageEvents.projectId, opts.projectId));
73
+ const rows = await handle.db
74
+ .select({
75
+ cost: usageEvents.cost,
76
+ inputTokens: usageEvents.inputTokens,
77
+ outputTokens: usageEvents.outputTokens,
78
+ cacheRead: usageEvents.cacheRead,
79
+ })
80
+ .from(usageEvents)
81
+ .where(and(...conds));
82
+ let cost = 0;
83
+ let tokens = 0;
84
+ let unknownCostCalls = 0;
85
+ for (const r of rows) {
86
+ if (typeof r.cost === 'number')
87
+ cost += r.cost;
88
+ else
89
+ unknownCostCalls += 1;
90
+ tokens += r.inputTokens + r.outputTokens + (r.cacheRead ?? 0);
91
+ }
92
+ return { cost, tokens, unknownCostCalls };
93
+ }
94
+ /** 当月累计用量(本月 1 日 00:00 本地时区起算)。
95
+ * 已知限制:跨时区/换机时「本月」边界随本地时区漂移,成本护栏阈值随之平移——
96
+ * 本地单机(服务端与用户同机)场景无影响。 */
97
+ export async function currentMonthUsage(handle, projectId, now = new Date()) {
98
+ const monthStart = new Date(now.getFullYear(), now.getMonth(), 1).getTime();
99
+ return monthUsage(handle, { projectId, sinceMs: monthStart });
100
+ }
101
+ /** 预算护栏判定:金额(USD)+ token 双口径,返回超支描述 + 阻断动作(空 parts =
102
+ * 无需阻断——可能超支但动作仅 warn)。
103
+ * 金额口径受 budgetAction 控制;token 口径受 tokenBudgetAction 控制(缺省回退
104
+ * budgetAction,向后兼容)。warn 动作只走前端徽标/面板告警,不进本判定。
105
+ * 供 Web loop 暂停/中止、CLI 拒绝、子 agent 提前中止共用。
106
+ * 项目口径仅在 projectId 可归属时检查(未归属会话只受全局口径兜底)。
107
+ * token 口径独立于价格,兜底自建网关/未登记模型(cost 恒 $0)的场景。
108
+ * P1:金额/token 两轴动作各自求值,多轴同时超支取更严格者(abort > pause),
109
+ * 不再被单一布尔预算标志坍缩降级。 */
110
+ export async function budgetOverageParts(handle, usage, projectId, now = new Date()) {
111
+ const projectBudgetUsd = usage.monthlyBudgetUsd ?? 0;
112
+ const globalBudgetUsd = usage.globalMonthlyBudgetUsd ?? 0;
113
+ const projectTokenBudget = usage.monthlyTokenBudget ?? 0;
114
+ const globalTokenBudget = usage.globalMonthlyTokenBudget ?? 0;
115
+ const amountAction = usage.budgetAction ?? 'warn';
116
+ const tokenAction = usage.tokenBudgetAction ?? amountAction;
117
+ // P0-3:'pause' 与 'abort' 都是阻断动作(均需 loop 放行前检查);'warn' 不阻断。
118
+ const amountBlocks = amountAction === 'pause' || amountAction === 'abort';
119
+ const tokenBlocks = tokenAction === 'pause' || tokenAction === 'abort';
120
+ const projectScoped = projectId != null && (projectBudgetUsd > 0 || projectTokenBudget > 0);
121
+ const globalScoped = globalBudgetUsd > 0 || globalTokenBudget > 0;
122
+ if (!projectScoped && !globalScoped)
123
+ return { parts: [], action: 'pause', warnings: [] };
124
+ const [project, global] = await Promise.all([
125
+ projectScoped ? currentMonthUsage(handle, projectId, now) : Promise.resolve(null),
126
+ globalScoped ? currentMonthUsage(handle, undefined, now) : Promise.resolve(null),
127
+ ]);
128
+ // P2-2:金额轴阻断启用但价格未知调用无 token 兜底 → 「金额口径低估」告警。
129
+ // 仅当对应范围未设置 token 预算(token 兜底缺失)且当月确有 cost=null 调用时
130
+ // 追加——warnings 不触发阻断,只并入暂停/中止文案让用户知道护栏口径不可靠。
131
+ const warnings = [];
132
+ const amountAxisActive = amountBlocks && (globalBudgetUsd > 0 || (projectBudgetUsd > 0 && projectId != null));
133
+ if (amountAxisActive) {
134
+ const globalUnknown = global?.unknownCostCalls ?? 0;
135
+ if (globalBudgetUsd > 0 && globalTokenBudget <= 0 && globalUnknown > 0) {
136
+ warnings.push(`金额口径低估:本月有 ${globalUnknown} 次调用价格未知(按 $0 计入),且未设置全局 token 预算兜底`);
137
+ }
138
+ const projectUnknown = project?.unknownCostCalls ?? 0;
139
+ if (projectBudgetUsd > 0 &&
140
+ projectTokenBudget <= 0 &&
141
+ projectId != null &&
142
+ projectUnknown > 0) {
143
+ warnings.push(`金额口径低估:本项目有 ${projectUnknown} 次调用价格未知(按 $0 计入),且未设置项目 token 预算兜底`);
144
+ }
145
+ }
146
+ const parts = [];
147
+ // 实际超支轴中的最严格阻断动作:任一超支轴动作 === 'abort' 即整体 abort,否则 pause。
148
+ let action = 'pause';
149
+ if (amountBlocks && globalBudgetUsd > 0 && global && global.cost > globalBudgetUsd) {
150
+ parts.push(`全局预算 $${globalBudgetUsd.toFixed(2)}:本月全部项目已 $${global.cost.toFixed(2)}`);
151
+ if (amountAction === 'abort')
152
+ action = 'abort';
153
+ }
154
+ if (amountBlocks && projectBudgetUsd > 0 && project && project.cost > projectBudgetUsd) {
155
+ parts.push(`项目预算 $${projectBudgetUsd.toFixed(2)}:本项目已 $${project.cost.toFixed(2)}`);
156
+ if (amountAction === 'abort')
157
+ action = 'abort';
158
+ }
159
+ if (tokenBlocks && globalTokenBudget > 0 && global && global.tokens > globalTokenBudget) {
160
+ parts.push(`全局 token 预算 ${globalTokenBudget.toLocaleString()}:本月已 ${global.tokens.toLocaleString()} tokens`);
161
+ if (tokenAction === 'abort')
162
+ action = 'abort';
163
+ }
164
+ if (tokenBlocks && projectTokenBudget > 0 && project && project.tokens > projectTokenBudget) {
165
+ parts.push(`项目 token 预算 ${projectTokenBudget.toLocaleString()}:本项目已 ${project.tokens.toLocaleString()} tokens`);
166
+ if (tokenAction === 'abort')
167
+ action = 'abort';
168
+ }
169
+ return { parts, action, warnings };
170
+ }
171
+ /** 本地时区的 YYYY-MM 月份键(与 usage 聚合、前端徽标同口径)。 */
172
+ export function localMonthKey(ts) {
173
+ const d = new Date(ts);
174
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
175
+ }
176
+ /** 一次性 backfill 标记键(app_meta)。 */
177
+ export const USAGE_BACKFILL_MARKER = 'usage_backfill_v1';
178
+ /**
179
+ * P3-8:backfill 只在「升级后的首次启动」执行一次,之后跳过——
180
+ * backfill 的真实消费者只有升级前遗留 segments(fork 复制的 segments 经 callId
181
+ * 唯一约束去重本就是 no-op;导入会话的 segments 已剥离 call id 不产生账本行),
182
+ * 每次启动全表扫描是纯浪费。标记写入与 backfill 同事务,失败不落标记可重试。
183
+ */
184
+ export async function backfillUsageEventsOnce(handle) {
185
+ const [marker] = await handle.db
186
+ .select()
187
+ .from(appMeta)
188
+ .where(eq(appMeta.key, USAGE_BACKFILL_MARKER));
189
+ if (marker)
190
+ return 0;
191
+ const added = await backfillUsageEvents(handle);
192
+ await handle.db.insert(appMeta).values({ key: USAGE_BACKFILL_MARKER, value: String(Date.now()) });
193
+ return added;
194
+ }