evolcore 0.0.28 → 0.0.29

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 (183) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/bin/ec-safe-output.js +270 -54
  3. package/dist/agents/baseagent-availability.js +37 -1
  4. package/dist/agents/baseagent.js +19 -9
  5. package/dist/agents/claude-runner.js +171 -13
  6. package/dist/agents/codex/app-server-client.js +6 -2
  7. package/dist/agents/codex/diagnostics.js +4 -3
  8. package/dist/agents/codex-runner.js +396 -95
  9. package/dist/agents/gemini-runner.js +35 -17
  10. package/dist/aun/group/history.js +130 -90
  11. package/dist/aun/group/operations.js +1 -3
  12. package/dist/aun/menu/transport.js +5 -3
  13. package/dist/aun/msg/managed-operation.js +70 -0
  14. package/dist/aun/msg/p2p.js +1 -3
  15. package/dist/aun/msg/upload.js +2 -2
  16. package/dist/aun/outbox.js +1 -37
  17. package/dist/channels/aun.js +249 -89
  18. package/dist/channels/daemon.js +18 -1
  19. package/dist/channels/feishu.js +1 -2
  20. package/dist/cli/agent-command.js +23 -3
  21. package/dist/cli/agent-create.js +68 -2
  22. package/dist/cli/agent.js +12 -2
  23. package/dist/cli/aun-commands.js +87 -13
  24. package/dist/cli/cli-argv.js +26 -0
  25. package/dist/cli/daemon-commands.js +40 -10
  26. package/dist/cli/data-command.js +4 -4
  27. package/dist/cli/handoff-command.js +2 -0
  28. package/dist/cli/index.js +18 -1
  29. package/dist/cli/managed-command-guard.js +10 -0
  30. package/dist/cli/memory-command.js +441 -0
  31. package/dist/cli/restart-monitor.js +62 -6
  32. package/dist/cli/skill-command.js +41 -0
  33. package/dist/cli/task-context.js +2 -23
  34. package/dist/cli/trigger-command.js +9 -1
  35. package/dist/cli/watch-msg-diag.js +209 -0
  36. package/dist/config/config-field-policy.js +52 -3
  37. package/dist/config/config-manager.js +17 -4
  38. package/dist/config/contact-book-store.js +2 -2
  39. package/dist/config/mention-mode.js +7 -2
  40. package/dist/config/merge.js +28 -9
  41. package/dist/config/resolved-config-op.js +1 -1
  42. package/dist/config/runtime-env.js +137 -0
  43. package/dist/config/schema-registry.js +11 -1
  44. package/dist/config-store.js +10 -10
  45. package/dist/core/audit/event-key.js +3 -1
  46. package/dist/core/audit/log-integrity.js +4 -0
  47. package/dist/core/auth/agent-delegation.js +2 -1
  48. package/dist/core/auth/auth-gateway.js +13 -6
  49. package/dist/core/auth/authorization-audit.js +54 -19
  50. package/dist/core/auth/operation-authorizer.js +17 -0
  51. package/dist/core/auth/operation-catalog.js +9 -0
  52. package/dist/core/causation/context.js +5 -1
  53. package/dist/core/command/agent-control.js +1 -2
  54. package/dist/core/command/command-handler.js +51 -23
  55. package/dist/core/command/memory-slash.js +270 -0
  56. package/dist/core/command/menu-catalog.js +1 -0
  57. package/dist/core/command/menu-handler.js +315 -60
  58. package/dist/core/command/menu-protocol.js +42 -4
  59. package/dist/core/command/slash-gate.js +10 -10
  60. package/dist/core/command/slash-handler.js +206 -64
  61. package/dist/core/data-migration.js +282 -505
  62. package/dist/core/event-catalog.js +26 -0
  63. package/dist/core/evolagent-registry.js +6 -0
  64. package/dist/core/evolagent.js +0 -10
  65. package/dist/core/handoff/store.js +1 -20
  66. package/dist/core/inference/text-inference.js +57 -1
  67. package/dist/core/memory/consolidation-context.js +505 -0
  68. package/dist/core/memory/consolidation-ops-records.js +124 -0
  69. package/dist/core/memory/consolidation-trigger.js +394 -0
  70. package/dist/core/memory/index-manager.js +308 -0
  71. package/dist/core/memory/launch-consolidation.js +395 -0
  72. package/dist/core/memory/memory-admin.js +234 -0
  73. package/dist/core/memory/memory-document.js +100 -0
  74. package/dist/core/memory/memory-session-policy.js +41 -0
  75. package/dist/core/memory/memory-signature.js +257 -0
  76. package/dist/core/memory/memory-writer.js +432 -0
  77. package/dist/core/memory/staleness.js +117 -0
  78. package/dist/core/message/auto-mention-gate.js +454 -0
  79. package/dist/core/message/im-renderer.js +69 -16
  80. package/dist/core/message/message-bridge.js +287 -21
  81. package/dist/core/message/message-log.js +1 -0
  82. package/dist/core/message/message-queue.js +114 -27
  83. package/dist/core/message/pending-hints.js +329 -161
  84. package/dist/core/message/response-engine.js +567 -80
  85. package/dist/core/permission/ec-command-parser.js +1029 -80
  86. package/dist/core/permission/protected-paths.js +376 -126
  87. package/dist/core/permission/sandbox-runtime.js +40 -12
  88. package/dist/core/permission/tool-error-code.js +96 -13
  89. package/dist/core/permission/tool-policy.js +276 -23
  90. package/dist/core/role/runtime-policy.js +3 -7
  91. package/dist/core/session/{runner-task-history.js → runner-event-summary.js} +1 -1
  92. package/dist/core/session/session-fs-store.js +0 -76
  93. package/dist/core/session/session-idle-monitor.js +355 -0
  94. package/dist/core/session/session-manager.js +478 -17
  95. package/dist/core/session/session-mapper.js +33 -16
  96. package/dist/core/session/session-renew.js +217 -155
  97. package/dist/core/session/{session-turns.js → session-transcript.js} +35 -6
  98. package/dist/core/skill/global-watcher.js +282 -0
  99. package/dist/core/skill/index-generator.js +84 -0
  100. package/dist/core/skill/startup.js +73 -0
  101. package/dist/core/skill/switch-resolver.js +115 -0
  102. package/dist/core/task/task-activity-store.js +219 -0
  103. package/dist/eck/kit-renderer.js +30 -3
  104. package/dist/eck/manifest-engine.js +26 -4
  105. package/dist/index.js +708 -18
  106. package/dist/ipc.js +564 -2
  107. package/dist/paths.js +30 -9
  108. package/dist/response-system/engines/v1/proactive-flow.js +116 -15
  109. package/dist/response-system/modes/single-session/index.js +3 -0
  110. package/dist/trigger/feedback.js +1 -0
  111. package/dist/trigger/manager.js +0 -10
  112. package/dist/trigger/scheduler.js +11 -0
  113. package/dist/trigger/validation.js +22 -0
  114. package/dist/utils/avatar-upload.js +59 -18
  115. package/dist/utils/error-dict.json +6 -3
  116. package/dist/utils/error-utils.js +27 -0
  117. package/dist/utils/logger.js +32 -10
  118. package/dist/utils/media-cache.js +84 -0
  119. package/dist/utils/msg-diag.js +97 -0
  120. package/dist/utils/welcome.js +37 -8
  121. package/kits/docs/AUTHORING.md +314 -0
  122. package/kits/docs/GUIDE.md +2 -0
  123. package/kits/docs/INDEX.md +2 -0
  124. package/kits/docs/aun/CHEATSHEET.md +4 -0
  125. package/kits/docs/aun/SDK.md +60 -0
  126. package/kits/docs/context-assembly.md +10 -6
  127. package/kits/docs/evolcore/INDEX.md +17 -3
  128. package/kits/docs/evolcore/agent-create.md +24 -0
  129. package/kits/docs/evolcore/agent.md +5 -2
  130. package/kits/docs/evolcore/config.md +1 -1
  131. package/kits/docs/evolcore/ec-capabilities.md +364 -0
  132. package/kits/docs/evolcore/group.md +34 -18
  133. package/kits/docs/evolcore/memory-architecture.md +78 -0
  134. package/kits/docs/evolcore/memory-consolidation-batches.md +133 -0
  135. package/kits/docs/evolcore/memory-consolidation.md +263 -0
  136. package/kits/docs/evolcore/memory-tags.md +63 -0
  137. package/kits/docs/evolcore/memory.md +110 -0
  138. package/kits/docs/evolcore/menu-client.md +27 -11
  139. package/kits/{schemas → docs/evolcore}/menu-exec-schema-commands.md +12 -6
  140. package/kits/docs/evolcore/menu-protocol.md +213 -0
  141. package/kits/docs/evolcore/msg.md +2 -0
  142. package/kits/docs/evolcore/trigger.md +7 -5
  143. package/kits/docs/path-registry.md +6 -8
  144. package/kits/docs/prompt-loading-architecture.md +5 -3
  145. package/kits/docs/venues/group.md +5 -4
  146. package/kits/eck_manifest.json +294 -88
  147. package/kits/eck_manifest.memory.json +257 -0
  148. package/kits/rules/03-identity.md +8 -8
  149. package/kits/rules/05-venue.md +4 -2
  150. package/kits/rules/06-channel.md +1 -1
  151. package/kits/schemas/_meta.json +276 -47
  152. package/kits/schemas/agent-config.schema.12.json +38 -2
  153. package/kits/schemas/agent-config.schema.13.json +591 -0
  154. package/kits/schemas/daemon.schema.6.json +9 -9
  155. package/kits/schemas/defaults.schema.5.json +29 -0
  156. package/kits/schemas/relation-config.schema.8.json +14 -1
  157. package/kits/schemas/relation-config.schema.9.json +129 -0
  158. package/kits/schemas/role-config.schema.1.json +3 -3
  159. package/kits/skills/.switches.json +3 -0
  160. package/kits/skills/ec-skill/SKILL.md +217 -0
  161. package/kits/skills/ec-skill/scripts/cli.mjs +128 -0
  162. package/kits/skills/ec-skill/scripts/deps.mjs +107 -0
  163. package/kits/skills/ec-skill/scripts/frontmatter.mjs +104 -0
  164. package/kits/skills/ec-skill/scripts/skill.mjs +629 -0
  165. package/kits/skills/ec-skill/scripts/template/SKILL.md.tmpl +30 -0
  166. package/kits/templates/decision/auto-mention.md +25 -0
  167. package/kits/templates/decision/session-renew.md +22 -0
  168. package/kits/templates/memory-consolidation/forget.md +104 -0
  169. package/kits/templates/memory-consolidation/generalize.md +76 -0
  170. package/kits/templates/memory-consolidation/notes.md +133 -0
  171. package/kits/templates/memory-consolidation/profile.md +58 -0
  172. package/kits/templates/memory-consolidation/promote.md +72 -0
  173. package/kits/templates/memory-consolidation/workflow.md +75 -0
  174. package/kits/templates/system-fragments/bootstrap.md +14 -5
  175. package/kits/templates/system-fragments/commands.md +1 -0
  176. package/kits/templates/system-fragments/group-history.md +6 -0
  177. package/kits/templates/system-fragments/identity.md +0 -3
  178. package/kits/templates/system-fragments/memory.md +79 -0
  179. package/kits/templates/system-fragments/reply.md +1 -0
  180. package/kits/templates/system-fragments/venue.md +1 -1
  181. package/package.json +4 -6
  182. package/dist/trigger/legacy-session-history.js +0 -19
  183. package/kits/templates/system-fragments/group-unread-history.md +0 -7
@@ -2,14 +2,228 @@ import { ensureDir } from '../../utils/atomic-write.js';
2
2
  import { resolvePaths } from '../../paths.js';
3
3
  import { logger } from '../../utils/logger.js';
4
4
  import { encodePath } from '../../utils/cross-platform.js';
5
- import { readRunnerTaskHistory } from './runner-task-history.js';
5
+ import { readRunnerEventSummary } from './runner-event-summary.js';
6
+ import { isInternalInjectedUserMessage, isToolResultMessage } from './session-transcript.js';
6
7
  import { chatDirPath, generateSessionId, formatTimestamp, atomicWriteJson, atomicWriteText, appendJsonl, appendJsonlDurable, fsyncJsonlFile, readJsonFile, readLastJsonlLine, readLastJsonlLineStrict, readAllJsonlLines, scanChatDirs, scanMetaFiles, ensureChatDir, readThreadIndex, writeThreadIndex, } from './session-fs-store.js';
7
- import { sessionToFile, fileToSession, formatSessionKey, DEFAULT_THREAD_ID, isArchivedTopicLable, normalizeTopicLable, } from './session-mapper.js';
8
+ import { sessionToFile, fileToSession, formatSessionKey, DEFAULT_THREAD_ID, normalizeTopicLabel, normalizeTopicStatus, } from './session-mapper.js';
8
9
  import { tryParseChannelKey } from '../channel-loader.js';
9
10
  import { isSystemControlChannel } from '../system-channels.js';
10
11
  import path from 'path';
11
12
  import fs from 'fs';
12
13
  import os from 'os';
14
+ import { createHash } from 'node:crypto';
15
+ function forkHistoryRecord(value) {
16
+ return value && typeof value === 'object' && !Array.isArray(value)
17
+ ? value
18
+ : undefined;
19
+ }
20
+ export function isForkHistorySnapshot(value) {
21
+ const snapshot = forkHistoryRecord(value);
22
+ if (snapshot?.version !== 1 || typeof snapshot.assistantMessageId !== 'string'
23
+ || !snapshot.assistantMessageId || !Array.isArray(snapshot.items))
24
+ return false;
25
+ return snapshot.items.every((value, index) => {
26
+ const item = forkHistoryRecord(value);
27
+ return item?.index === index
28
+ && typeof item.sourceMessageId === 'string'
29
+ && (item.role === 'user' || item.role === 'assistant')
30
+ && ['text', 'tool_call', 'tool_result', 'attachment', 'file_change'].includes(String(item.kind))
31
+ && typeof item.text === 'string'
32
+ && (item.tool === undefined || forkHistoryRecord(item.tool) !== undefined);
33
+ });
34
+ }
35
+ const MAX_FORK_HISTORY_ITEM_TEXT_BYTES = 16 * 1024;
36
+ const MAX_FORK_HISTORY_TOOL_VALUE_BYTES = 8 * 1024;
37
+ const MAX_FORK_HISTORY_PAGE_JSON_BYTES = 128 * 1024;
38
+ function cleanForkHistoryUserText(text) {
39
+ let cleaned = text.replace(/^【新消息插入】\s*/, '')
40
+ .replace(/\s*【(?:请无视之前中断继续处理|请根据前后消息酌情处理)】$/, '').trim();
41
+ if (cleaned.startsWith('‹')) {
42
+ const newline = cleaned.indexOf('\n');
43
+ if (newline >= 0)
44
+ cleaned = cleaned.slice(newline + 1).trim();
45
+ }
46
+ if (cleaned.startsWith('<messages>')) {
47
+ const parts = [...cleaned.matchAll(/<message(?:\s[^>]*)?>([\s\S]*?)<\/message>/g)]
48
+ .map(match => match[1].trim());
49
+ if (parts.length > 0)
50
+ cleaned = parts.join('\n');
51
+ }
52
+ return cleaned;
53
+ }
54
+ function splitForkHistoryText(text) {
55
+ if (Buffer.byteLength(text, 'utf8') <= MAX_FORK_HISTORY_ITEM_TEXT_BYTES)
56
+ return [text];
57
+ const chunks = [];
58
+ let chunk = '';
59
+ let bytes = 0;
60
+ for (const character of text) {
61
+ const characterBytes = Buffer.byteLength(character, 'utf8');
62
+ if (bytes + characterBytes > MAX_FORK_HISTORY_ITEM_TEXT_BYTES && chunk) {
63
+ chunks.push(chunk);
64
+ chunk = '';
65
+ bytes = 0;
66
+ }
67
+ chunk += character;
68
+ bytes += characterBytes;
69
+ }
70
+ if (chunk)
71
+ chunks.push(chunk);
72
+ return chunks;
73
+ }
74
+ function projectForkHistoryToolValue(value) {
75
+ if (value === undefined)
76
+ return undefined;
77
+ try {
78
+ const serialized = JSON.stringify(value);
79
+ if (serialized === undefined)
80
+ return undefined;
81
+ if (Buffer.byteLength(serialized, 'utf8') <= MAX_FORK_HISTORY_TOOL_VALUE_BYTES) {
82
+ return JSON.parse(serialized);
83
+ }
84
+ let preview = '';
85
+ let bytes = 0;
86
+ for (const character of serialized) {
87
+ const characterBytes = Buffer.byteLength(character, 'utf8');
88
+ if (bytes + characterBytes > MAX_FORK_HISTORY_TOOL_VALUE_BYTES - 16)
89
+ break;
90
+ preview += character;
91
+ bytes += characterBytes;
92
+ }
93
+ return `${preview}... [truncated]`;
94
+ }
95
+ catch {
96
+ return '[unavailable]';
97
+ }
98
+ }
99
+ function projectForkHistoryContent(content, type) {
100
+ if (typeof content === 'string') {
101
+ const text = type === 'user' ? cleanForkHistoryUserText(content) : content.trim();
102
+ return text ? [{ kind: 'text', text }] : [];
103
+ }
104
+ if (!Array.isArray(content))
105
+ return [];
106
+ const parts = [];
107
+ for (const rawBlock of content) {
108
+ const block = forkHistoryRecord(rawBlock);
109
+ if (!block)
110
+ continue;
111
+ const blockType = typeof block.type === 'string' ? block.type : '';
112
+ if (blockType === 'text' || blockType === 'input_text' || blockType === 'output_text') {
113
+ const text = typeof block.text === 'string'
114
+ ? (type === 'user' ? cleanForkHistoryUserText(block.text) : block.text.trim())
115
+ : '';
116
+ if (text)
117
+ parts.push({ kind: 'text', text });
118
+ }
119
+ else if (blockType === 'tool_use') {
120
+ const name = typeof block.name === 'string' ? block.name : '工具';
121
+ parts.push({
122
+ kind: 'tool_call',
123
+ text: name,
124
+ tool: {
125
+ name,
126
+ ...(typeof block.id === 'string' ? { callId: block.id } : {}),
127
+ ...(block.input !== undefined ? { arguments: projectForkHistoryToolValue(block.input) } : {}),
128
+ },
129
+ });
130
+ }
131
+ else if (blockType === 'tool_result') {
132
+ parts.push({
133
+ kind: 'tool_result',
134
+ text: '工具返回结果',
135
+ tool: {
136
+ ...(typeof block.name === 'string' ? { name: block.name } : {}),
137
+ ...(typeof block.tool_use_id === 'string' ? { callId: block.tool_use_id } : {}),
138
+ ...(block.content !== undefined ? { result: projectForkHistoryToolValue(block.content) } : {}),
139
+ ok: block.is_error !== true,
140
+ },
141
+ });
142
+ }
143
+ else if (blockType === 'image' || blockType === 'localImage' || blockType === 'image_url') {
144
+ parts.push({ kind: 'attachment', text: '[图片]' });
145
+ }
146
+ else if (blockType === 'document' || blockType === 'file') {
147
+ const name = typeof block.name === 'string' ? block.name : '';
148
+ parts.push({ kind: 'attachment', text: name ? `[文件] ${name}` : '[文件]' });
149
+ }
150
+ else if (blockType === 'file_change') {
151
+ parts.push({ kind: 'file_change', text: '文件变更' });
152
+ }
153
+ }
154
+ return parts;
155
+ }
156
+ export function buildForkHistorySnapshot(messages, assistantMessageId) {
157
+ let boundary = -1;
158
+ for (let index = 0; index < messages.length; index++) {
159
+ if (messages[index].uuid === assistantMessageId
160
+ && (messages[index].type === 'assistant'
161
+ || (messages[index].type === 'user'
162
+ && !isInternalInjectedUserMessage(messages[index].message)))) {
163
+ if (boundary >= 0)
164
+ throw new Error('fork history anchor is ambiguous');
165
+ boundary = index;
166
+ }
167
+ }
168
+ if (boundary < 0)
169
+ throw new Error('fork history anchor is missing');
170
+ const items = [];
171
+ for (let sourceIndex = 0; sourceIndex <= boundary; sourceIndex++) {
172
+ const source = messages[sourceIndex];
173
+ if (source.type === 'user' && isInternalInjectedUserMessage(source.message))
174
+ continue;
175
+ if (source.type !== 'user' && source.type !== 'assistant' && source.type !== 'system')
176
+ continue;
177
+ const message = forkHistoryRecord(source.message);
178
+ const content = message?.content ?? source.message;
179
+ const toolResult = source.type === 'user' && isToolResultMessage(source.message);
180
+ const role = source.type === 'user' && !toolResult ? 'user' : 'assistant';
181
+ const projected = projectForkHistoryContent(content, source.type);
182
+ for (let partIndex = 0; partIndex < projected.length; partIndex++) {
183
+ const part = projected[partIndex];
184
+ if (source.type === 'system' && part.kind !== 'file_change')
185
+ continue;
186
+ const chunks = splitForkHistoryText(part.text);
187
+ for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) {
188
+ items.push({
189
+ index: items.length,
190
+ sourceMessageId: `${source.uuid || sourceIndex}:${sourceIndex}:${partIndex}:${chunkIndex}`,
191
+ role,
192
+ kind: toolResult ? 'tool_result' : part.kind,
193
+ text: chunks[chunkIndex],
194
+ ...(part.tool ? { tool: part.tool } : {}),
195
+ });
196
+ }
197
+ }
198
+ }
199
+ if (items.length === 0)
200
+ throw new Error('fork history has no displayable messages');
201
+ return { version: 1, assistantMessageId, items };
202
+ }
203
+ export function pageForkHistory(snapshot, before = snapshot.items.length, limit = 20) {
204
+ if (!Number.isInteger(before) || before < 1 || before > snapshot.items.length
205
+ || !Number.isInteger(limit) || limit < 1 || limit > 20) {
206
+ throw new Error('invalid fork history page');
207
+ }
208
+ let start = before;
209
+ let bytes = 2;
210
+ while (start > 0 && before - start < limit) {
211
+ const itemBytes = Buffer.byteLength(JSON.stringify(snapshot.items[start - 1]), 'utf8');
212
+ const nextBytes = bytes + itemBytes + (start < before ? 1 : 0);
213
+ if (nextBytes > MAX_FORK_HISTORY_PAGE_JSON_BYTES) {
214
+ if (start === before)
215
+ throw new Error('fork history item exceeds page budget');
216
+ break;
217
+ }
218
+ bytes = nextBytes;
219
+ start--;
220
+ }
221
+ return {
222
+ items: snapshot.items.slice(start, before),
223
+ nextBefore: start > 0 ? start : null,
224
+ total: snapshot.items.length,
225
+ };
226
+ }
13
227
  /** Metadata keys owned by ordinary topic/session routing writers. */
14
228
  const TOPIC_METADATA_PROTECTED_KEYS = new Set(['turnState', 'resumeAt', 'agentSessions']);
15
229
  const TOPIC_BACKEND_SCOPED_KEYS = ['resumeAt', 'agentSessions'];
@@ -292,6 +506,119 @@ export class SessionManager {
292
506
  const targetDir = session.threadId ? path.join(dir, '_threads') : dir;
293
507
  return this.metaFilePath(targetDir, session.id);
294
508
  }
509
+ forkHistoryPath(session) {
510
+ if (!session.threadId)
511
+ throw new Error('fork history requires a topic session');
512
+ return path.join(this.resolveChatDirFromSession(session), '_threads', `${session.id}.fork-history.json`);
513
+ }
514
+ forkHistoryBackupPath(session) {
515
+ return `${this.forkHistoryPath(session)}.bak`;
516
+ }
517
+ forkHistoryText(snapshot, assistantMessageId) {
518
+ if (!isForkHistorySnapshot(snapshot) || snapshot.items.length === 0
519
+ || snapshot.assistantMessageId !== assistantMessageId) {
520
+ throw new Error('invalid fork history snapshot');
521
+ }
522
+ return `${JSON.stringify(snapshot)}\n`;
523
+ }
524
+ forkHistoryDigest(content) {
525
+ return createHash('sha256').update(content, 'utf8').digest('hex');
526
+ }
527
+ readForkHistory(session) {
528
+ const source = session.metadata?.forkSource;
529
+ if (!source?.historyVersion)
530
+ return undefined;
531
+ if (source.historyVersion !== 1 || typeof source.historyDigest !== 'string'
532
+ || !/^[a-f0-9]{64}$/.test(source.historyDigest)
533
+ || !Number.isInteger(source.historyCount) || source.historyCount < 1) {
534
+ throw new Error('fork history metadata is invalid');
535
+ }
536
+ const primary = this.forkHistoryPath(session);
537
+ const backup = this.forkHistoryBackupPath(session);
538
+ const checkedRead = (file) => {
539
+ const content = fs.readFileSync(file, 'utf8');
540
+ if (this.forkHistoryDigest(content) !== source.historyDigest) {
541
+ throw new Error('fork history checksum mismatch');
542
+ }
543
+ const snapshot = JSON.parse(content);
544
+ if (!isForkHistorySnapshot(snapshot) || snapshot.assistantMessageId !== source.assistantMessageId
545
+ || snapshot.items.length !== source.historyCount) {
546
+ throw new Error('fork history snapshot is invalid');
547
+ }
548
+ return snapshot;
549
+ };
550
+ try {
551
+ return checkedRead(primary);
552
+ }
553
+ catch (primaryError) {
554
+ try {
555
+ const snapshot = checkedRead(backup);
556
+ try {
557
+ atomicWriteText(primary, fs.readFileSync(backup, 'utf8'));
558
+ }
559
+ catch (error) {
560
+ logger.warn(`[SessionManager] Failed to repair fork history primary: ${error}`);
561
+ }
562
+ return snapshot;
563
+ }
564
+ catch {
565
+ throw primaryError;
566
+ }
567
+ }
568
+ }
569
+ async attachForkHistory(sessionId, snapshot) {
570
+ const loaded = this.loadSessionForUpdate(sessionId);
571
+ const session = loaded?.current;
572
+ const source = session?.metadata?.forkSource;
573
+ if (!session?.threadId || !source)
574
+ throw new Error('fork topic is missing');
575
+ if (source.historyVersion) {
576
+ this.readForkHistory(session);
577
+ return session;
578
+ }
579
+ const file = this.forkHistoryPath(session);
580
+ const backup = this.forkHistoryBackupPath(session);
581
+ let content;
582
+ let validFile;
583
+ let recovered;
584
+ for (const candidate of [file, backup]) {
585
+ try {
586
+ const existingContent = fs.readFileSync(candidate, 'utf8');
587
+ const existing = JSON.parse(existingContent);
588
+ if (!isForkHistorySnapshot(existing) || existing.items.length === 0
589
+ || existing.assistantMessageId !== source.assistantMessageId)
590
+ continue;
591
+ content = existingContent;
592
+ recovered = existing;
593
+ validFile = candidate;
594
+ break;
595
+ }
596
+ catch {
597
+ // Try the other copy or a projected legacy transcript.
598
+ }
599
+ }
600
+ if (!content || !recovered) {
601
+ if (!snapshot)
602
+ return undefined;
603
+ content = this.forkHistoryText(snapshot, source.assistantMessageId);
604
+ recovered = snapshot;
605
+ }
606
+ if (validFile !== file)
607
+ atomicWriteText(file, content);
608
+ if (validFile !== backup)
609
+ atomicWriteText(backup, content);
610
+ session.metadata = {
611
+ ...session.metadata,
612
+ forkSource: {
613
+ ...source,
614
+ historyVersion: 1,
615
+ historyCount: recovered.items.length,
616
+ historyDigest: this.forkHistoryDigest(content),
617
+ },
618
+ };
619
+ this.persistSession(session, 'none');
620
+ return session;
621
+ }
295
622
  appendMeta(channel, channelId, session) {
296
623
  const file = sessionToFile(session);
297
624
  const metaPath = this.metaPathForSession(session);
@@ -332,9 +659,10 @@ export class SessionManager {
332
659
  // backend boundary itself. Always take the latest binding and owner.
333
660
  session.agentSessionId = latestId;
334
661
  session.baseagent = latest.baseagent;
335
- // Classification is owned by setTopicLable, not stale turn snapshots.
336
- if (!opts?.lableMutation)
337
- session.lable = latest.lable;
662
+ if (!opts?.topicMutation) {
663
+ session.label = latest.label;
664
+ session.topicStatus = latest.topicStatus;
665
+ }
338
666
  // Merge metadata at field level as well. A caller can hold a stale
339
667
  // Session object across an await; preserving keys from the latest
340
668
  // snapshot prevents unrelated turn/route updates from being erased.
@@ -349,6 +677,9 @@ export class SessionManager {
349
677
  : { turnState: undefined })
350
678
  : {}),
351
679
  };
680
+ if (latest.metadata?.forkSource?.historyVersion === 1) {
681
+ mergedMetadata.forkSource = JSON.parse(JSON.stringify(latest.metadata.forkSource));
682
+ }
352
683
  for (const key of TOPIC_BACKEND_SCOPED_KEYS) {
353
684
  if (Object.prototype.hasOwnProperty.call(latest.metadata ?? {}, key)) {
354
685
  const value = latest.metadata[key];
@@ -445,6 +776,12 @@ export class SessionManager {
445
776
  this.persistSession(session, 'sync');
446
777
  return undefined;
447
778
  }
779
+ hasBoundAgentSession(session) {
780
+ if (!session.agentSessionId)
781
+ return false;
782
+ const adapter = this.getFileAdapter(session.baseagent);
783
+ return !!adapter?.checkExists(session.projectPath, session.agentSessionId, session.selfAID);
784
+ }
448
785
  getActiveChatType(channel, channelId) {
449
786
  const active = this.readActive(channel, channelId);
450
787
  if (active && !active.threadId)
@@ -550,6 +887,69 @@ export class SessionManager {
550
887
  throw error;
551
888
  }
552
889
  }
890
+ assertTopicResumeMutationTarget(current, input) {
891
+ if (!current.threadId || current.threadId !== input.expectedThreadId) {
892
+ const error = new Error('topic resumeAt threadId mismatch');
893
+ error.code = 'THREAD_MISMATCH';
894
+ throw error;
895
+ }
896
+ if (input.expectedAgentSessionId !== undefined
897
+ && normalizeBackendSessionId(current.agentSessionId) !== normalizeBackendSessionId(input.expectedAgentSessionId)) {
898
+ const error = new Error('topic resumeAt backend mismatch');
899
+ error.code = 'BACKEND_MISMATCH';
900
+ throw error;
901
+ }
902
+ }
903
+ /** Persist a pending Claude-style topic rewind anchor without generic metadata writers. */
904
+ async setTopicResumeAt(input) {
905
+ const resumeAt = input.resumeAt.trim();
906
+ if (!input.expectedThreadId.trim())
907
+ throw new Error('topic resumeAt requires a non-empty threadId');
908
+ if (!resumeAt)
909
+ throw new Error('topic resumeAt requires a non-empty anchor');
910
+ this.mutateLatestTopicSession(input.sessionId, (current, found) => {
911
+ this.assertTopicResumeMutationTarget(current, input);
912
+ const next = JSON.parse(JSON.stringify(current));
913
+ next.metadata = { ...(next.metadata ?? {}), resumeAt };
914
+ this.persistConfirmedTopicBoundary(current, found, next);
915
+ });
916
+ }
917
+ /** Clear a pending topic rewind anchor after provider rollback or backend rotation. */
918
+ async clearTopicResumeAt(input) {
919
+ if (!input.expectedThreadId.trim())
920
+ throw new Error('topic resumeAt requires a non-empty threadId');
921
+ this.mutateLatestTopicSession(input.sessionId, (current, found) => {
922
+ this.assertTopicResumeMutationTarget(current, input);
923
+ if (!current.metadata || !Object.prototype.hasOwnProperty.call(current.metadata, 'resumeAt'))
924
+ return;
925
+ const next = JSON.parse(JSON.stringify(current));
926
+ const metadata = { ...(next.metadata ?? {}) };
927
+ delete metadata.resumeAt;
928
+ next.metadata = metadata;
929
+ this.persistConfirmedTopicBoundary(current, found, next);
930
+ });
931
+ }
932
+ /** Atomically consume a topic rewind anchor for the matching backend turn. */
933
+ async consumeTopicResumeAt(input) {
934
+ if (!input.expectedThreadId.trim())
935
+ throw new Error('topic resumeAt requires a non-empty threadId');
936
+ return this.mutateLatestTopicSession(input.sessionId, (current, found) => {
937
+ this.assertTopicResumeMutationTarget(current, input);
938
+ const resumeAt = typeof current.metadata?.resumeAt === 'string'
939
+ ? current.metadata.resumeAt.trim()
940
+ : '';
941
+ if (!resumeAt)
942
+ return undefined;
943
+ if (input.expectedResumeAt !== undefined && resumeAt !== input.expectedResumeAt.trim())
944
+ return undefined;
945
+ const next = JSON.parse(JSON.stringify(current));
946
+ const metadata = { ...(next.metadata ?? {}) };
947
+ delete metadata.resumeAt;
948
+ next.metadata = metadata;
949
+ this.persistConfirmedTopicBoundary(current, found, next);
950
+ return resumeAt;
951
+ });
952
+ }
553
953
  /** Clear a topic binding in one short latest-snapshot mutation. */
554
954
  async rotateTopicBackend(input) {
555
955
  if (!input.expectedThreadId.trim())
@@ -730,7 +1130,7 @@ export class SessionManager {
730
1130
  return result;
731
1131
  }
732
1132
  // ─── Session lifecycle ───
733
- async getOrCreateSession(channel, channelId, defaultProjectPath, threadId, metadata, name, userId, chatType, baseagent, selfAID, channelType, peerType, identity, lable) {
1133
+ async getOrCreateSession(channel, channelId, defaultProjectPath, threadId, metadata, name, userId, chatType, baseagent, selfAID, channelType, peerType, identity, label, topicStatus) {
734
1134
  if (isSystemControlChannel(channel) || isSystemControlChannel(channelType)) {
735
1135
  throw new Error(`[SessionManager] system control channel is sessionless. channel="${channel}"`);
736
1136
  }
@@ -744,7 +1144,7 @@ export class SessionManager {
744
1144
  // 用于 resolveIdentity fallback 正确命中群成员角色表(见 resolvePeerRoleDetail)。
745
1145
  const identityConversationId = chatType === 'group' ? (metadata?.groupId || channelId) : userId;
746
1146
  if (threadId) {
747
- const session = this.getOrCreateThreadSession(channel, channelId, threadId, defaultProjectPath, metadata, name, baseagent, selfAID, channelType, peerType, chatType, lable);
1147
+ const session = this.getOrCreateThreadSession(channel, channelId, threadId, defaultProjectPath, metadata, name, baseagent, selfAID, channelType, peerType, chatType, label, topicStatus);
748
1148
  session.identity = identity ?? this.resolveIdentity(channel, userId, chatType, identityConversationId);
749
1149
  return session;
750
1150
  }
@@ -965,7 +1365,7 @@ export class SessionManager {
965
1365
  current.metadata = metadata;
966
1366
  this.persistSession(current, 'sync', { turnStateMutation: true });
967
1367
  }
968
- getOrCreateThreadSession(channel, channelId, threadId, defaultProjectPath, metadata, name, baseagent, selfAID, channelType, peerType, chatType, lable) {
1368
+ getOrCreateThreadSession(channel, channelId, threadId, defaultProjectPath, metadata, name, baseagent, selfAID, channelType, peerType, chatType, label, topicStatus) {
969
1369
  // 使用精确路径(channelType + selfAID)
970
1370
  const chatDir = (channelType && selfAID)
971
1371
  ? (() => { const d = chatDirPath(this.sessionsDir, channelType, channelId, selfAID, channel); fs.mkdirSync(d, { recursive: true }); fs.mkdirSync(path.join(d, '_threads'), { recursive: true }); return d; })()
@@ -1028,7 +1428,8 @@ export class SessionManager {
1028
1428
  chatType: effectiveChatType,
1029
1429
  metadata,
1030
1430
  name: name || '话题会话',
1031
- lable: normalizeTopicLable(lable),
1431
+ label: normalizeTopicLabel(label),
1432
+ topicStatus: topicStatus ? normalizeTopicStatus(topicStatus) : 'pending',
1032
1433
  createdAt: Date.now(),
1033
1434
  updatedAt: Date.now(),
1034
1435
  };
@@ -1307,7 +1708,7 @@ export class SessionManager {
1307
1708
  return true;
1308
1709
  if (!session.threadId)
1309
1710
  return archiveFilter !== 'archived';
1310
- const archived = isArchivedTopicLable(session.lable);
1711
+ const archived = session.topicStatus === 'archived';
1311
1712
  return archiveFilter === 'archived' ? archived : !archived;
1312
1713
  });
1313
1714
  return sessions.sort((a, b) => b.updatedAt - a.updatedAt);
@@ -1376,13 +1777,22 @@ export class SessionManager {
1376
1777
  this.persistSession(current, 'sync');
1377
1778
  return true;
1378
1779
  }
1379
- async setTopicLable(sessionId, lable) {
1380
- const normalized = normalizeTopicLable(lable);
1780
+ async setTopicLabel(sessionId, label) {
1781
+ const normalized = normalizeTopicLabel(label);
1782
+ const loaded = this.loadSessionForUpdate(sessionId);
1783
+ if (!loaded?.current.threadId)
1784
+ return false;
1785
+ loaded.current.label = normalized;
1786
+ this.persistSession(loaded.current, 'sync', { topicMutation: true });
1787
+ return true;
1788
+ }
1789
+ async setTopicStatus(sessionId, status) {
1790
+ const normalized = normalizeTopicStatus(status);
1381
1791
  const loaded = this.loadSessionForUpdate(sessionId);
1382
1792
  if (!loaded?.current.threadId)
1383
1793
  return false;
1384
- loaded.current.lable = normalized;
1385
- this.persistSession(loaded.current, 'sync', { lableMutation: true });
1794
+ loaded.current.topicStatus = normalized;
1795
+ this.persistSession(loaded.current, 'sync', { topicMutation: true });
1386
1796
  return true;
1387
1797
  }
1388
1798
  async unbindSession(sessionId) {
@@ -1401,6 +1811,17 @@ export class SessionManager {
1401
1811
  }
1402
1812
  // If thread session, remove from thread-index
1403
1813
  if (found.isThread) {
1814
+ for (const suffix of ['.fork-history.json', '.fork-history.json.bak', '.fork-history.json.tmp']) {
1815
+ const historyFile = path.join(found.chatDir, '_threads', `${sessionId}${suffix}`);
1816
+ const historyTrash = path.join(trashDir, `${sessionId}${suffix}`);
1817
+ try {
1818
+ fs.renameSync(historyFile, historyTrash);
1819
+ }
1820
+ catch (e) {
1821
+ if (e.code !== 'ENOENT')
1822
+ logger.warn(`[SessionManager] Failed to move fork history to trash: ${e}`);
1823
+ }
1824
+ }
1404
1825
  const threadIndex = readThreadIndex(found.chatDir);
1405
1826
  for (const [tid, entry] of Object.entries(threadIndex)) {
1406
1827
  if (entry.sessionId === sessionId) {
@@ -1454,6 +1875,19 @@ export class SessionManager {
1454
1875
  throw e;
1455
1876
  }
1456
1877
  }
1878
+ if (fs.existsSync(threadsDir)) {
1879
+ for (const file of fs.readdirSync(threadsDir).filter(name => /\.fork-history\.json(?:\.bak|\.tmp)?$/.test(name))) {
1880
+ const src = path.join(threadsDir, file);
1881
+ const dst = path.join(trashDir, file);
1882
+ try {
1883
+ fs.renameSync(src, dst);
1884
+ }
1885
+ catch (e) {
1886
+ if (e.code !== 'ENOENT')
1887
+ throw e;
1888
+ }
1889
+ }
1890
+ }
1457
1891
  // Clear active.json
1458
1892
  const activePath = path.join(dirPath, 'active.json');
1459
1893
  try {
@@ -1593,6 +2027,8 @@ export class SessionManager {
1593
2027
  const threadIndex = readThreadIndex(chatDir);
1594
2028
  if (threadIndex[targetThreadId])
1595
2029
  throw new Error('target thread already exists');
2030
+ const historyContent = this.forkHistoryText(options.historySnapshot, options.sourceAssistantMessageId);
2031
+ const historyDigest = this.forkHistoryDigest(historyContent);
1596
2032
  const now = Date.now();
1597
2033
  const sessionKey = formatSessionKey(channelType, sourceSession.channelId, targetThreadId);
1598
2034
  const sourceMetadata = sourceSession.metadata || {};
@@ -1606,9 +2042,14 @@ export class SessionManager {
1606
2042
  sessionId: sourceSession.id,
1607
2043
  threadId: sourceSession.threadId || null,
1608
2044
  assistantMessageId: options.sourceAssistantMessageId,
2045
+ ...(options.requestedSourceAssistantMessageId
2046
+ ? { requestedAssistantMessageId: options.requestedSourceAssistantMessageId } : {}),
1609
2047
  turn: options.sourceTurn,
1610
2048
  name: sourceSession.name || null,
1611
2049
  createdAt: now,
2050
+ historyVersion: 1,
2051
+ historyCount: options.historySnapshot.items.length,
2052
+ historyDigest,
1612
2053
  ...(options.requestId ? { requestId: options.requestId } : {}),
1613
2054
  },
1614
2055
  };
@@ -1626,12 +2067,17 @@ export class SessionManager {
1626
2067
  agentSessionId: forkedAgentSessionId,
1627
2068
  metadata,
1628
2069
  name: options.name || `${sourceSession.name || '会话'} · 第 ${options.sourceTurn} 轮`,
1629
- lable: normalizeTopicLable(options.lable === undefined ? sourceSession.lable : options.lable),
2070
+ label: normalizeTopicLabel(options.label === undefined ? sourceSession.label : options.label),
2071
+ topicStatus: options.topicStatus ? normalizeTopicStatus(options.topicStatus) : 'pending',
1630
2072
  createdAt: now,
1631
2073
  updatedAt: now,
1632
2074
  };
1633
- this.persistSession(session, 'none');
2075
+ const historyFile = this.forkHistoryPath(session);
2076
+ const historyBackup = this.forkHistoryBackupPath(session);
1634
2077
  try {
2078
+ atomicWriteText(historyFile, historyContent);
2079
+ atomicWriteText(historyBackup, historyContent);
2080
+ this.persistSession(session, 'none');
1635
2081
  threadIndex[targetThreadId] = {
1636
2082
  sessionId: session.id,
1637
2083
  sessionKey,
@@ -1644,6 +2090,14 @@ export class SessionManager {
1644
2090
  fs.unlinkSync(this.metaPathForSession(session));
1645
2091
  }
1646
2092
  catch { }
2093
+ try {
2094
+ fs.unlinkSync(historyFile);
2095
+ }
2096
+ catch { }
2097
+ try {
2098
+ fs.unlinkSync(historyBackup);
2099
+ }
2100
+ catch { }
1647
2101
  throw error;
1648
2102
  }
1649
2103
  this.eventBus.publish({
@@ -1700,6 +2154,13 @@ export class SessionManager {
1700
2154
  cause: 'renew',
1701
2155
  sourceSessionId: sourceSession.id,
1702
2156
  renewSource: context?.source,
2157
+ // 渠道快照:供记忆整理触发用(不依赖旧会话是否还有活动 taskCtx)
2158
+ sourceChannel: sourceSession.channelType || sourceSession.channel,
2159
+ sourceChannelId: sourceSession.channelId,
2160
+ sourceChatType: sourceSession.chatType,
2161
+ sourceThreadId: sourceSession.threadId,
2162
+ sourceSelfAid: sourceSession.selfAID,
2163
+ sourcePeerId: sourceSession.metadata?.peerId,
1703
2164
  timestamp: Date.now(),
1704
2165
  causation: context?.causation,
1705
2166
  });
@@ -1770,7 +2231,7 @@ export class SessionManager {
1770
2231
  return adapter.getFileInfo(projectPath, agentSessionId, agentAid);
1771
2232
  }
1772
2233
  getRunnerTaskHistory(sessionId) {
1773
- return readRunnerTaskHistory(resolvePaths().logs, sessionId);
2234
+ return readRunnerEventSummary(resolvePaths().logs, sessionId);
1774
2235
  }
1775
2236
  async listSdkSessions(projectPath, baseagent, agentAid) {
1776
2237
  const adapter = this.getFileAdapter(baseagent);