c0de-agent 1.13.0 → 1.14.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 (155) hide show
  1. package/dist/cli/commands/serve.js +5 -1
  2. package/dist/cli/deps.d.ts +2 -4
  3. package/dist/cli/deps.js +22 -36
  4. package/dist/cli/modes/print.js +27 -17
  5. package/dist/core/agent.js +59 -36
  6. package/dist/core/agents/parallel.js +4 -2
  7. package/dist/core/config-path.d.ts +3 -1
  8. package/dist/core/config-path.js +33 -3
  9. package/dist/core/config.js +81 -0
  10. package/dist/core/context.js +6 -3
  11. package/dist/core/loop/persist.js +4 -1
  12. package/dist/core/loop/subagent.d.ts +19 -0
  13. package/dist/core/loop/subagent.js +73 -7
  14. package/dist/core/loop.d.ts +4 -1
  15. package/dist/core/loop.js +22 -6
  16. package/dist/{server/registry-config.d.ts → core/provider-registry.d.ts} +10 -2
  17. package/dist/core/provider-registry.js +66 -0
  18. package/dist/core/redact.d.ts +2 -1
  19. package/dist/core/redact.js +4 -2
  20. package/dist/core/secret.d.ts +5 -0
  21. package/dist/core/secret.js +14 -0
  22. package/dist/core/skills.d.ts +2 -0
  23. package/dist/core/skills.js +61 -0
  24. package/dist/core/slash.js +31 -18
  25. package/dist/core/title.js +4 -2
  26. package/dist/core/todo-tags.d.ts +4 -3
  27. package/dist/core/todo-tags.js +20 -6
  28. package/dist/core/workflow.d.ts +4 -13
  29. package/dist/core/workflow.js +6 -6
  30. package/dist/core/workflows/context.js +31 -59
  31. package/dist/core/workflows/runtime.js +10 -8
  32. package/dist/dap/index.d.ts +1 -0
  33. package/dist/dap/index.js +1 -0
  34. package/dist/dap/protocol.d.ts +8 -3
  35. package/dist/dap/protocol.js +54 -18
  36. package/dist/dap/session.js +71 -12
  37. package/dist/dap/spawn.d.ts +11 -0
  38. package/dist/dap/spawn.js +48 -0
  39. package/dist/dap/transport.js +18 -0
  40. package/dist/kanban/index.d.ts +1 -1
  41. package/dist/kanban/index.js +1 -1
  42. package/dist/kanban/store.d.ts +8 -1
  43. package/dist/kanban/store.js +61 -13
  44. package/dist/llm/protocols/openai-compat.d.ts +7 -0
  45. package/dist/llm/protocols/openai-compat.js +19 -2
  46. package/dist/llm/protocols/utils/tool-stream.d.ts +12 -3
  47. package/dist/llm/protocols/utils/tool-stream.js +47 -23
  48. package/dist/llm/provider.js +18 -3
  49. package/dist/llm/retry.d.ts +1 -0
  50. package/dist/llm/retry.js +20 -7
  51. package/dist/llm/routing.js +14 -2
  52. package/dist/llm/schema/messages.d.ts +9 -2
  53. package/dist/llm/token.d.ts +9 -0
  54. package/dist/llm/token.js +10 -6
  55. package/dist/llm/transport.js +7 -2
  56. package/dist/mcp/client.d.ts +11 -0
  57. package/dist/mcp/client.js +70 -0
  58. package/dist/mcp/index.d.ts +46 -0
  59. package/dist/mcp/index.js +106 -0
  60. package/dist/mcp/tool-adapter.d.ts +14 -0
  61. package/dist/mcp/tool-adapter.js +65 -0
  62. package/dist/mcp/transport.d.ts +35 -0
  63. package/dist/mcp/transport.js +243 -0
  64. package/dist/mcp/types.d.ts +55 -0
  65. package/dist/mcp/types.js +3 -0
  66. package/dist/plugins/init.d.ts +2 -0
  67. package/dist/plugins/init.js +10 -1
  68. package/dist/project/project.js +50 -3
  69. package/dist/project/resolve.d.ts +7 -0
  70. package/dist/project/resolve.js +52 -5
  71. package/dist/server/permission/interactive.js +9 -4
  72. package/dist/server/permission/store.js +8 -2
  73. package/dist/server/routes/auth.js +28 -13
  74. package/dist/server/routes/chat.js +92 -26
  75. package/dist/server/routes/config.js +38 -10
  76. package/dist/server/routes/files.js +75 -16
  77. package/dist/server/routes/filesystem.js +9 -2
  78. package/dist/server/routes/kanban.js +146 -9
  79. package/dist/server/routes/permissions.js +32 -0
  80. package/dist/server/routes/project.js +15 -6
  81. package/dist/server/routes/provider.js +10 -5
  82. package/dist/server/routes/session.js +72 -26
  83. package/dist/server/routes/terminal.js +10 -2
  84. package/dist/server/routes/todo.js +18 -0
  85. package/dist/server/routes/tool.js +13 -2
  86. package/dist/server/routes/update.js +5 -1
  87. package/dist/server/routes/workflows.js +17 -6
  88. package/dist/server/server.d.ts +1 -1
  89. package/dist/server/server.js +31 -17
  90. package/dist/server/terminal/pty-manager.d.ts +3 -0
  91. package/dist/server/terminal/pty-manager.js +8 -3
  92. package/dist/server/types.d.ts +2 -0
  93. package/dist/server/util/json-body.d.ts +31 -0
  94. package/dist/server/util/json-body.js +38 -0
  95. package/dist/session/archive.js +4 -4
  96. package/dist/session/branch.d.ts +10 -1
  97. package/dist/session/branch.js +54 -9
  98. package/dist/session/compaction.d.ts +5 -0
  99. package/dist/session/compaction.js +15 -10
  100. package/dist/session/context.d.ts +7 -3
  101. package/dist/session/context.js +35 -9
  102. package/dist/session/import.js +53 -6
  103. package/dist/session/index.d.ts +2 -2
  104. package/dist/session/index.js +2 -2
  105. package/dist/session/message.d.ts +12 -3
  106. package/dist/session/message.js +44 -4
  107. package/dist/session/session.d.ts +11 -5
  108. package/dist/session/session.js +99 -88
  109. package/dist/session/shake.js +35 -25
  110. package/dist/session/squash.js +3 -4
  111. package/dist/session/token.d.ts +7 -8
  112. package/dist/session/token.js +19 -16
  113. package/dist/session/usage.js +4 -2
  114. package/dist/shared/index.d.ts +1 -0
  115. package/dist/shared/index.js +1 -0
  116. package/dist/shared/round.d.ts +12 -0
  117. package/dist/shared/round.js +18 -0
  118. package/dist/shared/timer.d.ts +23 -0
  119. package/dist/shared/timer.js +27 -0
  120. package/dist/shared/types/tool.d.ts +4 -0
  121. package/dist/shared/utils/markdown-code.d.ts +15 -0
  122. package/dist/shared/utils/markdown-code.js +44 -0
  123. package/dist/shared/utils/path.d.ts +4 -1
  124. package/dist/shared/utils/path.js +5 -2
  125. package/dist/shared/utils/string.d.ts +5 -0
  126. package/dist/shared/utils/string.js +52 -0
  127. package/dist/tools/builtin/bash.js +79 -10
  128. package/dist/tools/builtin/edit.js +67 -8
  129. package/dist/tools/builtin/glob.js +150 -14
  130. package/dist/tools/builtin/grep.js +53 -11
  131. package/dist/tools/builtin/kanban.d.ts +14 -0
  132. package/dist/tools/builtin/kanban.js +4 -1
  133. package/dist/tools/builtin/read.js +15 -2
  134. package/dist/tools/builtin/resolvers.d.ts +9 -2
  135. package/dist/tools/builtin/resolvers.js +20 -6
  136. package/dist/tools/builtin/task.js +107 -25
  137. package/dist/tools/builtin/todo.js +84 -9
  138. package/dist/tools/builtin/write.js +7 -1
  139. package/dist/tools/builtin/yield.js +9 -1
  140. package/dist/tools/executor.js +53 -1
  141. package/dist/tools/hashline/patch.js +114 -38
  142. package/dist/tools/truncate.js +45 -16
  143. package/dist/tools/validate.js +66 -20
  144. package/dist/tools/websearch/index.js +8 -4
  145. package/dist/update/index.d.ts +1 -1
  146. package/dist/update/index.js +1 -1
  147. package/dist/update/scheduler.js +9 -2
  148. package/dist/update/snapshot.d.ts +14 -4
  149. package/dist/update/snapshot.js +22 -22
  150. package/dist/update/updater.d.ts +16 -2
  151. package/dist/update/updater.js +39 -8
  152. package/dist/update/version.d.ts +3 -1
  153. package/dist/update/version.js +63 -9
  154. package/package.json +1 -1
  155. package/dist/server/registry-config.js +0 -44
@@ -1,7 +1,7 @@
1
1
  import { and, desc, eq, ilike } from 'drizzle-orm';
2
2
  import { compactionArchives } from '../db/schema.js';
3
3
  import { generateId } from '../shared/index.js';
4
- const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
4
+ import { isUuid } from '../shared/utils/string.js';
5
5
  function toEpochMs(value) {
6
6
  if (value instanceof Date)
7
7
  return value.getTime();
@@ -42,9 +42,9 @@ function entryToSearchableText(entry) {
42
42
  case 'steering':
43
43
  return part.text;
44
44
  case 'tool_call':
45
- return `${part.tool}: ${JSON.stringify(part.input)}`;
45
+ return `${part.tool}: ${JSON.stringify(part.input ?? {})}`;
46
46
  case 'tool_result':
47
- return JSON.stringify(part.output);
47
+ return JSON.stringify(part.output ?? {});
48
48
  default:
49
49
  return '';
50
50
  }
@@ -71,7 +71,7 @@ async function archiveOriginalEntries(handle, sessionId, entries, archiveType, s
71
71
  /** Get an archive by id. Returns null for invalid or non-existent ids. */
72
72
  async function getArchive(handle, id) {
73
73
  // PGLite rejects non-UUID strings at query time; validate first.
74
- if (!UUID_RE.test(id))
74
+ if (!isUuid(id))
75
75
  return null;
76
76
  const [row] = await handle.db
77
77
  .select()
@@ -1,4 +1,5 @@
1
1
  import type { DB } from '../db/client.js';
2
+ import type { HookRunner } from '../plugins/types.js';
2
3
  import type { Session, SessionTreeNode } from './types.js';
3
4
  /**
4
5
  * fork 分支点 messageIndex 越界(评审 NIT:客户端索引过期/分页 bug)。
@@ -9,13 +10,21 @@ declare class BranchPointOutOfRangeError extends Error {
9
10
  }
10
11
  /** Fork a session at a message index — copies all entries (messages + tool pairs)
11
12
  * up to and including the branch point message, plus latest file snapshots. */
12
- declare function forkSession(handle: DB, sessionId: string, messageIndex: number): Promise<Session>;
13
+ declare function forkSession(handle: DB, sessionId: string, messageIndex: number, hooks?: HookRunner): Promise<Session>;
13
14
  /** Get direct child sessions (branches) of a session. */
14
15
  declare function getBranches(handle: DB, sessionId: string): Promise<Session[]>;
15
16
  /** Build a full session tree from root sessions down.
16
17
  * 每层按 metadata.lastOpenedAt 降序(fallback updatedAt、createdAt)。
17
18
  * 排除软删除会话与临时 CLI 会话(print/workflow);持久化 CLI 会话(--continue 续接)
18
19
  * 与 Web 会话同树可见(CLI/Web 同库不同视图 → 同库同视图)。
20
+ *
21
+ * 环防护 + 可见性兜底(P1):parentId 成环数据(a↔b 互指/自引用,快照恢复会
22
+ * 保留环、手改 DB 亦可产生)的成员不可从任何根到达——根 = parentId 为 null,
23
+ * 环上成员父指针都在环内,不存在指向环外的边。朴素构建会让它们从会话树
24
+ * **静默消失**(列表看不到、无法打开、无法删除)。visited 保证每个会话恰好
25
+ * 出现一次(环在重复处断开),构建根后把未出现的成员按同一排序作为根补挂。
26
+ * 正常森林不受影响。与 restoreSessionCore 祖先链 / purgeDeletedSessions /
27
+ * recycleTree 的环守卫同口径。
19
28
  */
20
29
  declare function getTree(handle: DB): Promise<SessionTreeNode[]>;
21
30
  export { BranchPointOutOfRangeError, forkSession, getBranches, getTree };
@@ -14,9 +14,12 @@ class BranchPointOutOfRangeError extends Error {
14
14
  this.name = 'BranchPointOutOfRangeError';
15
15
  }
16
16
  }
17
- /** 把 SessionEntry 还原为 sessionEntries 原始行(fork 复制用)。 */
17
+ /** 把 SessionEntry 还原为 sessionEntries 原始行(fork 复制用)。
18
+ * createdAt 原样保留:丢弃它会让复制出的条目全部拿到事务 now()(同一毫秒),
19
+ * 分支会话内时间戳并列——顺序退化为物理行序,时间线显示也全部变成 fork 时刻。 */
18
20
  function entryToRow(e, sessionId) {
19
21
  const id = generateId();
22
+ const createdAt = new Date(e.createdAt);
20
23
  // Message 无 _tag(共享类型);其余条目经 _tag 判别。
21
24
  if (!('_tag' in e)) {
22
25
  return {
@@ -26,6 +29,7 @@ function entryToRow(e, sessionId) {
26
29
  role: e.role,
27
30
  content: e.content,
28
31
  tokenCount: e.tokenCount,
32
+ createdAt,
29
33
  };
30
34
  }
31
35
  switch (e._tag) {
@@ -40,6 +44,7 @@ function entryToRow(e, sessionId) {
40
44
  archiveId: e.archiveId,
41
45
  },
42
46
  tokenCount: e.tokenCount,
47
+ createdAt,
43
48
  };
44
49
  case 'squash':
45
50
  return {
@@ -52,6 +57,7 @@ function entryToRow(e, sessionId) {
52
57
  archiveId: e.archiveId,
53
58
  },
54
59
  tokenCount: e.tokenCount,
60
+ createdAt,
55
61
  };
56
62
  case 'branch_summary':
57
63
  return {
@@ -59,14 +65,15 @@ function entryToRow(e, sessionId) {
59
65
  sessionId,
60
66
  tag: 'branch_summary',
61
67
  content: { summary: e.summary, sourceSessionId: e.sourceSessionId },
68
+ createdAt,
62
69
  };
63
70
  case 'steering':
64
- return { id, sessionId, tag: 'steering', content: { text: e.content } };
71
+ return { id, sessionId, tag: 'steering', content: { text: e.content }, createdAt };
65
72
  }
66
73
  }
67
74
  /** Fork a session at a message index — copies all entries (messages + tool pairs)
68
75
  * up to and including the branch point message, plus latest file snapshots. */
69
- async function forkSession(handle, sessionId, messageIndex) {
76
+ async function forkSession(handle, sessionId, messageIndex, hooks) {
70
77
  const source = await getSession(handle, sessionId);
71
78
  if (!source)
72
79
  throw new Error(`Session not found: ${sessionId}`);
@@ -166,6 +173,15 @@ async function forkSession(handle, sessionId, messageIndex) {
166
173
  throw new Error('Forked session not found after creation');
167
174
  return created;
168
175
  });
176
+ if (hooks) {
177
+ try {
178
+ // 事务提交后再广播(hook 处理器不做 DB 写回调,避免事务内嵌套 IO)
179
+ await hooks.fireHooks('session:fork', { source, fork: updated });
180
+ }
181
+ catch {
182
+ // 广播失败不阻断分支返回
183
+ }
184
+ }
169
185
  return updated;
170
186
  }
171
187
  /** Get direct child sessions (branches) of a session. */
@@ -201,6 +217,14 @@ function sessionUsage(session) {
201
217
  * 每层按 metadata.lastOpenedAt 降序(fallback updatedAt、createdAt)。
202
218
  * 排除软删除会话与临时 CLI 会话(print/workflow);持久化 CLI 会话(--continue 续接)
203
219
  * 与 Web 会话同树可见(CLI/Web 同库不同视图 → 同库同视图)。
220
+ *
221
+ * 环防护 + 可见性兜底(P1):parentId 成环数据(a↔b 互指/自引用,快照恢复会
222
+ * 保留环、手改 DB 亦可产生)的成员不可从任何根到达——根 = parentId 为 null,
223
+ * 环上成员父指针都在环内,不存在指向环外的边。朴素构建会让它们从会话树
224
+ * **静默消失**(列表看不到、无法打开、无法删除)。visited 保证每个会话恰好
225
+ * 出现一次(环在重复处断开),构建根后把未出现的成员按同一排序作为根补挂。
226
+ * 正常森林不受影响。与 restoreSessionCore 祖先链 / purgeDeletedSessions /
227
+ * recycleTree 的环守卫同口径。
204
228
  */
205
229
  async function getTree(handle) {
206
230
  const rows = await handle.db
@@ -208,22 +232,43 @@ async function getTree(handle) {
208
232
  .from(sessions)
209
233
  .where(and(isNull(sessions.deletedAt), webVisibleSessionCondition()));
210
234
  const byParent = new Map();
235
+ const all = [];
211
236
  for (const row of rows) {
212
237
  const session = rowToSession(row);
238
+ all.push(session);
213
239
  const list = byParent.get(session.parentId) ?? [];
214
240
  list.push(session);
215
241
  byParent.set(session.parentId, list);
216
242
  }
217
243
  // 排序键:lastOpenedAt > updatedAt > createdAt(均为 epoch ms)
218
244
  const sortKey = (s) => s.metadata.lastOpenedAt ?? s.updatedAt ?? s.createdAt ?? 0;
245
+ const visited = new Set();
219
246
  const build = (parentId) => (byParent.get(parentId) ?? [])
220
247
  .slice()
221
248
  .sort((a, b) => sortKey(b) - sortKey(a))
222
- .map((session) => ({
223
- session,
224
- children: build(session.id),
225
- usage: sessionUsage(session),
226
- }));
227
- return build(null);
249
+ .flatMap((session) => {
250
+ if (visited.has(session.id))
251
+ return [];
252
+ visited.add(session.id);
253
+ return [
254
+ {
255
+ session,
256
+ children: build(session.id),
257
+ usage: sessionUsage(session),
258
+ },
259
+ ];
260
+ });
261
+ const roots = build(null);
262
+ // 环成员(不可从根到达):作为根补挂,环在重复处断开、节点不丢失。
263
+ // 前一个孤儿根的子树可能已把后续成员纳入(a↔b 环),visited 复查去重。
264
+ for (const orphan of all
265
+ .filter((s) => !visited.has(s.id))
266
+ .sort((a, b) => sortKey(b) - sortKey(a))) {
267
+ if (visited.has(orphan.id))
268
+ continue;
269
+ visited.add(orphan.id);
270
+ roots.push({ session: orphan, children: build(orphan.id), usage: sessionUsage(orphan) });
271
+ }
272
+ return roots;
228
273
  }
229
274
  export { BranchPointOutOfRangeError, forkSession, getBranches, getTree };
@@ -9,6 +9,11 @@ import type { CompactionConfig, CompactionResult, HotFile, Summarizer } from './
9
9
  declare function findSafeCutPoint(messages: Message[], preferredCut: number): number;
10
10
  /** Extract frequently-accessed files (read ≥ 2 times) from message history. */
11
11
  declare function extractHotFiles(messages: Message[]): HotFile[];
12
+ /** 工具输出在压缩 prompt 中保留的最大字符数(参考 opencode)。 */
13
+ export declare const TOOL_OUTPUT_MAX_CHARS = 2000;
14
+ /** 截断超长字符串:head 60% + "[truncated]" + tail 40%,而非硬切。
15
+ * 切点经 headChars/tailChars 内收,绝不产出孤立代理码元。 */
16
+ export declare function truncateToolOutput(s: string): string;
12
17
  /**
13
18
  * Build the LLM summarization prompt for a set of messages.
14
19
  *
@@ -1,6 +1,7 @@
1
1
  import { generateId } from '../shared/index.js';
2
+ import { headChars, tailChars } from '../shared/utils/string.js';
2
3
  import { archiveOriginalEntries } from './archive.js';
3
- import { deleteEntriesByIds, getEntries, getMessages, insertEntry } from './message.js';
4
+ import { deleteEntriesByIds, getEntries, getMessages, insertEntry, summaryEntryTimestamp, } from './message.js';
4
5
  import { upsertFileSnapshot } from './snapshot.js';
5
6
  import { estimateMessageTokens, estimateTokens } from './token.js';
6
7
  /**
@@ -52,14 +53,19 @@ function extractHotFiles(messages) {
52
53
  return hot.sort((a, b) => b.accessCount - a.accessCount).slice(0, 10);
53
54
  }
54
55
  /** 工具输出在压缩 prompt 中保留的最大字符数(参考 opencode)。 */
55
- const TOOL_OUTPUT_MAX_CHARS = 2000;
56
- /** 截断超长字符串:head 60% + "[truncated]" + tail 40%,而非硬切。 */
57
- function truncateToolOutput(s) {
56
+ export const TOOL_OUTPUT_MAX_CHARS = 2000;
57
+ /** 截断标记本身计入 TOOL_OUTPUT_MAX_CHARS 预算(此前 head+tail 吃满 2000 后
58
+ * 追加 11 字符标记,输出 2011 超出上限)。 */
59
+ const TOOL_OUTPUT_MARKER = '[truncated]';
60
+ /** 截断超长字符串:head 60% + "[truncated]" + tail 40%,而非硬切。
61
+ * 切点经 headChars/tailChars 内收,绝不产出孤立代理码元。 */
62
+ export function truncateToolOutput(s) {
58
63
  if (s.length <= TOOL_OUTPUT_MAX_CHARS)
59
64
  return s;
60
- const head = Math.floor(TOOL_OUTPUT_MAX_CHARS * 0.6);
61
- const tail = TOOL_OUTPUT_MAX_CHARS - head;
62
- return `${s.slice(0, head)}[truncated]${s.slice(-tail)}`;
65
+ const budget = TOOL_OUTPUT_MAX_CHARS - TOOL_OUTPUT_MARKER.length;
66
+ const head = Math.floor(budget * 0.6);
67
+ const tail = budget - head;
68
+ return `${headChars(s, head)}${TOOL_OUTPUT_MARKER}${tailChars(s, tail)}`;
63
69
  }
64
70
  /** 序列化单个 content part:text/thinking 保持原样,tool 输出超长时截断。 */
65
71
  function serializePart(p) {
@@ -244,9 +250,8 @@ async function compactSession(handle, sessionId, summarizer, config) {
244
250
  archiveId,
245
251
  },
246
252
  tokenCount: estimateTokens(summary),
247
- // Position the summary at the first compacted message's timestamp so it
248
- // sorts BEFORE the kept recent messages under createdAt-ascending order.
249
- createdAt: compactMessages[0] ? new Date(compactMessages[0].createdAt) : new Date(),
253
+ // 定位在首个被压缩消息处(并列时间戳时再早 1ms),保证摘要排在保留尾部之前。
254
+ createdAt: summaryEntryTimestamp(compactMessages[0], keepMessages[0]),
250
255
  });
251
256
  return { archiveId, fileSnapshotIds };
252
257
  });
@@ -2,8 +2,12 @@ import type { DB } from '../db/client.js';
2
2
  import type { ChatMessage } from '../shared/types/llm.js';
3
3
  import type { Message } from '../shared/types/message.js';
4
4
  import type { FileSnapshot, SessionEntry } from './types.js';
5
- /** Convert a stored Message to a protocol-level ChatMessage for the LLM. */
6
- declare function messageToChatMessage(msg: Message): ChatMessage;
5
+ /** Convert a stored Message to protocol-level ChatMessage(s) for the LLM.
6
+ * 多结果 tool 消息(同一条消息含多条 tool_result)展开为多条 tool 消息——
7
+ * 此前只取首个 result,其余结果的反馈永远到不了模型(模型会盲目重试
8
+ * 已完成/已失败的并行工具)。OpenAI 协议一条 tool 消息只能携带一个
9
+ * tool_call_id,故展开是唯一正确映射。 */
10
+ declare function messageToChatMessages(msg: Message): ChatMessage[];
7
11
  /** Convert all session entries (messages + special) to ChatMessage[] for the LLM. */
8
12
  declare function entriesToChatMessages(entries: SessionEntry[], snapshots: FileSnapshot[]): ChatMessage[];
9
13
  /** Inject file snapshot block after the first message (preserves cache prefix). */
@@ -14,4 +18,4 @@ declare function getSessionContext(handle: DB, sessionId: string, cwd?: string):
14
18
  entries: SessionEntry[];
15
19
  snapshots: FileSnapshot[];
16
20
  }>;
17
- export { entriesToChatMessages, getSessionContext, injectSnapshots, messageToChatMessage };
21
+ export { entriesToChatMessages, getSessionContext, injectSnapshots, messageToChatMessages };
@@ -74,8 +74,12 @@ function sanitizeToolPairs(messages) {
74
74
  }
75
75
  return result;
76
76
  }
77
- /** Convert a stored Message to a protocol-level ChatMessage for the LLM. */
78
- function messageToChatMessage(msg) {
77
+ /** Convert a stored Message to protocol-level ChatMessage(s) for the LLM.
78
+ * 多结果 tool 消息(同一条消息含多条 tool_result)展开为多条 tool 消息——
79
+ * 此前只取首个 result,其余结果的反馈永远到不了模型(模型会盲目重试
80
+ * 已完成/已失败的并行工具)。OpenAI 协议一条 tool 消息只能携带一个
81
+ * tool_call_id,故展开是唯一正确映射。 */
82
+ function messageToChatMessages(msg) {
79
83
  const textParts = msg.content
80
84
  .filter((p) => p._tag === 'text' || p._tag === 'thinking')
81
85
  .map((p) => (p._tag === 'thinking' ? `<think>${p.text}</think>` : p.text))
@@ -85,8 +89,25 @@ function messageToChatMessage(msg) {
85
89
  .map((p) => ({
86
90
  id: p.id,
87
91
  name: p.tool,
88
- arguments: JSON.stringify(p.input),
92
+ // 缺 input 的 tool_call(import 透传的漂移分片):JSON.stringify(undefined)
93
+ // 返回 undefined,arguments 字段缺失的调用在协议适配层行为未定义——
94
+ // 保守序列化为空对象。
95
+ arguments: JSON.stringify(p.input ?? {}),
89
96
  }));
97
+ if (msg.role === 'tool') {
98
+ const toolResults = msg.content.filter((p) => p._tag === 'tool_result');
99
+ if (toolResults.length === 0) {
100
+ // 空 tool 消息:无 toolCallId,sanitizeToolPairs 会丢弃(同旧行为)
101
+ return [{ role: 'tool', content: textParts || '' }];
102
+ }
103
+ return toolResults.map((p) => ({
104
+ role: 'tool',
105
+ toolCallId: p.id,
106
+ // 缺 output 的 tool_result:JSON.stringify(undefined) 返回 undefined,
107
+ // content 字段缺失的 tool 消息在协议适配层行为未定义——保守序列化为空对象。
108
+ content: JSON.stringify(p.output === undefined ? {} : p.output),
109
+ }));
110
+ }
90
111
  const toolResultPart = msg.content.find((p) => p._tag === 'tool_result');
91
112
  const imageParts = msg.content.filter((p) => p._tag === 'image');
92
113
  // 含图片:构建多模态 content 数组(text 在前,image 在后)
@@ -102,28 +123,33 @@ function messageToChatMessage(msg) {
102
123
  const multimodal = { role: msg.role, content: parts };
103
124
  if (toolCalls.length > 0)
104
125
  multimodal.toolCalls = toolCalls;
105
- return multimodal;
126
+ return [multimodal];
106
127
  }
107
128
  // 无图片:保持原纯字符串逻辑(零回归)
108
129
  const chat = {
109
130
  role: msg.role,
110
- content: textParts || (toolResultPart ? JSON.stringify(toolResultPart.output) : ''),
131
+ content: textParts ||
132
+ (toolResultPart
133
+ ? JSON.stringify(toolResultPart.output === undefined ? {} : toolResultPart.output)
134
+ : ''),
111
135
  };
112
136
  if (toolCalls.length > 0) {
113
137
  chat.toolCalls = toolCalls;
114
138
  }
115
139
  if (toolResultPart && toolResultPart._tag === 'tool_result') {
116
140
  chat.toolCallId = toolResultPart.id;
117
- chat.content = JSON.stringify(toolResultPart.output);
141
+ // 缺 output 的 tool_result:JSON.stringify(undefined) 返回 undefined,
142
+ // content 字段缺失的 tool 消息在协议适配层行为未定义——保守序列化为空对象。
143
+ chat.content = JSON.stringify(toolResultPart.output === undefined ? {} : toolResultPart.output);
118
144
  }
119
- return chat;
145
+ return [chat];
120
146
  }
121
147
  /** Convert all session entries (messages + special) to ChatMessage[] for the LLM. */
122
148
  function entriesToChatMessages(entries, snapshots) {
123
149
  const messages = [];
124
150
  for (const entry of entries) {
125
151
  if (!('_tag' in entry)) {
126
- messages.push(messageToChatMessage(entry));
152
+ messages.push(...messageToChatMessages(entry));
127
153
  continue;
128
154
  }
129
155
  switch (entry._tag) {
@@ -229,4 +255,4 @@ async function refreshStaleSnapshots(handle, sessionId, cwd) {
229
255
  await upsertFileSnapshot(handle, sessionId, s.filePath, content, st.mtimeMs);
230
256
  }
231
257
  }
232
- export { entriesToChatMessages, getSessionContext, injectSnapshots, messageToChatMessage };
258
+ export { entriesToChatMessages, getSessionContext, injectSnapshots, messageToChatMessages };
@@ -9,8 +9,11 @@ const VALID_ROLES = new Set([
9
9
  'tool',
10
10
  ]);
11
11
  function toDate(value) {
12
- if (typeof value === 'number')
13
- return new Date(value);
12
+ if (typeof value === 'number') {
13
+ // 非有限数值(JSON 1e999 → Infinity)会产出 Invalid Date,时间戳序列化
14
+ // 直接击穿插入事务(整单 500)。与字符串分支同口径:无法解析回退当前时间。
15
+ return Number.isFinite(value) ? new Date(value) : new Date();
16
+ }
14
17
  if (typeof value === 'string') {
15
18
  const d = new Date(value);
16
19
  if (!Number.isNaN(d.getTime()))
@@ -18,10 +21,45 @@ function toDate(value) {
18
21
  }
19
22
  return new Date();
20
23
  }
24
+ /**
25
+ * 按 MessageContent 判别字段的形状校验每个分片。
26
+ * 此前只校验 `_tag` 是字符串:`{_tag:'text', text:123}` 等类型违规分片原样入库,
27
+ * 后续续聊在 estimateTokens(123) 处 TypeError(历史会话每次对话都 500),
28
+ * 且 token/protocol 层被迫对缺字段形状逐个打补丁。导入是未受信 JSON 的唯一
29
+ * 入库入口——在此收敛:结构违规的分片整体丢弃(宁少勿坏)。
30
+ */
31
+ function isWellFormedPart(p) {
32
+ switch (p._tag) {
33
+ case 'text':
34
+ case 'thinking':
35
+ case 'steering':
36
+ return typeof p.text === 'string';
37
+ case 'tool_call':
38
+ return (typeof p.id === 'string' &&
39
+ typeof p.tool === 'string' &&
40
+ (p.input === undefined ||
41
+ (typeof p.input === 'object' && p.input !== null && !Array.isArray(p.input))));
42
+ case 'tool_result':
43
+ return (typeof p.id === 'string' &&
44
+ typeof p.tool === 'string' &&
45
+ typeof p.output === 'object' &&
46
+ p.output !== null &&
47
+ !Array.isArray(p.output));
48
+ case 'image':
49
+ return typeof p.mediaType === 'string' && typeof p.data === 'string';
50
+ default:
51
+ // 未知分片类型:丢弃(宁少勿坏)
52
+ return false;
53
+ }
54
+ }
21
55
  function sanitizeContent(content) {
22
56
  if (Array.isArray(content)) {
23
57
  // 只保留结构完整的分片;其余丢弃(导入宁少勿坏)
24
- return content.filter((p) => p !== null && typeof p === 'object' && typeof p._tag === 'string');
58
+ return content.filter((p) => p !== null &&
59
+ typeof p === 'object' &&
60
+ !Array.isArray(p) &&
61
+ typeof p._tag === 'string' &&
62
+ isWellFormedPart(p));
25
63
  }
26
64
  if (typeof content === 'string' && content.length > 0) {
27
65
  return [{ _tag: 'text', text: content }];
@@ -44,18 +82,26 @@ async function importSessionData(handle, opts) {
44
82
  await tx.update(sessions).set({ metadata: opts.metadata }).where(eq(sessions.id, session.id));
45
83
  }
46
84
  let messageCount = 0;
85
+ let lastTs = 0;
47
86
  for (const m of opts.messages) {
48
87
  const role = m.role;
49
88
  if (typeof role !== 'string' || !VALID_ROLES.has(role))
50
89
  continue;
90
+ // 时间戳严格递增(保持数组顺序):载荷缺时间戳时 toDate 回退 now(),
91
+ // 整批消息会落在同一毫秒——并列时间戳让会话内顺序退化为物理行序,
92
+ // 任何删除+重插都会打乱消息顺序。
93
+ const createdAt = new Date(Math.max(toDate(m.createdAt).getTime(), lastTs + 1));
94
+ lastTs = createdAt.getTime();
51
95
  await tx.insert(sessionEntries).values({
52
96
  id: generateId(),
53
97
  sessionId: session.id,
54
98
  tag: 'message',
55
99
  role,
56
100
  content: sanitizeContent(m.content),
57
- tokenCount: typeof m.tokenCount === 'number' ? m.tokenCount : 0,
58
- createdAt: toDate(m.createdAt),
101
+ // tokenCount 是 integer 列:非有限数值(JSON 1e999 → Infinity)会击穿
102
+ // 插入事务(整单 500);非法值收敛为 0(未知)。
103
+ tokenCount: typeof m.tokenCount === 'number' && Number.isFinite(m.tokenCount) ? m.tokenCount : 0,
104
+ createdAt,
59
105
  });
60
106
  messageCount += 1;
61
107
  }
@@ -71,7 +117,8 @@ async function importSessionData(handle, opts) {
71
117
  originalEntries: Array.isArray(a.originalEntries) ? a.originalEntries : [],
72
118
  fileSnapshots: Array.isArray(a.fileSnapshots) ? a.fileSnapshots : [],
73
119
  summary: typeof a.summary === 'string' ? a.summary : '',
74
- tokenCount: typeof a.tokenCount === 'number' ? a.tokenCount : null,
120
+ // integer 列:与消息 tokenCount 同口径拒绝非有限数值(JSON 1e999)。
121
+ tokenCount: typeof a.tokenCount === 'number' && Number.isFinite(a.tokenCount) ? a.tokenCount : null,
75
122
  searchableText: typeof a.searchableText === 'string' ? a.searchableText : null,
76
123
  createdAt: toDate(a.createdAt),
77
124
  });
@@ -1,8 +1,8 @@
1
1
  export { archiveOriginalEntries, getArchive, getArchiveOriginalEntries, parseArchiveReference, resolveArchiveReference, searchArchives, } from './archive.js';
2
2
  export { forkSession, getBranches, getTree } from './branch.js';
3
3
  export { buildCompactionPrompt, compactSession, extractHotFiles, findSafeCutPoint, } from './compaction.js';
4
- export { entriesToChatMessages, getSessionContext, injectSnapshots, messageToChatMessage, } from './context.js';
5
- export { appendMessage, deleteEntriesByIds, deleteMessagesAfter, getEntries, getMessageCount, getMessages, insertEntry, } from './message.js';
4
+ export { entriesToChatMessages, getSessionContext, injectSnapshots, messageToChatMessages, } from './context.js';
5
+ export { appendMessage, deleteEntriesByIds, deleteMessagesAfter, getEntries, getMessageCount, getMessages, insertEntry, summaryEntryTimestamp, updateEntryContent, } from './message.js';
6
6
  export { consumeBudgetPauseMarker, createSession, emptyTrash, getLLMSegments, getSession, listDeletedSessions, listSessions, markBudgetPause, migrateLegacyDetails, permanentlyDeleteSession, purgeDeletedSessions, restoreSession, restoreSessionCore, saveLLMSegments, segmentFingerprint, softDeleteSession, TRASH_RETENTION_MS, touchSession, touchTrashSeen, updateSessionTitle, } from './session.js';
7
7
  export { checkFileSnapshot, getFileSnapshots, getLatestFileSnapshot, upsertFileSnapshot, } from './snapshot.js';
8
8
  export { squashRecent } from './squash.js';
@@ -2,8 +2,8 @@
2
2
  export { archiveOriginalEntries, getArchive, getArchiveOriginalEntries, parseArchiveReference, resolveArchiveReference, searchArchives, } from './archive.js';
3
3
  export { forkSession, getBranches, getTree } from './branch.js';
4
4
  export { buildCompactionPrompt, compactSession, extractHotFiles, findSafeCutPoint, } from './compaction.js';
5
- export { entriesToChatMessages, getSessionContext, injectSnapshots, messageToChatMessage, } from './context.js';
6
- export { appendMessage, deleteEntriesByIds, deleteMessagesAfter, getEntries, getMessageCount, getMessages, insertEntry, } from './message.js';
5
+ export { entriesToChatMessages, getSessionContext, injectSnapshots, messageToChatMessages, } from './context.js';
6
+ export { appendMessage, deleteEntriesByIds, deleteMessagesAfter, getEntries, getMessageCount, getMessages, insertEntry, summaryEntryTimestamp, updateEntryContent, } from './message.js';
7
7
  export { consumeBudgetPauseMarker, createSession, emptyTrash, getLLMSegments, getSession, listDeletedSessions, listSessions, markBudgetPause, migrateLegacyDetails, permanentlyDeleteSession, purgeDeletedSessions, restoreSession, restoreSessionCore, saveLLMSegments, segmentFingerprint, softDeleteSession, TRASH_RETENTION_MS, touchSession, touchTrashSeen, updateSessionTitle, } from './session.js';
8
8
  export { checkFileSnapshot, getFileSnapshots, getLatestFileSnapshot, upsertFileSnapshot, } from './snapshot.js';
9
9
  export { squashRecent } from './squash.js';
@@ -1,7 +1,11 @@
1
1
  import type { DB } from '../db/client.js';
2
2
  import { sessionEntries } from '../db/schema.js';
3
- import type { Message } from '../shared/types/message.js';
3
+ import type { Message, MessageContent } from '../shared/types/message.js';
4
4
  import type { MessageInput, SessionEntry } from './types.js';
5
+ /** 摘要条目(compaction/squash)的时间戳:定位在首个被压缩消息处。
6
+ * 并列时间戳(fork 复制、跨机导入的历史数据)下必须再早 1ms——否则摘要落到
7
+ * 保留尾部之后,压缩语义反转(模型先看近期消息、再看「已压缩的历史」)。 */
8
+ declare function summaryEntryTimestamp(firstCompacted?: Message, firstKept?: Message): Date;
5
9
  /** Append a message to a session. Returns the stored Message with generated id/timestamp. */
6
10
  declare function appendMessage(handle: DB, sessionId: string, input: MessageInput): Promise<Message>;
7
11
  /** Get messages for a session (tag='message' only), ordered chronologically. */
@@ -23,6 +27,11 @@ declare function deleteMessagesAfter(handle: DB, sessionId: string, messageIndex
23
27
  declare function getEntries(handle: DB, sessionId: string): Promise<SessionEntry[]>;
24
28
  /** Low-level: delete entries by id. */
25
29
  declare function deleteEntriesByIds(handle: DB, ids: string[]): Promise<void>;
26
- /** Low-level: insert a raw entry row (for compaction/squash/branch_summary/steering). */
30
+ /** Low-level: insert a raw entry row (for compaction/squash/branch_summary/steering).
31
+ * 未显式给 createdAt 时取会话内单调递增时间戳(追加语义:排在全部已有条目之后)。 */
27
32
  declare function insertEntry(handle: DB, values: typeof sessionEntries.$inferInsert): Promise<typeof sessionEntries.$inferSelect>;
28
- export { appendMessage, deleteEntriesByIds, deleteMessagesAfter, getEntries, getMessageCount, getMessages, getSteeringAsMessages, insertEntry, };
33
+ /** 就地改写条目内容(shake 替换区域用)。
34
+ * 刻意不走「删除 + 重插」:重插的行落到物理末尾,并列时间戳的会话里会把
35
+ * 被改写消息挪到时间线最后——内容替换不应改变条目位置。 */
36
+ declare function updateEntryContent(handle: DB, id: string, content: MessageContent[], tokenCount: number): Promise<void>;
37
+ export { appendMessage, deleteEntriesByIds, deleteMessagesAfter, getEntries, getMessageCount, getMessages, getSteeringAsMessages, insertEntry, summaryEntryTimestamp, updateEntryContent, };
@@ -1,4 +1,4 @@
1
- import { and, asc, count, eq, inArray } from 'drizzle-orm';
1
+ import { and, asc, count, eq, inArray, max } from 'drizzle-orm';
2
2
  import { sessionEntries } from '../db/schema.js';
3
3
  import { generateId } from '../shared/index.js';
4
4
  import { touchSession } from './session.js';
@@ -74,6 +74,32 @@ function rowToEntry(row) {
74
74
  return rowToMessage(row);
75
75
  }
76
76
  }
77
+ /** 会话内单调递增的条目时间戳(逻辑时钟)。
78
+ *
79
+ * createdAt 是会话内唯一的顺序判定键(getMessages/getEntries/压缩摘要定位/
80
+ * 工具对相邻性都按它排序),因此必须**严格递增**——并列时间戳会让顺序退化成
81
+ * 物理行序,任何「删除+重插」都会把条目挪到时间线末尾。
82
+ * 会话内已有条目的时间戳可能晚于当前时刻(跨机导入、时钟偏移、快照恢复),
83
+ * 此时直接用 now() 会让新条目排到已有条目之前,故取 max(now, 会话内最大 + 1ms)。 */
84
+ async function nextEntryTimestamp(handle, sessionId) {
85
+ const [row] = await handle.db
86
+ .select({ latest: max(sessionEntries.createdAt) })
87
+ .from(sessionEntries)
88
+ .where(eq(sessionEntries.sessionId, sessionId));
89
+ const latestMs = row?.latest ? toEpochMs(row.latest) : 0;
90
+ return new Date(Math.max(Date.now(), latestMs + 1));
91
+ }
92
+ /** 摘要条目(compaction/squash)的时间戳:定位在首个被压缩消息处。
93
+ * 并列时间戳(fork 复制、跨机导入的历史数据)下必须再早 1ms——否则摘要落到
94
+ * 保留尾部之后,压缩语义反转(模型先看近期消息、再看「已压缩的历史」)。 */
95
+ function summaryEntryTimestamp(firstCompacted, firstKept) {
96
+ if (!firstCompacted)
97
+ return new Date();
98
+ const base = firstCompacted.createdAt;
99
+ if (firstKept === undefined || firstKept.createdAt > base)
100
+ return new Date(base);
101
+ return new Date(firstKept.createdAt - 1);
102
+ }
77
103
  /** Append a message to a session. Returns the stored Message with generated id/timestamp. */
78
104
  async function appendMessage(handle, sessionId, input) {
79
105
  const tokenCount = input.tokenCount ?? estimateMessageTokens(input.content);
@@ -86,6 +112,7 @@ async function appendMessage(handle, sessionId, input) {
86
112
  role: input.role,
87
113
  content: input.content,
88
114
  tokenCount,
115
+ createdAt: await nextEntryTimestamp(handle, sessionId),
89
116
  })
90
117
  .returning();
91
118
  await touchSession(handle, sessionId);
@@ -155,11 +182,24 @@ async function deleteEntriesByIds(handle, ids) {
155
182
  return;
156
183
  await handle.db.delete(sessionEntries).where(inArray(sessionEntries.id, ids));
157
184
  }
158
- /** Low-level: insert a raw entry row (for compaction/squash/branch_summary/steering). */
185
+ /** Low-level: insert a raw entry row (for compaction/squash/branch_summary/steering).
186
+ * 未显式给 createdAt 时取会话内单调递增时间戳(追加语义:排在全部已有条目之后)。 */
159
187
  async function insertEntry(handle, values) {
160
- const [row] = await handle.db.insert(sessionEntries).values(values).returning();
188
+ const rowValues = values.createdAt === undefined
189
+ ? { ...values, createdAt: await nextEntryTimestamp(handle, values.sessionId) }
190
+ : values;
191
+ const [row] = await handle.db.insert(sessionEntries).values(rowValues).returning();
161
192
  if (!row)
162
193
  throw new Error('Failed to insert entry');
163
194
  return row;
164
195
  }
165
- export { appendMessage, deleteEntriesByIds, deleteMessagesAfter, getEntries, getMessageCount, getMessages, getSteeringAsMessages, insertEntry, };
196
+ /** 就地改写条目内容(shake 替换区域用)。
197
+ * 刻意不走「删除 + 重插」:重插的行落到物理末尾,并列时间戳的会话里会把
198
+ * 被改写消息挪到时间线最后——内容替换不应改变条目位置。 */
199
+ async function updateEntryContent(handle, id, content, tokenCount) {
200
+ await handle.db
201
+ .update(sessionEntries)
202
+ .set({ content, tokenCount })
203
+ .where(eq(sessionEntries.id, id));
204
+ }
205
+ export { appendMessage, deleteEntriesByIds, deleteMessagesAfter, getEntries, getMessageCount, getMessages, getSteeringAsMessages, insertEntry, summaryEntryTimestamp, updateEntryContent, };
@@ -1,5 +1,6 @@
1
1
  import type { DB } from '../db/client.js';
2
2
  import { sessions } from '../db/schema.js';
3
+ import type { HookRunner } from '../plugins/types.js';
3
4
  import type { LLMSegment } from '../shared/types/agent.js';
4
5
  import type { ChatTool } from '../shared/types/llm.js';
5
6
  import type { LastRun, Session, SessionMetadata } from '../shared/types/message.js';
@@ -12,7 +13,9 @@ parentId?: string,
12
13
  /** 会话工作目录(CLI 会话必填:Web 打开时 agent 工具在该目录执行,而非 serve cwd)。 */
13
14
  worktreePath?: string,
14
15
  /** 初始 metadata(如工作流运行会话记录 workflowName,供中断恢复指引展示)。 */
15
- metadata?: Record<string, unknown>): Promise<Session>;
16
+ metadata?: Record<string, unknown>,
17
+ /** 插件 hook runner:创建成功后广播 session:create(可选,纯广播不阻断)。 */
18
+ hooks?: HookRunner): Promise<Session>;
16
19
  /**
17
20
  * Web 会话树可见性条件:web 会话(source 为 null/web)或「持久的 CLI 会话」。
18
21
  * 一次性 CLI print 会话与 CLI 来源的 workflow 会话是临时数据,隐藏;
@@ -118,7 +121,8 @@ export declare const TRASH_ABSOLUTE_MAX_MS: number;
118
121
  * 宽限期满才物理清除——杜绝「到期即静默物理清空」的数据丢失死角。
119
122
  */
120
123
  export declare const TRASH_PURGE_GRACE_MS: number;
121
- /** 清除结果:marked = 本次新标记进入宽限期的条目数;deleted = 本次物理清除数。 */
124
+ /** 清除结果:marked = 本次新标记进入宽限期的条目数;deleted = 本次物理清除行数
125
+ * (含随父行一并清除的后代——跨项目/未到期的子行无法在父行删除后幸存)。 */
122
126
  export type TrashPurgeResult = {
123
127
  marked: number;
124
128
  deleted: number;
@@ -127,7 +131,8 @@ export type TrashPurgeResult = {
127
131
  * 回收站两阶段清理(启动时与每日定时调用):
128
132
  * 阶段一:进入保留期截止(首次看到 + 60 天)或绝对上限(删除 + 365 天,无论是否
129
133
  * 被看到)且未标记 → 标记 purgePendingAt(进入宽限期,可恢复)。
130
- * 阶段二:purgePendingAt 早于宽限截止 → 物理清除(子会话先于父,自引用 FK 要求)。
134
+ * 阶段二:purgePendingAt 早于宽限截止 → 物理清除(子会话先于父,自引用 FK 要求;
135
+ * 删除闭包含全部后代,未到期的子行随父行一并清除)。
131
136
  * 恢复后重删、尚未重新看到的会话按「首次看到 + 60 天」计(绝对上限兜底仍生效)。
132
137
  */
133
138
  declare function purgeDeletedSessions(handle: DB, retentionMs?: number, graceMs?: number): Promise<TrashPurgeResult>;
@@ -151,8 +156,9 @@ declare function purgeEmptySession(handle: DB, id: string): Promise<boolean>;
151
156
  * 返回删除数量。会话不存在或不在回收站 → 返回 0。
152
157
  */
153
158
  declare function permanentlyDeleteSession(handle: DB, id: string): Promise<number>;
154
- /** 清空回收站:物理删除所有已软删除会话(子先于父)。返回删除数量。
155
- * projectId 提供时仅清空该项目(P1-7)。 */
159
+ /** 清空回收站:物理删除所有已软删除会话(子先于父,连同其全部后代)。
160
+ * projectId 提供时仅以该项目条目为根(P1-7)——后代闭包仍含其他项目的子行
161
+ * (它们引用被删父行,必须同批删除)。返回删除数量。 */
156
162
  declare function emptyTrash(handle: DB, projectId?: string): Promise<number>;
157
163
  /**
158
164
  * 清理过期临时会话(P2 → P1 收紧:仅处理显式标记的临时会话)。