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
@@ -2542,7 +2542,11 @@ export class CommandHandler {
2542
2542
  }
2543
2543
  }
2544
2544
  else {
2545
- if (!hasTrustedFullAccessDelegation(grant) && (grant.channelType !== 'aun' || grant.chatType !== 'private')) {
2545
+ // `--thread` 是话题作用域读取:目标对端由话题推断,日志属于本 agent 自己,
2546
+ // 所以既允许在群会话里发起,也允许读当前对端以外的话题历史。
2547
+ const threadScoped = parsed.command.kind === 'history' && !!parsed.command.threadId;
2548
+ if (!hasTrustedFullAccessDelegation(grant) && !threadScoped
2549
+ && (grant.channelType !== 'aun' || grant.chatType !== 'private')) {
2546
2550
  return { ok: false, code: 'SCOPE_MISMATCH', error: 'Managed msg reads require the current AUN private relation' };
2547
2551
  }
2548
2552
  // A management role may read history for any peer, one peer per query.
@@ -2551,6 +2555,7 @@ export class CommandHandler {
2551
2555
  // stay pinned here, and authorizeOperation below remains the authority
2552
2556
  // for every role (member carries currentRelationOnly; visitor is denied).
2553
2557
  const mayReadCrossPeer = hasTrustedFullAccessDelegation(grant)
2558
+ || threadScoped
2554
2559
  || (parsed.command.kind === 'history' && isAdminRole(grant.issuedRole ?? 'none'));
2555
2560
  if (!mayReadCrossPeer && parsed.command.target !== conversationId) {
2556
2561
  return { ok: false, code: 'SCOPE_MISMATCH', error: 'Only the current AUN private relation can be targeted' };
@@ -2601,6 +2606,7 @@ export class CommandHandler {
2601
2606
  ...(parsed.command.before !== undefined ? { before: parsed.command.before } : {}),
2602
2607
  ...(parsed.command.after !== undefined ? { after: parsed.command.after } : {}),
2603
2608
  ...(parsed.command.direction ? { direction: parsed.command.direction } : {}),
2609
+ ...(parsed.command.threadId ? { threadId: parsed.command.threadId } : {}),
2604
2610
  },
2605
2611
  },
2606
2612
  auditMetadata: {
@@ -1769,6 +1769,8 @@ export async function getSubMenuItems(cmd, channel, channelId, userId, args, ove
1769
1769
  value: definition.id,
1770
1770
  label: definition.name,
1771
1771
  desc: `${view.scheduleType}${view.nextFireAt ? ` | 下次 ${new Date(view.nextFireAt).toLocaleString()}` : ''}`,
1772
+ // 只有 trigger 有说明时才带上 description 字段,前端按需渲染
1773
+ ...(definition.description ? { description: definition.description } : {}),
1772
1774
  status: definition.enabled ? 'active' : 'disabled',
1773
1775
  };
1774
1776
  });
@@ -474,18 +474,15 @@ async function summaryCommand(ctx, service, positional, flags, write) {
474
474
  }
475
475
  // 覆盖游标由 service 自动记录,无需传参
476
476
  const ifRev = typeof flags['if-rev'] === 'string' ? parseInt(flags['if-rev'], 10) : undefined;
477
- const r = service.setSummary(ctx.selfAid, sessionId, content, Number.isFinite(ifRev) ? ifRev : undefined);
477
+ const r = await service.setSummary(ctx.selfAid, sessionId, content, Number.isFinite(ifRev) ? ifRev : undefined);
478
478
  const cursorNote = r.coveredThroughTs ? `,覆盖至 ${new Date(r.coveredThroughTs).toLocaleString('zh-CN')}` : '';
479
- // --name:顺带改名。主会话没有话题名,跳过改名校验而不是报错。
479
+ // --name:顺带改名。主会话(threadId 为空)同样可以改,走 renameThread 的主会话分支。
480
480
  const rawName = flags.name.trim();
481
481
  const newName = rawName && rawName !== '-' ? rawName : undefined;
482
- if (newName && !target.threadId) {
483
- return result(`✅ summary 已写入(rev ${r.rev}${cursorNote})\n(主会话没有话题名,--name 已忽略)`);
484
- }
485
- if (newName && target.threadId) {
482
+ if (newName) {
486
483
  try {
487
484
  const renamed = await service.renameThread({
488
- selfAid: ctx.selfAid, peer: target.peer, threadId: target.threadId, name: newName,
485
+ selfAid: ctx.selfAid, peer: target.peer, threadId: target.threadId ?? '', name: newName,
489
486
  });
490
487
  return result(`✅ summary 已写入(rev ${r.rev}${cursorNote})\n`
491
488
  + `✅ 话题已改名: ${renamed.name}`);
@@ -173,6 +173,20 @@ const CATALOG = [
173
173
  { path: 'newName', type: 'string' },
174
174
  ],
175
175
  },
176
+ {
177
+ type: 'thread:renamed',
178
+ namespace: 'thread',
179
+ name: 'renamed',
180
+ description: '对端协议消息改写本端话题名',
181
+ fields: [
182
+ { path: 'sessionId', type: 'string', optional: true },
183
+ { path: 'peer', type: 'string' },
184
+ { path: 'threadId', type: 'string' },
185
+ { path: 'name', type: 'string' },
186
+ { path: 'nameUpdatedAt', type: 'number' },
187
+ { path: 'by', type: 'string' },
188
+ ],
189
+ },
176
190
  {
177
191
  type: 'session:forked',
178
192
  namespace: 'session',
@@ -125,8 +125,18 @@ export function registerDaemonIpc({ ipcServer, agentRegistry, agentDelegationReg
125
125
  aid: params.from,
126
126
  to: params.to,
127
127
  scope: params.peerKind === 'group' ? 'group' : 'msg',
128
- payload: { type: 'topic-created', topicName: params.topicName, threadId: params.threadId },
128
+ payload: {
129
+ type: params.kind ?? 'topic-created',
130
+ topicName: params.topicName,
131
+ threadId: params.threadId,
132
+ ...(params.nameUpdatedAt ? { nameUpdatedAt: params.nameUpdatedAt } : {}),
133
+ },
129
134
  thread: params.threadId,
135
+ // 授权链需要托管上下文;宿主终端调用(localControl)没有 token,
136
+ // 此时 originSessionId 为空,sendAunMsg 会拒绝——按尽力而为处理。
137
+ ...(params.originSessionId ? { originSessionId: params.originSessionId } : {}),
138
+ ...(params.delegationToken ? { delegationToken: params.delegationToken } : {}),
139
+ ...(params.delegationCommandHash ? { delegationCommandHash: params.delegationCommandHash } : {}),
130
140
  });
131
141
  if (!sent?.ok)
132
142
  throw new Error(sent?.error || 'Topic notification failed');
@@ -95,6 +95,7 @@ export function updateBatchMeta(batchDir, updates) {
95
95
  finishedAt: updates.finishedAt,
96
96
  success: updates.success,
97
97
  ...(updates.agentSessionId ? { agentSessionId: updates.agentSessionId } : {}),
98
+ ...(updates.failureReason ? { failureReason: updates.failureReason } : {}),
98
99
  };
99
100
  // 更新 feedback 正文
100
101
  let newBody = body;
@@ -226,13 +226,50 @@ function fillCandidates(ctx, mode, forgetAfterDays) {
226
226
  else if (mode === 'forget') {
227
227
  const now = Date.now();
228
228
  const thresholds = { forgetAfterDays };
229
- ctx.candidates = collectCandidates(source, thresholds, now)
230
- .filter(info => isDueForReview(info, thresholds, now))
231
- .map(info => ({
232
- relPath: `consolidated/${info.name}`,
233
- description: readFmValue(info.content, 'description') ?? info.name.replace(/\.md$/, ''),
234
- tags: info.tags,
235
- }));
229
+ // forget + scope=agent:扫描所有 relation 的 consolidated 目录,以及 agent 自身的 consolidated
230
+ if (ctx.scope === 'agent') {
231
+ ctx.candidates = [];
232
+ // ctx.consolidatedDir 是 agents/<selfAid>/personal/memory/consolidated,
233
+ // 往上走三级才是 agents/<selfAid>
234
+ const agentDir = path.dirname(path.dirname(path.dirname(ctx.consolidatedDir)));
235
+ const relationsRoot = path.join(agentDir, 'relations');
236
+ if (fs.existsSync(relationsRoot)) {
237
+ for (const peerKey of fs.readdirSync(relationsRoot)) {
238
+ const consolidatedDir = path.join(relationsRoot, peerKey, 'memory', 'consolidated');
239
+ if (fs.existsSync(consolidatedDir)) {
240
+ const candidates = collectCandidates(consolidatedDir, thresholds, now)
241
+ .filter(info => isDueForReview(info, thresholds, now))
242
+ .map(info => ({
243
+ relPath: `${peerKey}/consolidated/${info.name}`,
244
+ description: readFmValue(info.content, 'description') ?? info.name.replace(/\.md$/, ''),
245
+ tags: info.tags,
246
+ }));
247
+ ctx.candidates.push(...candidates);
248
+ }
249
+ }
250
+ }
251
+ // agent 自身的 consolidated(原 ⑤b 拉起 forget/agent 的来源)
252
+ if (fs.existsSync(ctx.consolidatedDir)) {
253
+ const own = collectCandidates(ctx.consolidatedDir, thresholds, now)
254
+ .filter(info => isDueForReview(info, thresholds, now))
255
+ .map(info => ({
256
+ relPath: `consolidated/${info.name}`,
257
+ description: readFmValue(info.content, 'description') ?? info.name.replace(/\.md$/, ''),
258
+ tags: info.tags,
259
+ }));
260
+ ctx.candidates.push(...own);
261
+ }
262
+ }
263
+ else {
264
+ // relation / owner 级 forget:只扫描当前层级的 consolidated
265
+ ctx.candidates = collectCandidates(source, thresholds, now)
266
+ .filter(info => isDueForReview(info, thresholds, now))
267
+ .map(info => ({
268
+ relPath: `consolidated/${info.name}`,
269
+ description: readFmValue(info.content, 'description') ?? info.name.replace(/\.md$/, ''),
270
+ tags: info.tags,
271
+ }));
272
+ }
236
273
  }
237
274
  }
238
275
  // ── 渲染 ────────────────────────────────────────────────────────────────────
@@ -262,15 +299,45 @@ function renderScopeSection(ctx, mode, threadFiles) {
262
299
  lines.push(`待整理的 note 在 \`${ctx.processingDir}\`(整理开始前已从 pending/ 整体移入,是本次的快照)。`);
263
300
  }
264
301
  else if (ctx.candidates) {
265
- lines.push(`**候选记忆(${ctx.candidates.length} 条)** —— 只列路径与描述,按需读正文:`);
266
- lines.push('');
267
- if (ctx.candidates.length === 0) {
268
- lines.push('(无候选)');
302
+ // forget + scope=agent:按 relation 分组展示
303
+ if (mode === 'forget' && ctx.scope === 'agent') {
304
+ const byRelation = new Map();
305
+ for (const c of ctx.candidates) {
306
+ const match = c.relPath.match(/^([^/\\]+)[/\\]consolidated[/\\]/);
307
+ const relation = match ? match[1] : 'unknown';
308
+ if (!byRelation.has(relation))
309
+ byRelation.set(relation, []);
310
+ byRelation.get(relation).push(c);
311
+ }
312
+ lines.push(`**候选记忆(共 ${ctx.candidates.length} 条,分布在 ${byRelation.size} 个关系)** —— 只列路径与描述,按需读正文:`);
313
+ lines.push('');
314
+ if (ctx.candidates.length === 0) {
315
+ lines.push('(无候选)');
316
+ }
317
+ else {
318
+ for (const [relation, candidates] of byRelation) {
319
+ lines.push(`#### relation: ${relation} (${candidates.length} 条)`);
320
+ lines.push('');
321
+ for (const c of candidates) {
322
+ const tags = c.tags.length > 0 ? ` [${c.tags.join(', ')}]` : '';
323
+ lines.push(`- \`${c.relPath}\`${tags} — ${c.description}`);
324
+ }
325
+ lines.push('');
326
+ }
327
+ }
269
328
  }
270
329
  else {
271
- for (const c of ctx.candidates) {
272
- const tags = c.tags.length > 0 ? ` [${c.tags.join(', ')}]` : '';
273
- lines.push(`- \`${c.relPath}\`${tags} — ${c.description}`);
330
+ // 其他模式:直接列出候选
331
+ lines.push(`**候选记忆(${ctx.candidates.length} 条)** —— 只列路径与描述,按需读正文:`);
332
+ lines.push('');
333
+ if (ctx.candidates.length === 0) {
334
+ lines.push('(无候选)');
335
+ }
336
+ else {
337
+ for (const c of ctx.candidates) {
338
+ const tags = c.tags.length > 0 ? ` [${c.tags.join(', ')}]` : '';
339
+ lines.push(`- \`${c.relPath}\`${tags} — ${c.description}`);
340
+ }
274
341
  }
275
342
  }
276
343
  }
@@ -494,6 +561,22 @@ export function serializeConsolidationContext(input) {
494
561
  '',
495
562
  ...scopes.flatMap(ctx => [renderScopeSection(ctx, input.mode, threadFiles), '', '---', '']),
496
563
  ];
564
+ // 列出可读根(工具 path/file_path 参数必须落在这些路径内)
565
+ lines.push('', '**可读根(工具 path/file_path 参数必须落在这些路径内)**:');
566
+ lines.push('- 批次目录:`' + batchDir + '`');
567
+ for (const ctx of scopes) {
568
+ if (ctx.processingDir)
569
+ lines.push('- processingDir: `' + ctx.processingDir + '`');
570
+ if (ctx.consolidatedDir)
571
+ lines.push('- consolidatedDir: `' + ctx.consolidatedDir + '`');
572
+ if (ctx.indexPath)
573
+ lines.push('- indexPath: `' + ctx.indexPath + '`');
574
+ }
575
+ if (historyJsonlPath)
576
+ lines.push('- 消息流水: `' + historyJsonlPath + '`');
577
+ if (threadFiles && threadFiles.length > 0)
578
+ lines.push('- 话题快照: `' + path.join(batchDir, 'threads') + '`');
579
+ lines.push('', '---', '');
497
580
  if (input.mode === 'notes' && threadFiles && threadFiles.length > 0) {
498
581
  const threadsDir = path.join(batchDir, 'threads');
499
582
  lines.push('## 话题快照文件', '');
@@ -379,19 +379,27 @@ export function checkConsolidationCandidates(params) {
379
379
  out.push({ mode: 'profile', peerKey, reason: '索引有变化且画像超期' });
380
380
  }
381
381
  }
382
- // ⑤ 超期记忆 → forget(关系级)
382
+ // ⑤ 超期记忆 → forget(agent 级,扫描所有 relation + agent 自身,合并成一个会话)
383
+ let hasRelationForget = false;
383
384
  for (const peerKey of peerKeys) {
384
385
  const dir = path.join(relationMemoryDir(root, selfAid, peerKey), 'consolidated');
385
386
  const due = checkStale(dir, now, config.forgetAfterDays);
386
- if (due > 0) {
387
- out.push({ mode: 'forget', peerKey, scope: 'relation', reason: `${due} 条记忆超过审查周期(未续期)` });
388
- }
387
+ if (due > 0)
388
+ hasRelationForget = true;
389
389
  }
390
- // ⑤b 超期记忆 → forget(agent 级 / owner 级)
390
+ // ⑤b agent 自身的 consolidated 超期
391
391
  const agentConsolidated = path.join(root, 'agents', selfAid, 'personal', 'memory', 'consolidated');
392
- if (config.enableAgent && checkStale(agentConsolidated, now, config.forgetAfterDays) > 0) {
393
- out.push({ mode: 'forget', scope: 'agent', reason: 'agent 级记忆超期' });
392
+ const hasAgentForget = config.enableAgent && checkStale(agentConsolidated, now, config.forgetAfterDays) > 0;
393
+ // 只要有任意一层超期,起一个 forget scope=agent 会话,由会话内统一扫描
394
+ if (hasRelationForget || hasAgentForget) {
395
+ const parts = [];
396
+ if (hasRelationForget)
397
+ parts.push('关系级记忆超期');
398
+ if (hasAgentForget)
399
+ parts.push('agent 级记忆超期');
400
+ out.push({ mode: 'forget', scope: 'agent', reason: parts.join('、') });
394
401
  }
402
+ // ⑤c owner 级 forget 保持独立(owner 层与 agent/relation 层是平行的)
395
403
  if (config.enableOwner && ownerAid) {
396
404
  const ownerConsolidated = path.join(root, 'memory', 'owner', ownerAid, 'consolidated');
397
405
  if (checkStale(ownerConsolidated, now, config.forgetAfterDays) > 0) {
@@ -13,6 +13,7 @@ import path from 'path';
13
13
  import { agentDir, resolveRoot } from '../../paths.js';
14
14
  import { serializeConsolidationContext } from './consolidation-context.js';
15
15
  import { assertSystemSessionLaunchable } from '../system-session/registry.js';
16
+ import { resolveAgentLifecycle } from '../../config/lifecycle.js';
16
17
  import { ensureSessionRuntimeDir } from '../../cli/task-context.js';
17
18
  import { formatChannelKey } from '../channel-loader.js';
18
19
  import { logger } from '../../utils/logger.js';
@@ -48,6 +49,21 @@ function cleanupPreviousConsolidationBatches(_selfAid) {
48
49
  logger.warn(`[Memory] 清理整理批次时出错: ${e?.message}`);
49
50
  }
50
51
  }
52
+ /**
53
+ * 首次设定(lifecycle)没完成的 agent 不能整理记忆:整理会话会被注入首次设定规则而拒绝干活,
54
+ * note 却照样被认领。返回拒绝原因;可以整理时返回 undefined。
55
+ * 直接读磁盘上的 config.json:首次设定完成时 lifecycle 就落在这里,不依赖内存里的注册表。
56
+ */
57
+ export function consolidationBlockedReason(selfAid) {
58
+ const configPath = path.join(resolveRoot(), 'agents', selfAid, 'config.json');
59
+ const config = fs.existsSync(configPath)
60
+ ? JSON.parse(fs.readFileSync(configPath, 'utf-8'))
61
+ : {};
62
+ const lifecycle = resolveAgentLifecycle(config);
63
+ if (lifecycle === 'active')
64
+ return undefined;
65
+ return `agent ${selfAid} 首次设定尚未完成(lifecycle=${lifecycle ?? String(config.lifecycle)}),完成后才能整理记忆`;
66
+ }
51
67
  /**
52
68
  * 序列化上下文并直接创建整理会话。
53
69
  * 失败抛错,调用方负责决定是否记录诊断或回传错误。
@@ -64,6 +80,9 @@ export async function launchConsolidation(channel, params) {
64
80
  // 放在认领 processing 之前,拒绝时不留副作用。
65
81
  const systemSession = { type: 'memory', subtype: mode };
66
82
  assertSystemSessionLaunchable(systemSession, effectiveCtx.baseagent ?? 'claude');
83
+ const blockedReason = consolidationBlockedReason(selfAid);
84
+ if (blockedReason)
85
+ throw new Error(blockedReason);
67
86
  // 清理旧的整理批次(保留最新的一个历史)
68
87
  cleanupPreviousConsolidationBatches(selfAid);
69
88
  // notes 模式:pending/ → processing/ 快照迁移(在序列化上下文之前执行,声明认领)
@@ -213,6 +232,20 @@ function moveIntoDir(srcPath, destDir, stamp) {
213
232
  }
214
233
  fs.renameSync(srcPath, dest);
215
234
  }
235
+ /** 本次整理认领到 processing/ 的 note 条数(收尾归位之前调用)。 */
236
+ function countClaimedNotes(memoryDir) {
237
+ if (!memoryDir)
238
+ return 0;
239
+ const processingDir = path.join(memoryDir, 'processing');
240
+ try {
241
+ return fs.readdirSync(processingDir)
242
+ .filter(entry => entry.endsWith('.md') && fs.statSync(path.join(processingDir, entry)).isFile())
243
+ .length;
244
+ }
245
+ catch {
246
+ return 0;
247
+ }
248
+ }
216
249
  function restorePendingNotes(memoryDir) {
217
250
  const processingDir = path.join(memoryDir, 'processing');
218
251
  if (!fs.existsSync(processingDir))
@@ -363,6 +396,17 @@ export function createConsolidationSessionFinalizer({ sessionManager, getAgent,
363
396
  finalizedSessions.add(sessionId);
364
397
  releaseAid = session.selfAID;
365
398
  const { summarizeOpsRecords, collectAffectedScopes } = await import('./consolidation-ops-records.js');
399
+ // 会话正常结束不等于整理做完了。notes 模式认领了 note,却既没写记忆也没写反馈,
400
+ // 说明 agent 没处理这批 note(例如拒绝了任务)——按失败收尾,note 回 pending 下次重试。
401
+ let failureReason;
402
+ if (success && meta.memoryMode === 'notes' && countClaimedNotes(meta.consolidationMemoryDir) > 0) {
403
+ const ops = summarizeOpsRecords(batchDir);
404
+ if (ops.created + ops.updated + ops.archived === 0 && !readFeedbackText(batchDir)) {
405
+ success = false;
406
+ failureReason = '整理会话结束时没有写入任何记忆,也没有写反馈,note 未被处理';
407
+ logger.warn(`[Memory] ${failureReason}(session=${sessionId}),按失败收尾`);
408
+ }
409
+ }
366
410
  if (!success) {
367
411
  await rollbackConsolidation(batchDir, session.selfAID);
368
412
  }
@@ -478,6 +522,7 @@ export function createConsolidationSessionFinalizer({ sessionManager, getAgent,
478
522
  finishedAt: new Date().toISOString(),
479
523
  success,
480
524
  ...(session.agentSessionId ? { agentSessionId: session.agentSessionId } : {}),
525
+ ...(failureReason ? { failureReason } : {}),
481
526
  feedbackText: customText,
482
527
  });
483
528
  }
@@ -497,6 +542,7 @@ export function createConsolidationSessionFinalizer({ sessionManager, getAgent,
497
542
  success,
498
543
  summary: summarizeOpsRecords(batchDir),
499
544
  batchDir,
545
+ failureReason,
500
546
  });
501
547
  await sendFeedback(feedbackOrigin, text, session.selfAID);
502
548
  }
@@ -738,6 +784,10 @@ export function registerConsolidationLaunchExecutor({ ipcServer, enqueueConsolid
738
784
  ipcServer.setConsolidationLaunchExecutor(async (params) => {
739
785
  if (!params.taskCtx.selfAid)
740
786
  throw new Error('无法确定当前 agent AID(selfAid 为空)');
787
+ // 入队前就拦:拉起失败在队列里只会记成一句笼统的「拉起整理会话失败」
788
+ const blockedReason = consolidationBlockedReason(params.taskCtx.selfAid);
789
+ if (blockedReason)
790
+ throw new Error(blockedReason);
741
791
  const launched = await enqueueConsolidation(params);
742
792
  if (!launched)
743
793
  throw new Error('拉起整理会话失败,详见 daemon 日志');
@@ -752,6 +802,8 @@ export function registerConsolidationCheckExecutor({ ipcServer, agentRegistry, e
752
802
  const agentConfig = agentRegistry.get(selfAid)?.config;
753
803
  if (agentConfig?.memory?.enableRelation === false)
754
804
  return { launched: [] };
805
+ if (consolidationBlockedReason(selfAid))
806
+ return { launched: [] };
755
807
  const ownerAid = agentConfig?.owners?.[0];
756
808
  const agentCount = agentRegistry.runnableAgents().length;
757
809
  const relationsDir = path.join(agentDir(selfAid), 'relations');
@@ -808,6 +860,8 @@ export function registerRelationConsolidationEvents({ eventBus, agentRegistry, r
808
860
  return;
809
861
  if (taskCtx.sessionType === 'memory')
810
862
  return;
863
+ if (consolidationBlockedReason(taskCtx.selfAid))
864
+ return;
811
865
  const memoryConfig = agentRegistry.get(taskCtx.selfAid)?.config?.memory;
812
866
  if (memoryConfig?.enableRelation === false)
813
867
  return;
@@ -872,6 +926,7 @@ export function renderDefaultConsolidationFeedback(input) {
872
926
  '',
873
927
  `**模式**:${mode}`,
874
928
  `**触发**:${cause}`,
929
+ ...(input.failureReason ? [`**原因**:${input.failureReason}`] : []),
875
930
  '',
876
931
  '本次整理未完成,已回退未归档的改动。详情见整理批次:',
877
932
  `\`${input.batchDir}\``,
@@ -50,8 +50,10 @@ export function resolveScopeDirs(scope, taskCtx, ownerAid, peerKeyOverride) {
50
50
  /**
51
51
  * 把用户给的路径解析成 consolidated/ 下的绝对路径。
52
52
  * 允许 slug / <slug>.md / 相对或绝对路径(须落在本作用域的 consolidated/ 内)。
53
+ *
54
+ * forget-agent 模式特殊处理:允许路径落在任意 relation 的 consolidated/ 下。
53
55
  */
54
- export function resolveConsolidatedPath(dirs, input) {
56
+ export function resolveConsolidatedPath(dirs, input, taskCtx) {
55
57
  const base = path.resolve(dirs.consolidatedDir);
56
58
  const looksLikePath = input.includes('/') || input.includes('\\');
57
59
  if (!looksLikePath) {
@@ -66,6 +68,20 @@ export function resolveConsolidatedPath(dirs, input) {
66
68
  if (!rel.startsWith('..') && !path.isAbsolute(rel))
67
69
  return abs;
68
70
  }
71
+ // forget-agent 模式:允许路径落在任意 relation 的 consolidated/ 下,或 agent 自身的 consolidated/
72
+ const isForgetAgent = taskCtx?.memoryMode === 'forget' && taskCtx?.memoryScope === 'agent';
73
+ if (isForgetAgent && taskCtx?.selfAid) {
74
+ const relationsRoot = path.join(resolveRoot(), 'agents', taskCtx.selfAid, 'relations');
75
+ const absInput = path.resolve(input);
76
+ const relToRelations = path.relative(relationsRoot, absInput);
77
+ // 检查是否在某个 relation 的 consolidated/ 下
78
+ if (!relToRelations.startsWith('..') && !path.isAbsolute(relToRelations)) {
79
+ const parts = relToRelations.split(path.sep);
80
+ if (parts.length >= 3 && parts[1] === 'memory' && parts[2] === 'consolidated') {
81
+ return absInput;
82
+ }
83
+ }
84
+ }
69
85
  throw new Error(`ROLE_ACCESS_DENIED: 只能操作本作用域 consolidated/ 下的记忆(${base}),当前路径越界: ${input}`);
70
86
  }
71
87
  function assertSlug(slug) {
@@ -104,7 +120,7 @@ export async function createMemory(req) {
104
120
  const dirs = resolveScopeDirs(req.scope, req.taskCtx, req.ownerAid, req.targetPeerKey);
105
121
  fs.mkdirSync(dirs.consolidatedDir, { recursive: true });
106
122
  const targetPath = path.join(dirs.consolidatedDir, `${req.slug}.md`);
107
- const replacePaths = (req.replaces ?? []).map(p => resolveConsolidatedPath(dirs, p));
123
+ const replacePaths = (req.replaces ?? []).map(p => resolveConsolidatedPath(dirs, p, req.taskCtx));
108
124
  // 撞名闸门:目标已存在且没被 --replaces 覆盖 → 拒绝
109
125
  if (fs.existsSync(targetPath) && !replacePaths.includes(targetPath)) {
110
126
  throw new Error(`SLUG_EXISTS: 目标已存在: ${targetPath}\n` +
@@ -264,7 +280,7 @@ export async function demoteMemory(req) {
264
280
  }
265
281
  export async function updateMemory(req) {
266
282
  const dirs = resolveScopeDirs(req.scope, req.taskCtx, req.ownerAid);
267
- const filePath = resolveConsolidatedPath(dirs, req.target);
283
+ const filePath = resolveConsolidatedPath(dirs, req.target, req.taskCtx);
268
284
  if (!fs.existsSync(filePath))
269
285
  throw new Error(`找不到记忆文件: ${filePath}`);
270
286
  const document = readMemory(readVerifiedMemory(filePath));
@@ -286,7 +302,7 @@ export async function archiveMemory(req) {
286
302
  const dirs = resolveScopeDirs(req.scope, req.taskCtx, req.ownerAid);
287
303
  const archived = [];
288
304
  for (const t of req.targets) {
289
- const p = resolveConsolidatedPath(dirs, t);
305
+ const p = resolveConsolidatedPath(dirs, t, req.taskCtx);
290
306
  if (!fs.existsSync(p))
291
307
  throw new Error(`找不到记忆文件: ${p}`);
292
308
  const dest = archivePath(dirs.memoryDir, p);
@@ -28,7 +28,7 @@ export function createAunHandoffSender(sessionManager, channelInstances) {
28
28
  const log = {
29
29
  content: classified.content,
30
30
  sessionId: targetSession.id,
31
- threadId: targetSession.threadId || null,
31
+ threadId: targetSession.threadId || '',
32
32
  msgType: classified.msgType,
33
33
  payloadType: classified.payloadType,
34
34
  payloadSummary: classified.payloadSummary,
@@ -267,6 +267,24 @@ export function registerAunIpcSender({ ipcServer, channelInstances, agentDelegat
267
267
  if (!payload) {
268
268
  return { ok: false, error: 'message payload is required', code: 'INVALID_PAYLOAD' };
269
269
  }
270
+ // 托管 argv 通道(runManagedSend)先前不带 log 元数据:群聊 sendDaemonGroupMsg
271
+ // 自身会写日志,私聊 sendDaemonMsg 只在 args.log 存在时才写,于是同一份
272
+ // `ec msg send` 在私聊下不落 messages.jsonl,话题历史里查不到已发出的消息。
273
+ // 这里在 payload 定型后按同一套规则补出日志元数据,让两条路径记账一致。
274
+ if (params.argv !== undefined && params.log === undefined) {
275
+ const classified = classifyAunPayloadForLog(payload);
276
+ params = {
277
+ ...params,
278
+ log: {
279
+ content: classified.content,
280
+ threadId: params.thread ?? '',
281
+ msgType: classified.msgType,
282
+ payloadType: classified.payloadType,
283
+ payloadSummary: classified.payloadSummary,
284
+ source: 'msg',
285
+ },
286
+ };
287
+ }
270
288
  try {
271
289
  if (Object.prototype.hasOwnProperty.call(payload, 'mentions')) {
272
290
  payload = { ...payload, mentions: normalizeAunMentionEntries(payload.mentions) };
@@ -1264,7 +1264,7 @@ export class MessageBridge {
1264
1264
  appendMessageLog(chatDir, buildInboundEntry({
1265
1265
  from: msg.peerId || 'unknown',
1266
1266
  to: msg.selfAID || 'self',
1267
- threadId: msg.threadId ?? null,
1267
+ threadId: msg.threadId ?? '',
1268
1268
  chatType: msg.chatType || 'private',
1269
1269
  groupId: msg.groupId ?? null,
1270
1270
  msgId: msg.messageId ?? null,
@@ -1549,7 +1549,7 @@ export class MessageBridge {
1549
1549
  from: msg.peerId || 'unknown',
1550
1550
  to: msg.selfAID || 'self',
1551
1551
  sessionId: session.id,
1552
- threadId: session.threadId || null,
1552
+ threadId: session.threadId || '',
1553
1553
  chatType,
1554
1554
  groupId: msg.groupId ?? null,
1555
1555
  msgId: msg.messageId ?? null,
@@ -1608,7 +1608,7 @@ export class MessageBridge {
1608
1608
  const member = item.value;
1609
1609
  appendMessageLog(inboundEntry.chatDir, buildInboundEntry({
1610
1610
  from: item.sender, to: selfAid || '', sessionId: session.id,
1611
- threadId: session.threadId || null, chatType: 'group', groupId: msg.groupId,
1611
+ threadId: session.threadId || '', chatType: 'group', groupId: msg.groupId,
1612
1612
  msgId: item.messageId, content: member.messageLogContent ?? item.content,
1613
1613
  permMode: item.role, timestamp: item.receivedAt,
1614
1614
  receivedAt: member.receivedAt, gatewaySeq: member.gatewaySeq,
@@ -21,6 +21,7 @@ const TRANSIENT_PROTOCOL_TYPES = new Set([
21
21
  'handoff_state',
22
22
  'handoff_result',
23
23
  'topic-created', // 话题创建系统消息,不进大模型上下文
24
+ 'topic-renamed', // 话题改名通知,由 channel 层直接消费,不进大模型上下文
24
25
  ]);
25
26
  const KNOWN_AUN_PAYLOAD_TYPES = new Set([
26
27
  'text',
@@ -31,6 +31,23 @@ export function resolveChatMode(params) {
31
31
  ? effectiveValue
32
32
  : chatmodeFactoryDefaults()[field];
33
33
  }
34
+ /**
35
+ * 出站发送路径(`ec msg send` / daemon 私聊发送)用的最小判定。
36
+ *
37
+ * 这两条路径只有对端身份、拿不到角色,因此不能复用 `resolveChatMode` 的
38
+ * 分层配置。口径与 `resolveChatMode` 的硬约束保持一致:
39
+ * - 非人类对端(isMachine)→ proactive
40
+ * - 人类但角色未知 → proactive(保守侧)
41
+ * - 人类且 owner/admin → interactive
42
+ *
43
+ * 角色未知时不能假设高信任:interactive 表示"输出即回复"的同步交互,
44
+ * 只应给已确认的管理者。
45
+ */
46
+ export function chatmodeForPeerIdentity(identity, role) {
47
+ if (identity.isMachine)
48
+ return 'proactive';
49
+ return resolveChatMode({ role: role ?? undefined, chatType: 'private', peerType: identity.type ?? 'human' });
50
+ }
34
51
  export function resolveChatModeForField(params) {
35
52
  return resolveChatMode({
36
53
  ...params,
@@ -933,8 +933,18 @@ function wasRetryInputAlreadySubmitted(error) {
933
933
  return !!(error && typeof error === 'object' && error[RETRY_INPUT_ALREADY_SUBMITTED_MARK] === true);
934
934
  }
935
935
  function formatRetryInputAlreadySubmittedMessage(error) {
936
- const reason = getErrorRawText(error) || 'Codex 暂时不可用';
937
- return `⚠️ Codex 已接收本次消息,但本轮未能确认完成。为避免在会话中重复写入同一消息,系统未自动重试。请检查会话状态后重新发送。\n原因:${reason}`;
936
+ const raw = getErrorRawText(error);
937
+ const record = error && typeof error === 'object' ? error : undefined;
938
+ const retryCount = typeof record?.codexRetryCount === 'number' && Number.isSafeInteger(record.codexRetryCount)
939
+ ? record.codexRetryCount : undefined;
940
+ const statusCode = raw?.match(/\b([45]\d{2})\b/)?.[1];
941
+ if (retryCount !== undefined && statusCode) {
942
+ return `⚠️ Codex 已重连 ${retryCount} 次模型服务后仍失败(${statusCode})。本次执行已停止。`;
943
+ }
944
+ if (retryCount !== undefined) {
945
+ return `⚠️ Codex 已重连 ${retryCount} 次模型服务后仍失败。本次执行已停止。`;
946
+ }
947
+ return '⚠️ Codex 已接收本次消息,但模型服务请求失败。本次执行已停止。';
938
948
  }
939
949
  function isNonErrorInterruptReason(reason) {
940
950
  return reason === 'new_message'
@@ -4660,7 +4670,8 @@ export class ResponseEngine {
4660
4670
  peerId: peerIdRaw || undefined,
4661
4671
  peerKey,
4662
4672
  peerName: peerName || undefined,
4663
- peerRole,
4673
+ // 系统会话与角色体系无关:不给 peerRole,消息头就不渲染 `· role:` 段
4674
+ peerRole: systemSessionMarker ? undefined : peerRole,
4664
4675
  // Display missing identity explicitly without changing the mode resolver's legacy fallback.
4665
4676
  peerType: peerType || 'unknown',
4666
4677
  sameDevice: message.sameDevice ?? false,
@@ -4794,7 +4805,7 @@ export class ResponseEngine {
4794
4805
  } : {}),
4795
4806
  peerId: message.peerId, peerName: peerName || undefined,
4796
4807
  peerType: message.peerType,
4797
- peerRole: message.batchRole || session.identity?.role || 'none',
4808
+ peerRole: systemSessionMarker ? undefined : (message.batchRole || session.identity?.role || 'none'),
4798
4809
  sameDevice: message.sameDevice, sameNetwork: message.sameNetwork, sameEgressIp: message.sameEgressIp,
4799
4810
  encrypted: message.encrypted,
4800
4811
  content: message.content, timestamp: message.timestamp,