evolcore 0.0.32 → 0.0.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.
Files changed (214) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/bin/ec.js +3 -2
  3. package/bin/install-codex-managed-hooks.mjs +135 -37
  4. package/dist/agents/baseagent-availability.js +20 -36
  5. package/dist/agents/claude-runner.js +111 -15
  6. package/dist/agents/codex/app-server-client.js +3 -0
  7. package/dist/agents/codex/diagnostics.js +112 -54
  8. package/dist/agents/codex-runner.js +241 -63
  9. package/dist/agents/ecagent-runner.js +78 -33
  10. package/dist/agents/runner-types.js +5 -6
  11. package/dist/aun/aid/identity.js +1 -1
  12. package/dist/aun/group/operations.js +0 -1
  13. package/dist/aun/msg/history.js +1 -0
  14. package/dist/aun/msg/p2p.js +0 -1
  15. package/dist/aun/outbox.js +50 -1
  16. package/dist/channels/aun.js +44 -8
  17. package/dist/channels/daemon.js +261 -3
  18. package/dist/cli/agent-command.js +58 -24
  19. package/dist/cli/agent.js +1 -1
  20. package/dist/cli/aun-commands.js +368 -31
  21. package/dist/cli/contact.js +8 -2
  22. package/dist/cli/ctl-command.js +3 -1
  23. package/dist/cli/daemon-commands.js +197 -27
  24. package/dist/cli/debug-mode.js +154 -0
  25. package/dist/cli/fs-command.js +2 -1
  26. package/dist/cli/handoff-command.js +3 -0
  27. package/dist/cli/index.js +98 -67
  28. package/dist/cli/init.js +2 -2
  29. package/dist/cli/integrity-command.js +196 -0
  30. package/dist/cli/managed-command-guard.js +5 -0
  31. package/dist/cli/memory-command.js +166 -146
  32. package/dist/cli/queue-command.js +9 -4
  33. package/dist/cli/session-command.js +194 -0
  34. package/dist/cli/skill-command.js +78 -2
  35. package/dist/cli/task-context.js +1 -0
  36. package/dist/cli/thread-command.js +622 -0
  37. package/dist/cli/trigger-command.js +10 -3
  38. package/dist/config/builtin-roles.js +1 -0
  39. package/dist/config/config-field-policy.js +6 -3
  40. package/dist/config/config-manager.js +26 -2
  41. package/dist/config/contact-book-store.js +5 -0
  42. package/dist/config/resolved-config-op.js +3 -0
  43. package/dist/config/role-service.js +13 -0
  44. package/dist/config/runtime-env.js +16 -0
  45. package/dist/config-store.js +45 -0
  46. package/dist/core/audit/log-integrity.js +3 -1
  47. package/dist/core/auth/agent-delegation.js +133 -36
  48. package/dist/core/auth/debug-context.js +139 -0
  49. package/dist/core/auth/delegation-diagnostics.js +414 -0
  50. package/dist/core/auth/operation-authorizer.js +25 -2
  51. package/dist/core/auth/operation-catalog.js +49 -0
  52. package/dist/core/background-session/session-cleanup.js +212 -0
  53. package/dist/core/background-session/types.js +10 -0
  54. package/dist/core/bootstrap-messages.js +1 -1
  55. package/dist/core/bootstrap-service.js +6 -3
  56. package/dist/core/command/agent-control.js +18 -26
  57. package/dist/core/command/command-handler.js +197 -27
  58. package/dist/core/command/daemon-diagnostics.js +1 -0
  59. package/dist/core/command/memory-slash.js +6 -2
  60. package/dist/core/command/menu-catalog.js +51 -1
  61. package/dist/core/command/menu-handler.js +114 -23
  62. package/dist/core/command/menu-protocol.js +25 -1
  63. package/dist/core/command/skill-slash.js +322 -0
  64. package/dist/core/command/slash-gate.js +6 -4
  65. package/dist/core/command/slash-handler.js +195 -13
  66. package/dist/core/command/topic-slash.js +528 -0
  67. package/dist/core/daemon-bootstrap.js +4 -7
  68. package/dist/core/evolagent-registry.js +20 -5
  69. package/dist/core/file-integrity/read.js +61 -0
  70. package/dist/core/file-integrity/store.js +262 -0
  71. package/dist/core/handoff/runtime.js +5 -11
  72. package/dist/core/handoff/store.js +5 -0
  73. package/dist/core/ipc-registration.js +175 -3
  74. package/dist/core/memory/batch-meta.js +132 -0
  75. package/dist/core/memory/consolidation-context.js +83 -29
  76. package/dist/core/memory/consolidation-ops-records.js +49 -16
  77. package/dist/core/memory/consolidation-queue-format.js +21 -0
  78. package/dist/core/memory/consolidation.js +264 -205
  79. package/dist/core/memory/index-manager.js +19 -28
  80. package/dist/core/memory/memory-admin.js +19 -14
  81. package/dist/core/memory/memory-document.js +53 -9
  82. package/dist/core/memory/memory-writer.js +30 -15
  83. package/dist/core/message/aun-ipc-sender.js +161 -6
  84. package/dist/core/message/im-renderer.js +169 -10
  85. package/dist/core/message/message-bridge.js +15 -4
  86. package/dist/core/message/message-log.js +1 -0
  87. package/dist/core/message/peer-mode.js +6 -1
  88. package/dist/core/message/response-engine.js +929 -110
  89. package/dist/core/message/subtask-tool-projection.js +140 -0
  90. package/dist/core/model/catalog-service.js +17 -12
  91. package/dist/core/permission/ec-command-parser.js +217 -9
  92. package/dist/core/permission/protected-paths.js +5 -1
  93. package/dist/core/permission/readonly-shell-query.js +43 -5
  94. package/dist/core/permission/session-path-grants.js +334 -0
  95. package/dist/core/permission/tool-canonical.js +226 -0
  96. package/dist/core/permission/tool-policy.js +30 -62
  97. package/dist/core/role/runtime-policy.js +21 -10
  98. package/dist/core/session/session-fs-store.js +123 -8
  99. package/dist/core/session/session-idle-monitor.js +249 -22
  100. package/dist/core/session/session-manager.js +129 -14
  101. package/dist/core/skill/command-parser.js +13 -0
  102. package/dist/core/skill/index-generator.js +21 -4
  103. package/dist/core/skill/startup.js +41 -0
  104. package/dist/core/skill/switch-resolver.js +55 -29
  105. package/dist/core/system-session/grants.js +40 -0
  106. package/dist/core/system-session/memory.js +222 -0
  107. package/dist/core/system-session/registry.js +74 -0
  108. package/dist/core/system-session/types.js +14 -0
  109. package/dist/core/thread/command-parser.js +125 -0
  110. package/dist/core/thread/index.js +1 -0
  111. package/dist/core/thread/ipc-operation.js +95 -0
  112. package/dist/core/thread/thread-context-storage.js +131 -0
  113. package/dist/core/thread/thread-operation.js +258 -0
  114. package/dist/core/thread/thread-route.js +33 -0
  115. package/dist/core/thread/thread-service.js +770 -0
  116. package/dist/core/thread/types.js +1 -0
  117. package/dist/eck/kit-renderer.js +7 -2
  118. package/dist/eck/manifest-engine.js +24 -4
  119. package/dist/eck/message-renderer.js +35 -0
  120. package/dist/index.js +19 -7
  121. package/dist/ipc.js +140 -50
  122. package/dist/paths.js +93 -0
  123. package/dist/response-system/engines/v1/proactive-flow.js +169 -80
  124. package/dist/stats/billing.js +20 -8
  125. package/dist/trigger/authorization.js +57 -28
  126. package/dist/trigger/feedback.js +13 -2
  127. package/dist/trigger/parser.js +70 -1
  128. package/dist/trigger/system-definitions.js +3 -3
  129. package/dist/trigger/validation.js +14 -0
  130. package/dist/utils/cross-platform.js +11 -2
  131. package/dist/utils/error-dict.json +18 -4
  132. package/dist/utils/error-utils.js +143 -1
  133. package/dist/utils/instance-registry.js +13 -8
  134. package/dist/utils/log-writer.js +12 -0
  135. package/dist/utils/process-introspect.js +11 -0
  136. package/kits/docs/context-assembly.md +1 -1
  137. package/kits/docs/evolcore/INDEX.md +1 -0
  138. package/kits/docs/evolcore/agent-create.md +40 -28
  139. package/kits/docs/evolcore/agent.md +21 -6
  140. package/kits/docs/evolcore/config.md +2 -2
  141. package/kits/docs/evolcore/ctl.md +2 -2
  142. package/kits/docs/evolcore/ec-capabilities.md +5 -1
  143. package/kits/docs/evolcore/fs-architecture.md +4 -5
  144. package/kits/docs/evolcore/fs.md +1 -1
  145. package/kits/docs/evolcore/group-collaboration.md +4 -6
  146. package/kits/docs/evolcore/group.md +11 -12
  147. package/kits/docs/evolcore/memory-architecture.md +4 -5
  148. package/kits/docs/evolcore/memory-consolidation.md +19 -15
  149. package/kits/docs/evolcore/memory-tags.md +3 -1
  150. package/kits/docs/evolcore/memory.md +24 -12
  151. package/kits/docs/evolcore/menu-exec-schema-commands.md +2 -2
  152. package/kits/docs/evolcore/menu-protocol.md +7 -0
  153. package/kits/docs/evolcore/model.md +16 -0
  154. package/kits/docs/evolcore/msg.md +8 -11
  155. package/kits/docs/evolcore/thread-chain.md +267 -0
  156. package/kits/docs/prompt-loading-architecture.md +1 -1
  157. package/kits/eck_manifest.json +55 -40
  158. package/kits/eck_manifest.memory.json +0 -12
  159. package/kits/eck_message_manifest.json +169 -10
  160. package/kits/rules/01-overview.md +11 -0
  161. package/kits/rules/03-identity.md +6 -3
  162. package/kits/rules/05-venue.md +1 -1
  163. package/kits/rules/06-channel.md +1 -1
  164. package/kits/rules/07-memory-and-thread.md +199 -0
  165. package/kits/schemas/_meta.json +7 -1
  166. package/kits/schemas/agent-config.schema.13.json +1 -1
  167. package/kits/schemas/daemon.schema.7.json +140 -0
  168. package/kits/schemas/relation-config.schema.9.json +18 -3
  169. package/kits/schemas/role-config.schema.1.json +6 -6
  170. package/kits/schemas/single-session.schema.1.json +6 -6
  171. package/kits/schemas/single-session.schema.2.json +6 -6
  172. package/kits/schemas/single-session.schema.3.json +6 -6
  173. package/kits/skills/.switches.json +12 -1
  174. package/kits/skills/ec-group/SKILL.md +234 -0
  175. package/kits/skills/ec-skill/SKILL.md +10 -1
  176. package/kits/skills/ec-skill/scripts/cli.mjs +16 -2
  177. package/kits/skills/ec-skill/scripts/skill.mjs +1 -1
  178. package/kits/skills/scrapling-article-fetch/SKILL.md +128 -0
  179. package/kits/skills/scrapling-article-fetch/scripts/check_python_env.py +54 -0
  180. package/kits/skills/scrapling-article-fetch/scripts/render_markdown_reply.py +35 -0
  181. package/kits/skills/scrapling-article-fetch/scripts/run_pipeline.sh +16 -0
  182. package/kits/skills/scrapling-article-fetch/scripts/scrapling_fetch.py +344 -0
  183. package/kits/skills/thread-ops/SKILL.md +492 -0
  184. package/kits/skills/wechat-article-media-download/SKILL.md +65 -0
  185. package/kits/skills/wechat-article-media-download/scripts/download.py +163 -0
  186. package/kits/skills/wechat-article-reader/SKILL.md +97 -0
  187. package/kits/skills/wechat-article-reader/scripts/fetch.mjs +256 -0
  188. package/kits/templates/bootstrap-welcome.md +7 -7
  189. package/kits/templates/memory-consolidation/forget.md +7 -2
  190. package/kits/templates/memory-consolidation/generalize.md +9 -4
  191. package/kits/templates/memory-consolidation/notes.md +11 -11
  192. package/kits/templates/memory-consolidation/profile.md +8 -3
  193. package/kits/templates/memory-consolidation/promote.md +8 -3
  194. package/kits/templates/memory-consolidation/workflow.md +6 -6
  195. package/kits/templates/message-fragments/handoff-request-to-target.md +1 -1
  196. package/kits/templates/message-fragments/item.md +2 -1
  197. package/kits/templates/message-fragments/thread-context.md +6 -0
  198. package/kits/templates/message-fragments/trigger-session.md +2 -0
  199. package/kits/templates/message-fragments/trigger-thread-check.md +2 -0
  200. package/kits/templates/roles/member.json +2 -0
  201. package/kits/templates/roles/visitor.json +2 -0
  202. package/kits/templates/system-fragments/bootstrap.md +67 -72
  203. package/kits/templates/system-fragments/channel.md +3 -6
  204. package/kits/templates/system-fragments/commands.md +1 -0
  205. package/kits/templates/system-fragments/managed-runtime.md +15 -0
  206. package/kits/templates/system-fragments/memory.md +16 -9
  207. package/kits/templates/system-fragments/reply.md +7 -6
  208. package/kits/templates/system-fragments/session.md +5 -14
  209. package/package.json +6 -3
  210. package/dist/core/memory/memory-session-policy.js +0 -41
  211. package/dist/core/memory/memory-signature.js +0 -257
  212. package/kits/docs/evolcore/group-fs.md +0 -17
  213. package/kits/docs/venues/aun-group.md +0 -45
  214. package/kits/docs/venues/group.md +0 -43
@@ -0,0 +1,770 @@
1
+ import crypto from 'crypto';
2
+ import fs from 'fs';
3
+ import path from 'path';
4
+ import { agentSessionsDir, agentDir, agentThreadIndexPath, agentThreadSummariesDir, agentThreadSummaryPath } from '../../paths.js';
5
+ import { atomicWriteJson, atomicReadJson } from '../../utils/atomic-write.js';
6
+ import { appendJsonl, appendJsonlDurable, readAllJsonlLines, readLastJsonlLine, readLastJsonlLineStrict, chatDirPath, tryParseChannelSessionKey } from '../session/session-fs-store.js';
7
+ import { sanitizeSessionTitle } from '../session/session-title.js';
8
+ import { ThreadContextStorage } from './thread-context-storage.js';
9
+ const THREAD_ID_RE = /^thread-[0-9]+-[0-9a-f]{4}-[0-9a-f]{4}$/;
10
+ const RESERVED_THREAD_IDS = new Set(['current', 'main', 'all']);
11
+ const THREAD_NAME_MAX_LEN = 80;
12
+ const LIST_DEFAULT_LIMIT = 10;
13
+ const LIST_MAX_LIMIT = 200;
14
+ export class ThreadServiceError extends Error {
15
+ code;
16
+ constructor(message, code) {
17
+ super(message);
18
+ this.code = code;
19
+ this.name = 'ThreadServiceError';
20
+ }
21
+ }
22
+ function newThreadId() {
23
+ const ms = Date.now();
24
+ const seq = Math.floor(Math.random() * 9999).toString().padStart(4, '0');
25
+ const rand = crypto.randomBytes(2).toString('hex');
26
+ return `thread-${ms}-${seq}-${rand}`;
27
+ }
28
+ function readIndex(indexPath) {
29
+ const raw = atomicReadJson(indexPath);
30
+ if (!raw || !Array.isArray(raw.threads)) {
31
+ return { version: 1, threads: [] };
32
+ }
33
+ return raw;
34
+ }
35
+ /**
36
+ * 把用户传入的 peer 归一成索引里实际存储的形态(对端在该渠道下的原生 ID)。
37
+ *
38
+ * 索引的 `peer` 字段存的是裸 ID(AUN 为裸 AID,内部话题为 `bg:`/`trigger:` 标识符),
39
+ * 而调用方可能传入带渠道前缀的写法。两种形态都接受:
40
+ * `leiliang.agentid.cn` → `leiliang.agentid.cn`
41
+ * `aun#leiliang.agentid.cn` → `leiliang.agentid.cn`
42
+ * `aun#main#leiliang.agentid.cn` → `leiliang.agentid.cn`(channelSessionKey)
43
+ *
44
+ * 对已经是裸 ID 的输入是幂等的;解析不出前缀时原样返回(由调用方按"无此对端"处理)。
45
+ */
46
+ export function normalizeThreadPeer(input) {
47
+ if (input == null)
48
+ return undefined;
49
+ const raw = String(input).trim();
50
+ if (!raw)
51
+ return undefined;
52
+ if (!raw.includes('#'))
53
+ return raw;
54
+ const seg = raw.split('#').pop() ?? '';
55
+ try {
56
+ return decodeURIComponent(seg).trim() || undefined;
57
+ }
58
+ catch {
59
+ return seg.trim() || undefined;
60
+ }
61
+ }
62
+ /** 内部话题(daemon 命名空间的会话记录)的对端标识前缀。 */
63
+ const INTERNAL_PEER_RE = /^(bg:|trigger:|daemon:|memory:)/;
64
+ /** 该条目是否为内部话题(非真实联系人,通常没有消息日志)。 */
65
+ export function isInternalThreadPeer(peer) {
66
+ return INTERNAL_PEER_RE.test(peer);
67
+ }
68
+ function writeIndex(indexPath, index) {
69
+ atomicWriteJson(indexPath, index);
70
+ }
71
+ /**
72
+ * 索引条目所属的渠道类型。
73
+ *
74
+ * 新条目带 `channelType` 字段,直接采用。历史条目缺该字段,按对端形态推断:
75
+ * `bg:`/`trigger:` 等内部标识只可能来自 daemon 命名空间,其余按 aun。
76
+ * 这样不重建索引也能修好内部话题的寻址(`ec thread rebuild-index` 会把真实值写回)。
77
+ */
78
+ function entryChannelType(entry) {
79
+ const t = (entry.channelType ?? '').trim();
80
+ if (t)
81
+ return t;
82
+ return entry.peer && INTERNAL_PEER_RE.test(entry.peer) ? 'daemon' : 'aun';
83
+ }
84
+ export class ThreadService {
85
+ sessionManager;
86
+ constructor(sessionManager) {
87
+ this.sessionManager = sessionManager;
88
+ }
89
+ // ── P1a.1.3: 话题索引读写 ──
90
+ readThreadIndex(selfAid) {
91
+ return readIndex(agentThreadIndexPath(selfAid));
92
+ }
93
+ writeThreadIndex(selfAid, index) {
94
+ const indexPath = agentThreadIndexPath(selfAid);
95
+ fs.mkdirSync(path.dirname(indexPath), { recursive: true });
96
+ writeIndex(indexPath, index);
97
+ }
98
+ findInIndex(selfAid, peer, threadId) {
99
+ const want = normalizeThreadPeer(peer);
100
+ const index = this.readThreadIndex(selfAid);
101
+ return index.threads.find(t => normalizeThreadPeer(t.peer) === want && t.threadId === threadId);
102
+ }
103
+ /**
104
+ * 该条目对应的会话目录。
105
+ * chatDirPath 内部会拼出 `<agentsRoot>/<aid>/sessions/<channelSessionKey>`,
106
+ * 因此 sessionsRoot 必须是 agents 根目录(~/.evolcore/agents)。
107
+ */
108
+ chatDirFor(selfAid, peer, channelType) {
109
+ const agentsRoot = path.dirname(agentDir(selfAid));
110
+ return chatDirPath(agentsRoot, entryChannelType({ channelType, peer }), peer, selfAid);
111
+ }
112
+ // ── P1a.1.2: 话题创建 ──
113
+ async createThread(input) {
114
+ const { selfAid, peerKind, name, label, topicStatus } = input;
115
+ const peer = normalizeThreadPeer(input.peer) ?? input.peer;
116
+ // Validate name
117
+ const sanitized = sanitizeSessionTitle(name);
118
+ if (!sanitized)
119
+ throw new ThreadServiceError('话题名称不能为空', 'INVALID_NAME');
120
+ if (sanitized.length > THREAD_NAME_MAX_LEN) {
121
+ throw new ThreadServiceError(`话题名称超过 ${THREAD_NAME_MAX_LEN} 字符`, 'NAME_TOO_LONG');
122
+ }
123
+ // Check for duplicate name on same peer
124
+ const index = this.readThreadIndex(selfAid);
125
+ const dup = index.threads.find(t => t.peer === peer && t.name === sanitized);
126
+ if (dup) {
127
+ throw new ThreadServiceError(`与 ${peer} 已有同名话题: ${sanitized}`, 'TOPIC_CONFLICT');
128
+ }
129
+ const threadId = newThreadId();
130
+ const now = Date.now();
131
+ // Create local session via SessionManager
132
+ // channel = 'aun#<selfAid>#main', channelId = peer, defaultProjectPath = '' (threads have no project)
133
+ const channelKey = `aun#${selfAid}#main`;
134
+ const session = await this.sessionManager.getOrCreateSession(channelKey, // channel
135
+ peer, // channelId
136
+ '', // defaultProjectPath — threads are not bound to a project
137
+ threadId, // threadId
138
+ { peerName: peer }, // metadata
139
+ sanitized, // name
140
+ undefined, // userId
141
+ peerKind === 'group' ? 'group' : 'private', // chatType
142
+ undefined, // baseagent — use default
143
+ selfAid, // selfAID
144
+ 'aun', // channelType
145
+ peerKind === 'group' ? 'group' : 'agent', // peerType
146
+ undefined, // identity
147
+ label, // label
148
+ topicStatus ?? 'pending');
149
+ const entry = {
150
+ threadId,
151
+ sessionId: session.id,
152
+ peer,
153
+ channelType: 'aun',
154
+ peerKind,
155
+ name: sanitized,
156
+ label: label ?? null,
157
+ topicStatus: topicStatus ?? 'pending',
158
+ createdAt: now,
159
+ lastActiveAt: now,
160
+ };
161
+ index.threads.push(entry);
162
+ this.writeThreadIndex(selfAid, index);
163
+ return {
164
+ threadId,
165
+ sessionId: session.id,
166
+ name: sanitized,
167
+ peer,
168
+ peerKind,
169
+ createdAt: now,
170
+ };
171
+ }
172
+ // ── P1a.1.5: 话题改名 ──
173
+ async renameThread(input) {
174
+ const { selfAid, threadId, name } = input;
175
+ const peer = normalizeThreadPeer(input.peer) ?? input.peer;
176
+ const sanitized = sanitizeSessionTitle(name);
177
+ if (!sanitized)
178
+ throw new ThreadServiceError('话题名称不能为空', 'INVALID_NAME');
179
+ if (sanitized.length > THREAD_NAME_MAX_LEN) {
180
+ throw new ThreadServiceError(`话题名称超过 ${THREAD_NAME_MAX_LEN} 字符`, 'NAME_TOO_LONG');
181
+ }
182
+ const index = this.readThreadIndex(selfAid);
183
+ const entry = index.threads.find(t => t.peer === peer && t.threadId === threadId);
184
+ if (!entry)
185
+ throw new ThreadServiceError(`话题不存在: ${threadId}`, 'TOPIC_NOT_FOUND');
186
+ if (entry.name === sanitized)
187
+ return { changed: false, name: sanitized };
188
+ entry.name = sanitized;
189
+ entry.lastActiveAt = Date.now();
190
+ this.writeThreadIndex(selfAid, index);
191
+ this.appendThreadSnapshot(selfAid, entry);
192
+ // Sync to session
193
+ const session = await this.sessionManager.getThreadSession(entryChannelType(entry), entry.peer, threadId, selfAid);
194
+ if (session) {
195
+ await this.sessionManager.renameSession(session.id, sanitized);
196
+ }
197
+ return { changed: true, name: sanitized };
198
+ }
199
+ // ── P1a.1.6: 话题标签 ──
200
+ async labelThread(input) {
201
+ const { selfAid, threadId, label } = input;
202
+ const peer = normalizeThreadPeer(input.peer) ?? input.peer;
203
+ const index = this.readThreadIndex(selfAid);
204
+ const entry = index.threads.find(t => t.peer === peer && t.threadId === threadId);
205
+ if (!entry)
206
+ throw new ThreadServiceError(`话题不存在: ${threadId}`, 'TOPIC_NOT_FOUND');
207
+ entry.label = label;
208
+ entry.lastActiveAt = Date.now();
209
+ this.writeThreadIndex(selfAid, index);
210
+ this.appendThreadSnapshot(selfAid, entry);
211
+ const session = await this.sessionManager.getThreadSession(entryChannelType(entry), entry.peer, threadId, selfAid);
212
+ if (session) {
213
+ await this.sessionManager.setTopicLabel(session.id, label);
214
+ }
215
+ }
216
+ // ── P1a.1.6: 话题状态 ──
217
+ async statusThread(input) {
218
+ const { selfAid, threadId, topicStatus } = input;
219
+ const peer = normalizeThreadPeer(input.peer) ?? input.peer;
220
+ const index = this.readThreadIndex(selfAid);
221
+ const entry = index.threads.find(t => t.peer === peer && t.threadId === threadId);
222
+ if (!entry)
223
+ throw new ThreadServiceError(`话题不存在: ${threadId}`, 'TOPIC_NOT_FOUND');
224
+ entry.topicStatus = topicStatus;
225
+ entry.lastActiveAt = Date.now();
226
+ this.writeThreadIndex(selfAid, index);
227
+ this.appendThreadSnapshot(selfAid, entry);
228
+ const session = await this.sessionManager.getThreadSession(entryChannelType(entry), entry.peer, threadId, selfAid);
229
+ if (session) {
230
+ await this.sessionManager.setTopicStatus(session.id, topicStatus);
231
+ }
232
+ }
233
+ // ── list-peers ──
234
+ /**
235
+ * 列出某个 agent 有话题记录的所有对端,按**最近活跃时间**(真实消息往来)降序。
236
+ *
237
+ * 活跃时间取自该对端消息日志的最后一行,读尾行(不整文件加载);
238
+ * 无消息日志或读取失败时退回索引里的管理动作时间。
239
+ * 内部话题(daemon 命名空间的会话记录)不是真实联系人,不在此列。
240
+ */
241
+ listPeers(selfAid) {
242
+ const index = this.readThreadIndex(selfAid);
243
+ const map = new Map();
244
+ for (const entry of index.threads) {
245
+ // 过滤内部话题(daemon 命名空间的会话记录),这类对端不是真实联系人
246
+ if (isInternalThreadPeer(entry.peer))
247
+ continue;
248
+ const existing = map.get(entry.peer);
249
+ if (!existing) {
250
+ map.set(entry.peer, {
251
+ peerKind: entry.peerKind,
252
+ threadCount: 1,
253
+ lastActiveAt: entry.lastActiveAt,
254
+ channelType: entryChannelType(entry),
255
+ });
256
+ }
257
+ else {
258
+ existing.threadCount++;
259
+ if (entry.lastActiveAt > existing.lastActiveAt)
260
+ existing.lastActiveAt = entry.lastActiveAt;
261
+ }
262
+ }
263
+ return Array.from(map.entries())
264
+ .map(([peer, v]) => {
265
+ // 真实活跃时间 = 消息日志末行的时间戳。
266
+ // 取日志值而非与索引值取大:索引里的时间来自管理动作(改名/打标签/改状态),
267
+ // 那不是"活跃"。日志不存在或读不出时才退回索引值,保证仍有可排序的兜底。
268
+ const logPath = path.join(this.chatDirFor(selfAid, peer, v.channelType), 'messages.jsonl');
269
+ let lastActiveAt = v.lastActiveAt;
270
+ try {
271
+ const last = readLastJsonlLineStrict(logPath);
272
+ if (last?.ts)
273
+ lastActiveAt = last.ts;
274
+ }
275
+ catch {
276
+ // 消息日志缺失/损坏时保持索引值
277
+ }
278
+ return { peer, peerKind: v.peerKind, threadCount: v.threadCount, lastActiveAt };
279
+ })
280
+ .sort((a, b) => b.lastActiveAt - a.lastActiveAt);
281
+ }
282
+ // ── list ──
283
+ listThreads(input) {
284
+ const { selfAid, status, label, unlabeled, sort, page = 1, limit: rawLimit } = input;
285
+ const peer = normalizeThreadPeer(input.peer);
286
+ const limit = Math.min(rawLimit ?? LIST_DEFAULT_LIMIT, LIST_MAX_LIMIT);
287
+ const index = this.readThreadIndex(selfAid);
288
+ let threads = index.threads.filter(t => {
289
+ if (peer && normalizeThreadPeer(t.peer) !== peer)
290
+ return false;
291
+ if (status && t.topicStatus !== status)
292
+ return false;
293
+ if (unlabeled && t.label)
294
+ return false;
295
+ if (label && t.label !== label)
296
+ return false;
297
+ return true;
298
+ });
299
+ // 先读消息日志并派生真实活跃时间,再排序、再分页——
300
+ // 否则 recent 排序用的是索引里的管理动作时间,与实际展示的活跃时间不一致。
301
+ const ts24h = Date.now() - 86400000;
302
+ // 按会话目录分组,每个目录只读一次 messages.jsonl。
303
+ // 键用 channelType + peer,避免不同命名空间下同名对端串数据。
304
+ const peerMessages = new Map();
305
+ for (const t of threads) {
306
+ const key = `${entryChannelType(t)}\u0000${t.peer}`;
307
+ if (!peerMessages.has(key)) {
308
+ const logPath = path.join(this.chatDirFor(selfAid, t.peer, t.channelType), 'messages.jsonl');
309
+ const msgs = [];
310
+ if (fs.existsSync(logPath)) {
311
+ try {
312
+ for (const m of readAllJsonlLines(logPath)) {
313
+ const payload = m.payloadSummary;
314
+ const text = (typeof payload === 'object' && payload !== null)
315
+ ? (payload.text ?? payload.content ?? '')
316
+ : (m.content ?? '');
317
+ msgs.push({
318
+ threadId: m.threadId ?? null,
319
+ ts: m.ts ?? 0,
320
+ snippet: String(text).replace(/\n/g, ' ').slice(0, 50),
321
+ });
322
+ }
323
+ }
324
+ catch { /* skip corrupt file */ }
325
+ }
326
+ peerMessages.set(key, msgs);
327
+ }
328
+ }
329
+ // 每条话题的最后一条消息 → 真实活跃时间。
330
+ // 不与索引值取大:索引里的时间来自管理动作(改名/打标签/改状态),不是活跃。
331
+ // 无消息时才退回索引值,保证空话题仍有可排序的兜底。
332
+ const lastMsgOf = new Map();
333
+ for (const t of threads) {
334
+ const msgs = (peerMessages.get(`${entryChannelType(t)}\u0000${t.peer}`) ?? [])
335
+ .filter(m => m.threadId === t.threadId);
336
+ const lastMsg = msgs.reduce((acc, m) => (!acc || m.ts > acc.ts) ? m : acc, null);
337
+ if (lastMsg)
338
+ lastMsgOf.set(t.threadId, lastMsg);
339
+ }
340
+ if (sort === 'created') {
341
+ threads.sort((a, b) => b.createdAt - a.createdAt);
342
+ }
343
+ else {
344
+ threads.sort((a, b) => (lastMsgOf.get(b.threadId)?.ts || b.lastActiveAt)
345
+ - (lastMsgOf.get(a.threadId)?.ts || a.lastActiveAt));
346
+ }
347
+ const total = threads.length;
348
+ const offset = (page - 1) * limit;
349
+ threads = threads.slice(offset, offset + limit);
350
+ threads = threads.map(t => {
351
+ const msgs = (peerMessages.get(`${entryChannelType(t)}\u0000${t.peer}`) ?? [])
352
+ .filter(m => m.threadId === t.threadId);
353
+ const msgTotal = msgs.length;
354
+ const msg24h = msgs.filter(m => m.ts >= ts24h).length;
355
+ const lastMsg = lastMsgOf.get(t.threadId);
356
+ const lastActiveAt = lastMsg?.ts || t.lastActiveAt;
357
+ // 读该话题的 summary(当前最新版本)
358
+ const summaryEntry = this.showSummary(selfAid, t.sessionId);
359
+ const summarySnippet = summaryEntry
360
+ ? summaryEntry.content.replace(/\n/g, ' ').slice(0, 100)
361
+ : undefined;
362
+ return {
363
+ ...t,
364
+ lastActiveAt,
365
+ internal: isInternalThreadPeer(t.peer),
366
+ msgTotal,
367
+ msg24h,
368
+ lastMsgSnippet: lastMsg?.snippet ?? '',
369
+ summarySnippet,
370
+ };
371
+ });
372
+ const totalMsgCount = threads.reduce((sum, t) => sum + (t.msgTotal ?? 0), 0);
373
+ return { threads, total, page, limit, hasMore: offset + threads.length < total, totalMsgCount, peer, selfAid, sort, status, label };
374
+ }
375
+ // ── show ──
376
+ async showThread(input) {
377
+ const { selfAid, peer, threadId } = input;
378
+ const entry = this.findInIndex(selfAid, peer, threadId);
379
+ if (!entry)
380
+ throw new ThreadServiceError(`话题不存在: ${threadId}`, 'TOPIC_NOT_FOUND');
381
+ // getThreadSession is only used to derive the `running` flag; if it fails
382
+ // (e.g. baseagent is empty in an old session file), fall back to running=false
383
+ // rather than letting the error propagate and block the whole show operation.
384
+ let running = false;
385
+ try {
386
+ const session = await this.sessionManager.getThreadSession(entryChannelType(entry), entry.peer, threadId, selfAid);
387
+ running = session?.processingState?.startsWith('processing') ?? false;
388
+ }
389
+ catch {
390
+ // ignore — running defaults to false
391
+ }
392
+ return { ...entry, running };
393
+ }
394
+ // ── history ──
395
+ /**
396
+ * 读取某话题(或主会话)的全部消息,时间正序。
397
+ *
398
+ * 从 messages.jsonl 按 threadId 过滤出该话题(或主会话)的全部消息,时间正序。
399
+ * historyThread 在其上做 direction / 时间区间过滤与分页;summary 游标取末条 ts。
400
+ */
401
+ _readThreadMessages(selfAid, peerInput, threadId) {
402
+ const peer = normalizeThreadPeer(peerInput) ?? peerInput;
403
+ // 不检查索引——允许读取已删除话题的历史(session 文件已移入 _trash,但 messages.jsonl 仍在)
404
+ // 索引里能查到条目时用条目的命名空间(内部话题在 daemon 下),查不到时退回 aun。
405
+ const indexed = this.readThreadIndex(selfAid).threads.find(t => t.threadId === threadId && normalizeThreadPeer(t.peer) === peer);
406
+ const chatDir = this.chatDirFor(selfAid, indexed?.peer ?? peer, indexed?.channelType);
407
+ const logPath = path.join(chatDir, 'messages.jsonl');
408
+ if (!fs.existsSync(logPath))
409
+ return { peer, messages: [] };
410
+ const messages = readAllJsonlLines(logPath)
411
+ .filter((m) => {
412
+ // threadId 为 undefined/null 时读主会话消息(threadId === null 的行),
413
+ // 否则按 threadId 精确匹配子话题消息。
414
+ if (threadId)
415
+ return m.threadId === threadId;
416
+ return m.threadId == null;
417
+ })
418
+ .map((m) => {
419
+ // 消息体优先取 payloadSummary.text(加密消息的明文摘要),
420
+ // 其次 content 字段,最后降级为空字符串。
421
+ const payload = m.payloadSummary;
422
+ const text = (typeof payload === 'object' && payload !== null)
423
+ ? (payload.text ?? payload.content ?? '')
424
+ : (m.content ?? '');
425
+ return {
426
+ msgId: String(m.msgId ?? m.id ?? ''),
427
+ dir: m.dir ?? 'in',
428
+ from: m.from ?? '',
429
+ to: m.to ?? '',
430
+ time: new Date(m.ts ?? 0).toLocaleString('zh-CN'),
431
+ timestamp: m.ts ?? 0,
432
+ msgType: m.msgType ?? 'text',
433
+ content: String(text),
434
+ sessionId: '',
435
+ };
436
+ });
437
+ return { peer, messages };
438
+ }
439
+ async historyThread(input) {
440
+ const { selfAid, threadId, limit = 50, before, after, direction = 'all' } = input;
441
+ const { peer, messages: all } = this._readThreadMessages(selfAid, input.peer, threadId);
442
+ const filtered = all.filter(m => {
443
+ if (direction === 'in' && m.dir !== 'in')
444
+ return false;
445
+ if (direction === 'out' && m.dir !== 'out')
446
+ return false;
447
+ if (before && m.timestamp > before)
448
+ return false;
449
+ if (after && m.timestamp < after)
450
+ return false;
451
+ return true;
452
+ });
453
+ const shown = filtered.slice(-limit);
454
+ return {
455
+ count: Math.min(filtered.length, limit),
456
+ total: filtered.length,
457
+ messages: shown,
458
+ threadId: threadId ?? '',
459
+ peer,
460
+ };
461
+ }
462
+ // ── index rebuild ──
463
+ async rebuildIndex(selfAid, dryRun = false) {
464
+ const sessionsDir = agentSessionsDir(selfAid);
465
+ const entries = [];
466
+ for (const dirEntry of fs.readdirSync(sessionsDir, { withFileTypes: true })) {
467
+ if (!dirEntry.isDirectory())
468
+ continue;
469
+ const chatDir = path.join(sessionsDir, dirEntry.name);
470
+ const threadsDir = path.join(chatDir, '_threads');
471
+ if (!fs.existsSync(threadsDir))
472
+ continue;
473
+ for (const f of fs.readdirSync(threadsDir, { withFileTypes: true })) {
474
+ if (!f.isFile() || !f.name.endsWith('.jsonl'))
475
+ continue;
476
+ const sessionId = f.name.replace('.jsonl', '');
477
+ const metaPath = path.join(threadsDir, f.name);
478
+ try {
479
+ const lines = readAllJsonlLines(metaPath);
480
+ const last = lines[lines.length - 1];
481
+ if (!last?.threadId)
482
+ continue;
483
+ // 渠道类型从目录名(channelSessionKey)解析,避免把 daemon 会话记成 aun。
484
+ const parsedDir = tryParseChannelSessionKey(dirEntry.name);
485
+ entries.push({
486
+ threadId: last.threadId,
487
+ sessionId,
488
+ peer: last.channelId ?? '',
489
+ channelType: parsedDir?.channelType ?? 'aun',
490
+ peerKind: last.chatType === 'group' ? 'group' : 'private',
491
+ name: last.name ?? last.threadId,
492
+ label: last.label ?? null,
493
+ topicStatus: last.topicStatus ?? 'pending',
494
+ createdAt: last.createdAt ?? 0,
495
+ lastActiveAt: last.updatedAt ?? 0,
496
+ });
497
+ }
498
+ catch {
499
+ // Skip corrupt files
500
+ }
501
+ }
502
+ }
503
+ if (!dryRun) {
504
+ const index = { version: 1, threads: entries };
505
+ this.writeThreadIndex(selfAid, index);
506
+ }
507
+ return { rebuilt: entries.length };
508
+ }
509
+ /**
510
+ * 检查话题索引的健康状态,区分两类问题:
511
+ * missing — _threads JSONL 文件不存在(数据问题,严重,rebuild 会丢失属性改动)
512
+ * stale — 文件存在但最后一行的 name/label/topicStatus 与索引不一致(写盘时序问题,轻微)
513
+ */
514
+ checkIndex(selfAid) {
515
+ const index = this.readThreadIndex(selfAid);
516
+ const missingIds = [];
517
+ const staleIds = [];
518
+ for (const entry of index.threads) {
519
+ // 按条目自己的命名空间(aun / daemon)拼目录。历史上这里写死 `aun#main#`,
520
+ // 导致 daemon 命名空间的内部话题 100% 被误判为 missing。
521
+ const chatDir = this.chatDirFor(selfAid, entry.peer, entry.channelType);
522
+ const metaPath = path.join(chatDir, '_threads', `${entry.sessionId}.jsonl`);
523
+ if (!fs.existsSync(metaPath)) {
524
+ missingIds.push(entry.threadId);
525
+ continue;
526
+ }
527
+ // 检查最后一行是否与索引一致(name / label / topicStatus)
528
+ try {
529
+ const lines = readAllJsonlLines(metaPath);
530
+ const last = lines[lines.length - 1];
531
+ if (last && (last.name !== entry.name ||
532
+ (last.label ?? null) !== (entry.label ?? null) ||
533
+ (last.topicStatus ?? 'pending') !== entry.topicStatus)) {
534
+ staleIds.push(entry.threadId);
535
+ }
536
+ }
537
+ catch {
538
+ // 文件损坏,归入 missing
539
+ missingIds.push(entry.threadId);
540
+ }
541
+ }
542
+ return {
543
+ total: index.threads.length,
544
+ missing: missingIds.length, missingIds,
545
+ stale: staleIds.length, staleIds,
546
+ };
547
+ }
548
+ /**
549
+ * 查主会话(show <peer> 不带 threadId):读取与某对端的 active session。
550
+ */
551
+ async showMainSession(selfAid, peer) {
552
+ const normalized = normalizeThreadPeer(peer) ?? peer;
553
+ const channelKey = `aun#${selfAid}#main`;
554
+ const session = await this.sessionManager.getActiveSession(channelKey, normalized);
555
+ if (!session)
556
+ return undefined;
557
+ return {
558
+ threadId: session.threadId ?? '',
559
+ sessionId: session.id,
560
+ peer: normalized,
561
+ channelType: 'aun',
562
+ peerKind: session.chatType === 'group' ? 'group' : 'private',
563
+ name: session.name ?? peer,
564
+ label: session.label ?? null,
565
+ topicStatus: session.topicStatus ?? 'pending',
566
+ createdAt: session.createdAt ?? 0,
567
+ lastActiveAt: session.updatedAt ?? 0,
568
+ running: session.processingState?.startsWith('processing') ?? false,
569
+ };
570
+ }
571
+ // ── touch (update lastActiveAt) ──
572
+ touchThread(selfAid, peer, threadId) {
573
+ const want = normalizeThreadPeer(peer);
574
+ const indexPath = agentThreadIndexPath(selfAid);
575
+ const index = readIndex(indexPath);
576
+ const entry = index.threads.find(t => normalizeThreadPeer(t.peer) === want && t.threadId === threadId);
577
+ if (!entry)
578
+ return;
579
+ entry.lastActiveAt = Date.now();
580
+ fs.mkdirSync(path.dirname(indexPath), { recursive: true });
581
+ writeIndex(indexPath, index);
582
+ }
583
+ // ── P2.1: 话题删除 ──
584
+ /**
585
+ * 删除话题:与 Menu /topic delete 保持一致,走 unbindSession 软删除机制
586
+ * (session 文件移入 chatDir/_trash/,索引条目直接删除)。
587
+ * 禁止删除:busy(processingState 有值)的话题,以及 threadId 为空的主会话。
588
+ */
589
+ async deleteThread(selfAid, peer, threadId) {
590
+ if (!threadId)
591
+ throw new ThreadServiceError('不能删除主会话', 'NOT_A_TOPIC');
592
+ const want = normalizeThreadPeer(peer);
593
+ const indexPath = agentThreadIndexPath(selfAid);
594
+ const index = readIndex(indexPath);
595
+ const entryIdx = index.threads.findIndex(t => normalizeThreadPeer(t.peer) === want && t.threadId === threadId);
596
+ if (entryIdx < 0)
597
+ throw new ThreadServiceError(`话题不存在: ${threadId}`, 'TOPIC_NOT_FOUND');
598
+ const entry = index.threads[entryIdx];
599
+ // 检查话题会话是否繁忙
600
+ const session = await this.sessionManager.getSessionById(entry.sessionId).catch(() => null);
601
+ if (session?.processingState) {
602
+ throw new ThreadServiceError(`话题正在处理中,无法删除: ${threadId}`, 'TOPIC_BUSY');
603
+ }
604
+ // 通过 unbindSession 软删除(与 Menu /topic delete 行为一致)
605
+ await this.sessionManager.unbindSession(entry.sessionId);
606
+ // 从 agent 级话题索引删掉条目
607
+ index.threads.splice(entryIdx, 1);
608
+ fs.mkdirSync(path.dirname(indexPath), { recursive: true });
609
+ writeIndex(indexPath, index);
610
+ }
611
+ // ── ref resolution ──
612
+ static resolveRef(ref) {
613
+ if (ref === 'current')
614
+ return { kind: 'current' };
615
+ const slash = ref.lastIndexOf('/');
616
+ if (slash < 0)
617
+ return { kind: 'peer', peer: ref };
618
+ return { kind: 'thread', peer: ref.slice(0, slash), threadId: ref.slice(slash + 1) };
619
+ }
620
+ /**
621
+ * 按 sessionId 在话题索引里反查 peer + threadId。
622
+ * 用于 daemon 侧把 "current" ref 解析成具体话题。
623
+ */
624
+ resolveCurrentThread(selfAid, sessionId) {
625
+ const index = this.readThreadIndex(selfAid);
626
+ const entry = index.threads.find(t => t.sessionId === sessionId);
627
+ if (entry)
628
+ return { peer: entry.peer, threadId: entry.threadId };
629
+ return undefined;
630
+ }
631
+ /**
632
+ * 把话题的当前元数据追加一行到 _threads/<sessionId>.jsonl,
633
+ * 使 rebuildIndex 读最后一行时能拿到 rename/label/status 后的最新值。
634
+ * 追加的行以最后一行(当前权威快照)为底,只覆盖话题字段,
635
+ * 确保 readMetaLatest 读最后一行时不报 "baseagent is empty"。
636
+ *
637
+ * 不能从第一行继承:第一行是创建记录,agentSessionId 为 null、没有 turnState,
638
+ * 继承它会把已绑定的后端会话冲掉;而且 meta 文件超限压缩后第一行也不再是创建记录。
639
+ */
640
+ appendThreadSnapshot(selfAid, entry) {
641
+ const chatDir = this.chatDirFor(selfAid, entry.peer, entry.channelType);
642
+ const metaPath = path.join(chatDir, '_threads', `${entry.sessionId}.jsonl`);
643
+ if (!fs.existsSync(metaPath))
644
+ return;
645
+ const latest = readLastJsonlLine(metaPath) ?? {};
646
+ const now = entry.lastActiveAt;
647
+ appendJsonlDurable(metaPath, {
648
+ // session fields (id, baseagent, agentSessionId, metadata, activeTask...) — from the latest snapshot
649
+ ...latest,
650
+ id: latest.id ?? entry.sessionId,
651
+ projectPath: latest.projectPath ?? '',
652
+ chatType: entry.peerKind === 'group' ? 'group' : 'private',
653
+ metadata: latest.metadata ?? {},
654
+ // topic metadata — updated values
655
+ threadId: entry.threadId,
656
+ channelIdAlias: entry.peer,
657
+ name: entry.name,
658
+ label: entry.label,
659
+ topicStatus: entry.topicStatus,
660
+ createdAt: entry.createdAt,
661
+ createdAtStr: latest.createdAtStr ?? '',
662
+ updatedAt: now,
663
+ updatedAtStr: new Date(now).toISOString(),
664
+ });
665
+ }
666
+ // ── P1b static helpers (SessionManager 不参与) ──
667
+ /** 按 threadId 查找话题条目。用于 CLI 层校验,不需要实例化。 */
668
+ static findThreadById(selfAid, threadId) {
669
+ const index = readIndex(agentThreadIndexPath(selfAid));
670
+ return index.threads.find(t => t.threadId === threadId);
671
+ }
672
+ // ── Thread Context inbox(跨会话上下文的提醒缓存) ──
673
+ /** 跨话题发送成功后写入本端 inbox。 */
674
+ static appendInbox(selfAid, entry) {
675
+ new ThreadContextStorage().append(selfAid, entry);
676
+ }
677
+ /** 读取某会话的全部待注入 inbox 记录(含空洞检测结果)。 */
678
+ static readInbox(selfAid, targetSessionId) {
679
+ const storage = new ThreadContextStorage();
680
+ return storage.readAll(selfAid, targetSessionId).map(entry => ({
681
+ ...entry,
682
+ gapDetection: storage.detectGap(selfAid, entry.originSessionId, entry.timestamp),
683
+ }));
684
+ }
685
+ /** 注入完成后删除 inbox(注入一次语义)。 */
686
+ static clearInbox(selfAid, targetSessionId) {
687
+ new ThreadContextStorage().deleteFile(selfAid, targetSessionId);
688
+ }
689
+ // ── P3.1: 话题 summary 读写 ──
690
+ /**
691
+ * 写入一条 summary 记录(追加到 JSONL)。
692
+ * 支持 CAS:若传入 ifRev,只有当前最新版本号匹配时才写入。
693
+ *
694
+ * 自动记录 `coveredThroughTs`:写入时刻该话题最后一条消息的时间戳,
695
+ * 作为"这条摘要覆盖到哪"的游标。下次总结时用它作 `history --after` 起点,
696
+ * 只读增量。话题为空(或主会话无消息)时不记录该字段。
697
+ */
698
+ setSummary(selfAid, sessionId, content, ifRev) {
699
+ const dir = agentThreadSummariesDir(selfAid);
700
+ fs.mkdirSync(dir, { recursive: true });
701
+ const filePath = agentThreadSummaryPath(selfAid, sessionId);
702
+ const existing = this._readSummaryLines(filePath);
703
+ const currentRev = existing.length;
704
+ if (ifRev !== undefined && ifRev !== currentRev) {
705
+ throw new ThreadServiceError(`summary CAS 冲突:期望版本 ${ifRev},当前版本 ${currentRev}`, 'SUMMARY_CAS_CONFLICT');
706
+ }
707
+ const coveredThroughTs = this.resolveCoveredThroughTs(selfAid, sessionId);
708
+ const entry = {
709
+ rev: currentRev + 1,
710
+ content,
711
+ updatedAt: Date.now(),
712
+ ...(coveredThroughTs !== undefined ? { coveredThroughTs } : {}),
713
+ };
714
+ appendJsonl(filePath, entry);
715
+ return { rev: entry.rev, ...(coveredThroughTs !== undefined ? { coveredThroughTs } : {}) };
716
+ }
717
+ /**
718
+ * 取该话题最新一条消息的时间戳,作为摘要的覆盖游标。
719
+ *
720
+ * 话题内总结时这就是"当前消息",跨话题总结时(先读全增量再写)也是同一语义。
721
+ * 话题不存在或无消息时返回 undefined——此时摘要没有游标,下次总结只能全量读。
722
+ */
723
+ resolveCoveredThroughTs(selfAid, sessionId) {
724
+ const entry = this.readThreadIndex(selfAid).threads.find(t => t.sessionId === sessionId);
725
+ if (!entry)
726
+ return undefined;
727
+ const { messages } = this._readThreadMessages(selfAid, entry.peer, entry.threadId);
728
+ if (messages.length === 0)
729
+ return undefined;
730
+ return messages[messages.length - 1].timestamp;
731
+ }
732
+ /** 读取当前 summary(最新版本)。若不存在或已清空则返回 undefined。 */
733
+ showSummary(selfAid, sessionId) {
734
+ const filePath = agentThreadSummaryPath(selfAid, sessionId);
735
+ if (!fs.existsSync(filePath))
736
+ return undefined;
737
+ const all = readAllJsonlLines(filePath);
738
+ if (all.length === 0)
739
+ return undefined;
740
+ const last = all[all.length - 1];
741
+ if (last.content === null)
742
+ return undefined; // 已 clear
743
+ return last;
744
+ }
745
+ /** 读取 summary 全部历史版本(含 set 操作,不含 clear 墓碑)。 */
746
+ logSummary(selfAid, sessionId) {
747
+ const filePath = agentThreadSummaryPath(selfAid, sessionId);
748
+ return this._readSummaryLines(filePath);
749
+ }
750
+ /**
751
+ * 清空当前 summary(追加 content=null 的墓碑记录,历史版本保留)。
752
+ * 清空后 showSummary 返回 undefined,logSummary 仍有历史。
753
+ */
754
+ clearSummary(selfAid, sessionId) {
755
+ const filePath = agentThreadSummaryPath(selfAid, sessionId);
756
+ const existing = this._readSummaryLines(filePath);
757
+ if (existing.length === 0)
758
+ return; // 已无内容,幂等
759
+ const dir = agentThreadSummariesDir(selfAid);
760
+ fs.mkdirSync(dir, { recursive: true });
761
+ appendJsonl(filePath, { rev: existing.length + 1, content: null, updatedAt: Date.now() });
762
+ }
763
+ _readSummaryLines(filePath) {
764
+ if (!fs.existsSync(filePath))
765
+ return [];
766
+ const all = readAllJsonlLines(filePath);
767
+ // 过滤掉 clear 墓碑(content=null),只返回实际写入的版本
768
+ return all.filter(l => l.content !== null);
769
+ }
770
+ }