evolcore 0.0.34 → 0.0.35

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 (69) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/bin/install-codex-managed-hooks.mjs +26 -3
  3. package/dist/agents/claude-runner.js +11 -1
  4. package/dist/agents/codex/diagnostics.js +42 -11
  5. package/dist/agents/codex-runner.js +27 -3
  6. package/dist/agents/ecagent-runner.js +17 -7
  7. package/dist/aun/aid/agentmd.js +13 -2
  8. package/dist/aun/aid/client.js +1 -1
  9. package/dist/aun/msg/managed-operation.js +16 -0
  10. package/dist/aun/msg/p2p.js +18 -2
  11. package/dist/channels/aun.js +113 -7
  12. package/dist/cli/aun-commands.js +37 -13
  13. package/dist/cli/daemon-commands.js +10 -4
  14. package/dist/cli/fs-command.js +1 -1
  15. package/dist/cli/getmeta-command.js +211 -0
  16. package/dist/cli/index.js +6 -0
  17. package/dist/cli/managed-command-guard.js +3 -0
  18. package/dist/cli/memory-command.js +46 -15
  19. package/dist/cli/thread-command.js +12 -4
  20. package/dist/config/resolved-config-op.js +25 -8
  21. package/dist/core/auth/operation-authorizer.js +21 -2
  22. package/dist/core/channel-runtime.js +1 -1
  23. package/dist/core/command/command-handler.js +7 -1
  24. package/dist/core/command/menu-handler.js +2 -0
  25. package/dist/core/command/topic-slash.js +4 -7
  26. package/dist/core/event-catalog.js +14 -0
  27. package/dist/core/ipc-registration.js +11 -1
  28. package/dist/core/memory/batch-meta.js +1 -0
  29. package/dist/core/memory/consolidation-context.js +97 -14
  30. package/dist/core/memory/consolidation-trigger.js +15 -7
  31. package/dist/core/memory/consolidation.js +55 -0
  32. package/dist/core/memory/memory-writer.js +20 -4
  33. package/dist/core/message/aun-ipc-sender.js +19 -1
  34. package/dist/core/message/message-bridge.js +3 -3
  35. package/dist/core/message/message-log.js +1 -0
  36. package/dist/core/message/peer-mode.js +17 -0
  37. package/dist/core/message/response-engine.js +15 -4
  38. package/dist/core/permission/ec-command-parser.js +76 -40
  39. package/dist/core/permission/tool-policy.js +32 -2
  40. package/dist/core/relation/peer-identity.js +87 -12
  41. package/dist/core/session/session-manager.js +56 -1
  42. package/dist/core/session/session-title.js +26 -1
  43. package/dist/core/system-session/memory.js +87 -20
  44. package/dist/core/thread/command-parser.js +6 -1
  45. package/dist/core/thread/ipc-operation.js +33 -16
  46. package/dist/core/thread/thread-operation.js +62 -24
  47. package/dist/core/thread/thread-service.js +246 -32
  48. package/dist/utils/cross-platform.js +6 -3
  49. package/kits/docs/evolcore/fs-architecture.md +5 -5
  50. package/kits/docs/evolcore/fs.md +1 -1
  51. package/kits/docs/evolcore/group-collaboration.md +1 -1
  52. package/kits/docs/evolcore/group.md +5 -3
  53. package/kits/docs/evolcore/memory-consolidation.md +1 -2
  54. package/kits/docs/evolcore/memory-tags.md +1 -1
  55. package/kits/docs/evolcore/memory.md +2 -2
  56. package/kits/docs/evolcore/stats.md +1 -1
  57. package/kits/docs/evolcore/thread-chain.md +25 -9
  58. package/kits/eck_manifest.auxiliary.json +9 -0
  59. package/kits/eck_manifest.json +20 -0
  60. package/kits/eck_manifest.memory.json +20 -0
  61. package/kits/rules/01-overview.md +3 -3
  62. package/kits/rules/07-memory-and-thread.md +1 -1
  63. package/kits/skills/ec-group/SKILL.md +6 -3
  64. package/kits/skills/thread-ops/SKILL.md +18 -18
  65. package/kits/templates/memory-consolidation/notes.md +1 -1
  66. package/kits/templates/memory-consolidation/workflow.md +36 -1
  67. package/kits/templates/system-fragments/bash-description.md +9 -0
  68. package/kits/templates/system-fragments/memory.md +2 -2
  69. package/package.json +1 -1
@@ -16,19 +16,21 @@
16
16
  * 详见 docs/memory/system-session-plugin-design.md §5-§8
17
17
  */
18
18
  import path from 'path';
19
+ import fs from 'fs';
19
20
  import { resolveRoot } from '../../paths.js';
20
21
  import { parseCodexToolCommand } from '../permission/ec-command-parser.js';
21
22
  import { canonicalToolCall } from '../permission/tool-canonical.js';
22
23
  import { checkReadonly } from '../permission/tool-policy.js';
24
+ import { analyzeReadonlyShellQuery } from '../permission/readonly-shell-query.js';
23
25
  import { CONSOLIDATION_MODES } from '../memory/consolidation-context.js';
24
26
  import { feedbackFilePath } from '../memory/consolidation-ops-records.js';
25
27
  import { checkWriteTargets } from './grants.js';
26
28
  export const MEMORY_SESSION_DENY_MESSAGE = '整理会话不允许执行此命令';
27
29
  const MEMORY_MANIFEST = 'eck_manifest.memory.json';
28
30
  const ALLOWED_MEMORY_SUBCOMMANDS = new Set(['create', 'update', 'archive']);
29
- const BASH_COMMAND_RULE = 'Bash 只允许单条 `ec memory create|update|archive`,或落在本次整理目录内的只读查询'
30
- + '(不能拼接其他命令;正文含特殊字符时用单引号包住)。读文件请用 Read / Glob / Grep;'
31
- + '整理反馈用文件写入工具写到任务书指定的反馈文件。';
31
+ const BASH_COMMAND_RULE = 'Bash 只允许两类命令:单条 `ec memory create|update|archive`(不能与其他命令拼接);'
32
+ + '或路径落在可读根内、可证明只读的查询(如 ls / cat / grep),多条查询可用 `&&` 或 `;` 连接,最多 8 段。'
33
+ + '读文件请用 Read / Glob / Grep;整理反馈用文件写入工具写到任务书指定的反馈文件。';
32
34
  function layerDir(layer, ctx) {
33
35
  const root = resolveRoot();
34
36
  if (layer === 'agent')
@@ -68,7 +70,22 @@ export function memoryReadRoots(ctx) {
68
70
  add(...consolidated('relation'), messages);
69
71
  break;
70
72
  case 'forget':
71
- if (ctx.memoryScope) {
73
+ if (ctx.memoryScope === 'agent') {
74
+ // agent 级 forget:给所有 relation 的 consolidated 目录 + agent 自身的 consolidated
75
+ const relationsRoot = path.join(resolveRoot(), 'agents', ctx.selfAid, 'relations');
76
+ if (fs.existsSync(relationsRoot)) {
77
+ for (const peerKey of fs.readdirSync(relationsRoot)) {
78
+ const consolidatedDir = path.join(relationsRoot, peerKey, 'memory', 'consolidated');
79
+ const indexPath = path.join(relationsRoot, peerKey, 'memory', 'MEMORY.md');
80
+ if (fs.existsSync(consolidatedDir)) {
81
+ add(consolidatedDir, indexPath);
82
+ }
83
+ }
84
+ }
85
+ add(...consolidated('agent'));
86
+ }
87
+ else if (ctx.memoryScope) {
88
+ // relation / owner 级 forget:只给当前层级
72
89
  add(...consolidated(ctx.memoryScope));
73
90
  if (ctx.memoryScope === 'relation')
74
91
  add(messages);
@@ -100,19 +117,18 @@ function shellIssueRemedy(issue) {
100
117
  case 'unsafe-expansion':
101
118
  return '自由文本参数(--desc、正文、--keywords)里的反引号或 `$` 出现在双引号内,'
102
119
  + '会被 shell 当作命令替换或变量展开,所以无法确认它实际会执行什么。'
103
- + '请把这些参数改用单引号包住(如 --desc \'……\')。'
104
- + '注意单引号内部不能再出现单引号本身,正文可以跨多行。';
120
+ + '请把这些参数改用单引号包住(如 --desc \'……\');'
121
+ + '若正文本身含单引号,改用 $\'…\' 语法传入(支持 \\n \\t \\\\ \\\')。';
105
122
  case 'invalid-quote':
106
- return '单引号包裹的正文里出现了单引号本身,导致引号提前闭合、命令无法解析。'
107
- + '正文里不要写半角撇号(ASCII 0x27):改用 `ʼ`(U+02BC)或 `'`(U+FF07)代替,'
108
- + '或换一种说法避开这个字符。不要尝试转义——'
109
- + '单引号内无法转义单引号,转义与拼接一律被拒。';
123
+ return '命令引号未闭合或存在错误嵌套。'
124
+ + '普通单引号内部不能再出现单引号——含单引号的正文改用 $\'…\' 语法:'
125
+ + '用 \\\' 转义内部的单引号,如 $\'it\\\'s fine\'。';
110
126
  case 'structural-newline':
111
- return '命令里存在未闭合的换行结构。把正文放进单引号内即可直接换行。';
127
+ return '命令里存在未闭合的换行结构。把正文放进单引号或 $\'…\' 内即可直接换行($\'…\' 支持 \\n 转义)。';
112
128
  case 'shell-composition':
113
129
  return '命令里含有 shell 拼接、管道或重定向(`;` `&&` `|` `$()` 等)。整理会话只允许单条 ec memory 命令。';
114
130
  case 'unsupported-escape':
115
- return '命令里使用了不受支持的转义写法。请改用单引号包住正文,避免转义。';
131
+ return '命令里使用了不受支持的转义写法。请改用单引号包住正文;若正文含单引号,改用 $\'…\' 语法传入。';
116
132
  case 'lossy-cmd-shim':
117
133
  return '命令经过 Windows cmd 包装后无法无歧义还原。请把正文改成不含换行的单条命令。';
118
134
  case 'untrusted-shell':
@@ -167,23 +183,74 @@ function checkShell(input, ctx) {
167
183
  const readonly = checkReadWithinMatrix('Bash', input, ctx);
168
184
  if (readonly.behavior === 'allow')
169
185
  return { allow: true };
170
- if (!parsed.ok) {
171
- const rawCommand = typeof input.command === 'string' ? input.command : '';
172
- const remedy = shellIssueRemedy(parsed.issue);
173
- if (looksLikeMemoryCommand(rawCommand) && remedy) {
186
+ const rawCommand = typeof input.command === 'string' ? input.command : '';
187
+ // ec 命令按 ec 解析器归因;ec 解析器的 issue 只描述 ec 命令,不能拿来解释普通 shell 查询
188
+ if (looksLikeMemoryCommand(rawCommand)) {
189
+ const remedy = parsed.ok ? undefined : shellIssueRemedy(parsed.issue);
190
+ if (remedy) {
174
191
  return deny('memory_session_command_forbidden', `这条命令本身就是 \`ec memory\`,但未能解析成单条字面命令。${remedy}`);
175
192
  }
193
+ return deny('memory_session_command_forbidden', `${BASH_COMMAND_RULE} 具体原因:ec memory 只允许 create / update / archive 三个子命令。`);
194
+ }
195
+ if ((parsed.ok && parsed.argv[0] === 'ec') || looksLikeEcCommand(rawCommand)) {
196
+ return deny('memory_session_command_forbidden', `${BASH_COMMAND_RULE} 具体原因:整理会话不能执行 ec memory create / update / archive 以外的 ec 命令。`);
197
+ }
198
+ // 普通 shell 查询:原因取自只读判定本身
199
+ return deny('memory_session_command_forbidden', `${BASH_COMMAND_RULE} 具体原因:${readonlyDenialHint(rawCommand, readonly.policyCode, ctx)}`);
200
+ }
201
+ function looksLikeEcCommand(command) {
202
+ return /^\s*ec(?:\.cmd|\.exe|\.ps1)?\s/.test(command);
203
+ }
204
+ /** 只读查询被拒的原因:与 checkReadonly 的判定分支一一对应。 */
205
+ function readonlyDenialHint(command, policyCode, ctx) {
206
+ switch (policyCode) {
207
+ case 'readonly_workspace_escape':
208
+ return '命令可证明只读,但读取的路径不在可读根内。用任务书「可读根」列出的绝对路径;相对路径按会话 cwd 解析,cwd 本身不可读。';
209
+ case 'readonly_h_class_read':
210
+ case 'readonly_l_class_read':
211
+ return '命令读取了受保护路径,整理会话不能读。';
212
+ default:
213
+ break;
214
+ }
215
+ const analysis = analyzeReadonlyShellQuery(command, { managedTempDir: ctx.managedTempDir });
216
+ if (analysis.kind !== 'unproven')
217
+ return '命令无法证明只读。';
218
+ switch (analysis.reason) {
219
+ case 'unsupported-shell-structure':
220
+ return '命令含有不支持的 shell 结构:后台执行 `&`、`||`、输入重定向 `<`、换行,'
221
+ + '或除 `2>/dev/null` 以外的重定向(如 `2>&1`、`> file`),或连接段数、管道级数超限。'
222
+ + '多条查询只能用 `&&` 或 `;` 连接,最多 8 段。';
223
+ case 'non-literal-command':
224
+ return '命令参数不是字面量:未加引号的通配符(`*` `?` `[`)、变量 `$`、命令替换或无法解析的引号,'
225
+ + '静态确定不了会读哪些文件。列文件请用 Glob 并给绝对 path。';
226
+ case 'unsupported-command-or-option':
227
+ return '命令或选项不在只读查询白名单内。不需要 `cd`:把绝对路径直接写进命令里。'
228
+ + '读文件用 Read,列文件用 Glob,搜索用 Grep。';
229
+ case 'unsupported-pipeline-filter':
230
+ return '管道 `|` 后面的命令不是纯 stdin 过滤命令。';
231
+ case 'pipeline-filter-must-use-stdin':
232
+ return '管道 `|` 后面的过滤命令只能处理上一级的输出,不能再带文件路径参数。';
233
+ default:
234
+ return `命令无法证明只读(${analysis.reason})。`;
176
235
  }
177
- return deny('memory_session_command_forbidden', BASH_COMMAND_RULE);
178
236
  }
179
237
  function checkTool(rawToolName, input, ctx) {
180
238
  const call = canonicalToolCall(rawToolName, input);
181
239
  switch (call.access) {
182
240
  case 'read': {
183
241
  const result = checkReadWithinMatrix(call.name, input, ctx);
184
- return result.behavior === 'allow'
185
- ? { allow: true }
186
- : deny(result.policyCode ?? 'memory_session_read_outside_matrix', '只能读取本次整理的批次目录、对应记忆目录与本对话流水');
242
+ if (result.behavior !== 'allow') {
243
+ // 给出更具体的提示
244
+ let hint = '';
245
+ if (call.name === 'Glob' && !input.path) {
246
+ hint = '。Glob 需要传绝对路径给 path 参数(不传时起点落在 cwd,整理会话的 cwd 不可读)';
247
+ }
248
+ else if (call.name === 'Grep' && !input.path) {
249
+ hint = '。Grep 需要传绝对路径给 path 参数';
250
+ }
251
+ return deny(result.policyCode ?? 'memory_session_read_outside_matrix', `只能读取本次整理的批次目录、对应记忆目录与本对话流水${hint}`);
252
+ }
253
+ return { allow: true };
187
254
  }
188
255
  case 'write':
189
256
  // 引号内恰好出现 apply_patch 字样的 ec memory 命令可能被保守识别成 shell 写入,先按 ec 命令认定
@@ -81,7 +81,12 @@ export function resolveThreadCommandArgv(argv) {
81
81
  if (pieces.length > 2 || !pieces[0] || pieces[1] === '')
82
82
  throw new Error('Invalid thread reference');
83
83
  request.peer = normalizeThreadPeer(pieces[0]);
84
- request.threadId = pieces[1];
84
+ // 主会话的 threadId 是空串;`<peer>/main` 是同义写法(list 也这样显示)。
85
+ // `rename` 额外把裸 peer 也当成主会话:不写斜杠时改的是主会话名,
86
+ // 与 `list` / `history` 不传 threadId 读主会话的口径一致。
87
+ request.threadId = pieces[1] === 'main' || (command === 'rename' && pieces.length === 1)
88
+ ? ''
89
+ : pieces[1];
85
90
  if (!request.peer || request.threadId && !/^[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(request.threadId))
86
91
  throw new Error('Invalid thread reference');
87
92
  }
@@ -1,6 +1,5 @@
1
1
  import { trustedExecutionIdentityInput } from '../auth/agent-delegation.js';
2
2
  import { authorizeOperation, buildAuthSubject } from '../auth/auth-gateway.js';
3
- import { isManagementRole } from '../../config/builtin-roles.js';
4
3
  import { parsePeerKey } from '../relation/peer-identity.js';
5
4
  import { resolveThreadCommandArgv } from './command-parser.js';
6
5
  import { executeThreadOperation } from './thread-operation.js';
@@ -13,6 +12,16 @@ export function createThreadOperationExecutor(dependencies) {
13
12
  return { ok: false, code: 'INVALID_REQUEST', error: 'Thread operations require argv' };
14
13
  const request = resolveThreadCommandArgv(incoming.argv);
15
14
  const token = typeof incoming.delegationToken === 'string' ? incoming.delegationToken.trim() : '';
15
+ // `request` 是从 argv 重建的,不含调用方凭据。后续系统通知(topic-renamed
16
+ // 等)要经 `sendAunMsg` 再授权一次,必须带上来源会话与 token;否则会被
17
+ // 判为 missing_token。宿主终端调用没有 token,这里如实留空。
18
+ if (token) {
19
+ request.delegationToken = token;
20
+ if (incoming.delegationCommandHash)
21
+ request.delegationCommandHash = incoming.delegationCommandHash;
22
+ if (incoming.sessionId)
23
+ request.sessionId = incoming.sessionId;
24
+ }
16
25
  let currentPeer;
17
26
  let relationOnly = false;
18
27
  if (token) {
@@ -38,12 +47,24 @@ export function createThreadOperationExecutor(dependencies) {
38
47
  });
39
48
  relationOnly = subject.dataScope === 'relation';
40
49
  const readOnly = ['list', 'list-peers', 'show', 'history', 'check-index', 'summary-show', 'summary-log'].includes(request.op);
41
- const management = isManagementRole(subject.role) || subject.processRole === 'fullaccess-run';
42
- if (delegation.grant.chatType === 'group' && !management) {
43
- return { ok: false, code: 'NOT_ALLOWED', error: 'Group thread operations require an administrator role' };
50
+ // 话题数据是**本 agent 自己的**(`agents/<self>/data/threads/`),与当前
51
+ // 所在的关系(私聊对端 / 群)无关。因此:
52
+ // - 触发场景的 chatType 不能决定能否操作话题:在群里被要求联系第三方,
53
+ // 查索引、建话题、写摘要都是对自己数据的操作,不涉及当前群。
54
+ // - 建/改话题也不再要求管理员:member 建一个"我与 C"的话题与管理员无关。
55
+ // 保留的唯一硬约束:readonly 权限模式(visitor 兜底)不得写。
56
+ if (!readOnly && subject.permissionMode === 'readonly') {
57
+ return { ok: false, code: 'NOT_ALLOWED', error: 'Thread changes require a writable task' };
58
+ }
59
+ // `current` 必须先解析成具体 threadId,后续判断(日志、通知)都依赖它。
60
+ // 群主会话改名的限制不在这里做:普通成员允许改自己的本地称呼,
61
+ // 只是不发通知(见 thread-operation.ts 的 topic-renamed 分支)。
62
+ if (request.ref === 'current') {
63
+ const indexed = threadService.resolveCurrentThread(request.selfAid, session.id);
64
+ request.peer = indexed?.peer ?? currentPeer;
65
+ request.threadId = indexed?.threadId ?? session.threadId ?? undefined;
66
+ request.sessionId = session.id;
44
67
  }
45
- if (!readOnly && (!management || subject.permissionMode === 'readonly'))
46
- return { ok: false, code: 'NOT_ALLOWED', error: 'Thread changes require a writable administrator task' };
47
68
  const globalOperation = ['list-peers', 'check-index', 'rebuild-index'].includes(request.op);
48
69
  const decision = authorizeOperation({
49
70
  source: 'agent-tool', subject,
@@ -54,12 +75,6 @@ export function createThreadOperationExecutor(dependencies) {
54
75
  return { ok: false, code: decision.code, error: decision.reason };
55
76
  if (relationOnly && globalOperation && request.op !== 'list-peers')
56
77
  return { ok: false, code: 'SCOPE_MISMATCH', error: 'Index management requires Agent scope' };
57
- if (request.ref === 'current') {
58
- const indexed = threadService.resolveCurrentThread(request.selfAid, session.id);
59
- request.peer = indexed?.peer ?? currentPeer;
60
- request.threadId = indexed?.threadId ?? session.threadId ?? undefined;
61
- request.sessionId = session.id;
62
- }
63
78
  }
64
79
  else {
65
80
  if (incoming.sessionId || incoming.delegationToken || incoming.localControl !== true)
@@ -69,8 +84,10 @@ export function createThreadOperationExecutor(dependencies) {
69
84
  if (request.ref === 'current')
70
85
  return { ok: false, code: 'INVALID_REQUEST', error: 'current requires a managed caller session' };
71
86
  }
72
- if (relationOnly && request.peer && request.peer !== currentPeer)
73
- return { ok: false, code: 'SCOPE_MISMATCH', error: 'Only the current relation can be targeted' };
87
+ // 话题操作的目标对端与"当前在哪个关系里被触发"是两个不同维度:
88
+ // 索引、话题会话、摘要全部落在本 agent 自己的数据目录下。在群 G 里
89
+ // 被要求联系 C 时,查/建/写的是我与 C 的话题,不属于 G,也不该受 G 约束。
90
+ // "只能操作自己数据"由 authorizeOperation 的 targetCurrentAgentOnly 负责。
74
91
  if (request.op.startsWith('summary-') && !request.sessionId) {
75
92
  const target = request.threadId
76
93
  ? await threadService.showThread({ selfAid: request.selfAid, peer: request.peer, threadId: request.threadId })
@@ -84,8 +101,8 @@ export function createThreadOperationExecutor(dependencies) {
84
101
  const response = await executeThreadOperation(request, threadService, dependencies.sendSystemMsg);
85
102
  if (response.ok && request.op === 'delete' && request.threadId)
86
103
  dependencies.onDeleted?.(request.selfAid, request.threadId);
87
- if (response.ok && relationOnly && request.op === 'list-peers')
88
- response.result = { peers: response.result.peers.filter(entry => entry.peer === currentPeer) };
104
+ // list-peers 不再裁剪到当前对端:它就是本 agent 自己的对端目录索引,
105
+ // 跨话题协作的前提恰恰是先能看到"我和谁是过话题"。
89
106
  return response;
90
107
  }
91
108
  catch (error) {
@@ -1,3 +1,4 @@
1
+ import { logger } from '../../utils/logger.js';
1
2
  import { normalizeThreadPeer } from './thread-service.js';
2
3
  export function executeThreadOperation(request, threadService, sendSystemMsg) {
3
4
  return _executeThreadOperation(request, threadService, sendSystemMsg);
@@ -118,6 +119,10 @@ async function _executeThreadOperation(request, threadService, sendSystemMsg) {
118
119
  threadId: result.threadId,
119
120
  topicName: result.name,
120
121
  peerKind: request.peerKind ?? 'private',
122
+ // 与 rename 相同:授权链需要托管上下文,宿主调用则留空(跳过通知)。
123
+ originSessionId: request.sessionId,
124
+ delegationToken: request.delegationToken,
125
+ delegationCommandHash: request.delegationCommandHash,
121
126
  });
122
127
  }
123
128
  catch {
@@ -136,9 +141,41 @@ async function _executeThreadOperation(request, threadService, sendSystemMsg) {
136
141
  threadId = resolved.threadId;
137
142
  }
138
143
  }
139
- if (!peer || !threadId || !request.name)
144
+ // threadId === '' 是主会话的合法写法(`<peer>/main`),不能被当成缺参。
145
+ if (!peer || threadId === undefined || !request.name) {
140
146
  return { ok: false, code: 'MISSING_ARGS', error: 'peer, threadId and name required' };
147
+ }
141
148
  const result = await threadService.renameThread({ selfAid, peer, threadId, name: request.name });
149
+ // 改名通知(尽力而为,失败不影响本地改名):
150
+ // - 私聊主会话/子话题:通知对端 agent 或人类,便于其按名字选择话题。
151
+ // - 群子话题:发到群,所有成员同步。
152
+ // - 群主会话:不发(只有 admin/owner 能改,且它是本地对群本身的称呼)。
153
+ if (sendSystemMsg && result.changed && result.nameUpdatedAt) {
154
+ const entry = threadId ? threadService.findInIndex(selfAid, peer, threadId) : undefined;
155
+ const peerKind = entry?.peerKind ?? (request.peerKind ?? 'private');
156
+ const isGroupMain = peerKind === 'group' && !threadId;
157
+ if (!isGroupMain) {
158
+ try {
159
+ await sendSystemMsg({
160
+ from: selfAid,
161
+ to: peer,
162
+ threadId: threadId ?? '',
163
+ topicName: result.name,
164
+ peerKind,
165
+ kind: 'topic-renamed',
166
+ nameUpdatedAt: result.nameUpdatedAt,
167
+ originSessionId: request.sessionId,
168
+ delegationToken: request.delegationToken,
169
+ delegationCommandHash: request.delegationCommandHash,
170
+ });
171
+ }
172
+ catch (error) {
173
+ // 通知失败不影响本地改名,但必须留痕:静默失败会让"改名同步"
174
+ // 看起来像没实现(实测已踩过)。
175
+ logger.warn(`[Thread] topic-renamed notification failed: peer=${peer} thread=${threadId ?? 'main'} name=${JSON.stringify(result.name)} error=${error instanceof Error ? error.message : String(error)}`);
176
+ }
177
+ }
178
+ }
142
179
  return { ok: true, result };
143
180
  }
144
181
  case 'label': {
@@ -206,35 +243,36 @@ async function _executeThreadOperation(request, threadService, sendSystemMsg) {
206
243
  if (!request.sessionId || !request.content)
207
244
  return { ok: false, code: 'MISSING_ARGS', error: 'sessionId and content required' };
208
245
  // 覆盖游标由 service 自动记录(写入时刻话题最后一条消息的 ts),调用方不用传。
209
- const result = threadService.setSummary(selfAid, request.sessionId, request.content, request.ifRev);
246
+ const result = await threadService.setSummary(selfAid, request.sessionId, request.content, request.ifRev);
210
247
  // 顺带改名:总结时往往正好把话题主题想清楚了,一次命令写完更省事。
211
248
  // 调用方(CLI / slash)保证 name 一定存在;`-` 表示"保持原标题不改"。
212
- // 主会话没有话题名可改(threadId 为空),忽略该参数而不是报错。
213
249
  const rawName = typeof request.name === 'string' ? request.name.trim() : '';
214
- let name = rawName && rawName !== '-' ? rawName : undefined;
215
- let threadId = request.threadId;
216
- if (name && !threadId) {
217
- const resolved = threadService.resolveCurrentThread(selfAid, request.sessionId);
218
- threadId = resolved?.threadId;
250
+ const name = rawName && rawName !== '-' ? rawName : undefined;
251
+ if (!name)
252
+ return { ok: true, result };
253
+ // 解析改名目标:主会话(threadId 为空串)不进索引,靠 peer + sessionId 定位;
254
+ // 子话题优先用索引里的 peer/threadId,索引查不到时退回请求里带的 ref。
255
+ const indexed = threadService.readThreadIndex(selfAid).threads.find(t => t.sessionId === request.sessionId);
256
+ const peer = request.peer ?? indexed?.peer;
257
+ const threadId = request.threadId ?? indexed?.threadId;
258
+ if (!peer) {
259
+ // 摘要已写入,改名目标无从确定——如实返回原因,别静默丢弃。
260
+ return {
261
+ ok: true,
262
+ result: { ...result, renameError: '无法解析改名目标:缺少 peer' },
263
+ };
219
264
  }
220
- let peer = request.peer;
221
- if (name && threadId && !peer) {
222
- peer = threadService.readThreadIndex(selfAid).threads.find(t => t.threadId === threadId)?.peer;
265
+ try {
266
+ const renamed = await threadService.renameThread({ selfAid, peer, threadId: threadId ?? '', name });
267
+ return { ok: true, result: { ...result, renamedTo: renamed.name, renamedChanged: renamed.changed } };
223
268
  }
224
- if (name && threadId && peer) {
225
- try {
226
- const renamed = await threadService.renameThread({ selfAid, peer, threadId, name });
227
- return { ok: true, result: { ...result, renamedTo: renamed.name, renamedChanged: renamed.changed } };
228
- }
229
- catch (e) {
230
- // 摘要已写入,改名失败不回滚——如实返回,让调用方知道只有改名这步没成
231
- return {
232
- ok: true,
233
- result: { ...result, renameError: e?.message ?? String(e) },
234
- };
235
- }
269
+ catch (e) {
270
+ // 摘要已写入,改名失败不回滚——如实返回,让调用方知道只有改名这步没成
271
+ return {
272
+ ok: true,
273
+ result: { ...result, renameError: e?.message ?? String(e) },
274
+ };
236
275
  }
237
- return { ok: true, result };
238
276
  }
239
277
  case 'summary-log': {
240
278
  if (!request.sessionId)