evolcore 0.0.33 → 0.0.35-test.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (152) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/bin/ec.js +3 -2
  3. package/bin/install-codex-managed-hooks.mjs +38 -4
  4. package/dist/agents/baseagent-availability.js +20 -36
  5. package/dist/agents/claude-runner.js +93 -14
  6. package/dist/agents/codex/diagnostics.js +79 -22
  7. package/dist/agents/codex-runner.js +245 -47
  8. package/dist/agents/ecagent-runner.js +90 -27
  9. package/dist/agents/runner-types.js +5 -6
  10. package/dist/aun/aid/agentmd.js +13 -2
  11. package/dist/aun/aid/client.js +1 -1
  12. package/dist/aun/aid/identity.js +1 -1
  13. package/dist/aun/msg/managed-operation.js +16 -0
  14. package/dist/aun/msg/p2p.js +18 -2
  15. package/dist/channels/aun.js +123 -7
  16. package/dist/channels/daemon.js +6 -3
  17. package/dist/cli/aun-commands.js +39 -14
  18. package/dist/cli/ctl-command.js +3 -1
  19. package/dist/cli/daemon-commands.js +130 -23
  20. package/dist/cli/fs-command.js +2 -1
  21. package/dist/cli/getmeta-command.js +211 -0
  22. package/dist/cli/index.js +26 -6
  23. package/dist/cli/integrity-command.js +196 -0
  24. package/dist/cli/managed-command-guard.js +3 -0
  25. package/dist/cli/memory-command.js +200 -156
  26. package/dist/cli/thread-command.js +17 -7
  27. package/dist/cli/trigger-command.js +6 -1
  28. package/dist/config/config-field-policy.js +4 -0
  29. package/dist/config/config-manager.js +19 -0
  30. package/dist/config/contact-book-store.js +5 -0
  31. package/dist/config/resolved-config-op.js +25 -8
  32. package/dist/config/role-service.js +13 -0
  33. package/dist/config/runtime-env.js +16 -0
  34. package/dist/config-store.js +30 -0
  35. package/dist/core/audit/log-integrity.js +3 -1
  36. package/dist/core/auth/agent-delegation.js +108 -35
  37. package/dist/core/auth/delegation-diagnostics.js +414 -0
  38. package/dist/core/auth/operation-authorizer.js +22 -3
  39. package/dist/core/auth/operation-catalog.js +16 -0
  40. package/dist/core/channel-runtime.js +1 -1
  41. package/dist/core/command/agent-control.js +18 -26
  42. package/dist/core/command/command-handler.js +145 -5
  43. package/dist/core/command/daemon-diagnostics.js +1 -0
  44. package/dist/core/command/menu-catalog.js +3 -0
  45. package/dist/core/command/menu-handler.js +24 -7
  46. package/dist/core/command/menu-protocol.js +1 -0
  47. package/dist/core/command/slash-handler.js +2 -0
  48. package/dist/core/command/topic-slash.js +4 -7
  49. package/dist/core/daemon-bootstrap.js +4 -7
  50. package/dist/core/event-catalog.js +14 -0
  51. package/dist/core/file-integrity/read.js +61 -0
  52. package/dist/core/file-integrity/store.js +262 -0
  53. package/dist/core/ipc-registration.js +47 -2
  54. package/dist/core/memory/batch-meta.js +133 -0
  55. package/dist/core/memory/consolidation-context.js +132 -28
  56. package/dist/core/memory/consolidation-ops-records.js +14 -16
  57. package/dist/core/memory/consolidation-trigger.js +15 -7
  58. package/dist/core/memory/consolidation.js +146 -26
  59. package/dist/core/memory/index-manager.js +19 -28
  60. package/dist/core/memory/memory-admin.js +19 -14
  61. package/dist/core/memory/memory-document.js +53 -9
  62. package/dist/core/memory/memory-writer.js +50 -19
  63. package/dist/core/message/aun-ipc-sender.js +19 -1
  64. package/dist/core/message/im-renderer.js +20 -4
  65. package/dist/core/message/message-bridge.js +5 -4
  66. package/dist/core/message/message-log.js +1 -0
  67. package/dist/core/message/peer-mode.js +17 -0
  68. package/dist/core/message/response-engine.js +598 -50
  69. package/dist/core/permission/ec-command-parser.js +169 -44
  70. package/dist/core/permission/protected-paths.js +5 -1
  71. package/dist/core/permission/readonly-shell-query.js +43 -5
  72. package/dist/core/permission/session-path-grants.js +334 -0
  73. package/dist/core/permission/tool-canonical.js +226 -0
  74. package/dist/core/permission/tool-policy.js +60 -62
  75. package/dist/core/relation/peer-identity.js +87 -12
  76. package/dist/core/role/runtime-policy.js +21 -10
  77. package/dist/core/session/session-fs-store.js +123 -8
  78. package/dist/core/session/session-manager.js +131 -6
  79. package/dist/core/session/session-title.js +26 -1
  80. package/dist/core/skill/index-generator.js +17 -3
  81. package/dist/core/system-session/grants.js +40 -0
  82. package/dist/core/system-session/memory.js +289 -0
  83. package/dist/core/system-session/registry.js +74 -0
  84. package/dist/core/system-session/types.js +14 -0
  85. package/dist/core/thread/command-parser.js +6 -1
  86. package/dist/core/thread/ipc-operation.js +33 -16
  87. package/dist/core/thread/thread-operation.js +62 -24
  88. package/dist/core/thread/thread-service.js +258 -51
  89. package/dist/eck/kit-renderer.js +7 -2
  90. package/dist/eck/manifest-engine.js +24 -4
  91. package/dist/index.js +11 -2
  92. package/dist/ipc.js +51 -40
  93. package/dist/paths.js +70 -0
  94. package/dist/stats/billing.js +20 -8
  95. package/dist/trigger/feedback.js +6 -1
  96. package/dist/trigger/parser.js +8 -1
  97. package/dist/trigger/system-definitions.js +3 -3
  98. package/dist/utils/cross-platform.js +17 -5
  99. package/dist/utils/error-dict.json +18 -4
  100. package/dist/utils/error-utils.js +121 -0
  101. package/dist/utils/instance-registry.js +13 -8
  102. package/dist/utils/log-writer.js +12 -0
  103. package/dist/utils/process-introspect.js +11 -0
  104. package/kits/docs/context-assembly.md +0 -1
  105. package/kits/docs/evolcore/agent-create.md +5 -4
  106. package/kits/docs/evolcore/agent.md +2 -2
  107. package/kits/docs/evolcore/config.md +2 -2
  108. package/kits/docs/evolcore/ctl.md +2 -2
  109. package/kits/docs/evolcore/ec-capabilities.md +1 -1
  110. package/kits/docs/evolcore/fs-architecture.md +9 -10
  111. package/kits/docs/evolcore/fs.md +2 -2
  112. package/kits/docs/evolcore/group-collaboration.md +3 -3
  113. package/kits/docs/evolcore/group.md +14 -12
  114. package/kits/docs/evolcore/memory-architecture.md +4 -5
  115. package/kits/docs/evolcore/memory-consolidation.md +19 -16
  116. package/kits/docs/evolcore/memory-tags.md +3 -1
  117. package/kits/docs/evolcore/memory.md +24 -12
  118. package/kits/docs/evolcore/model.md +16 -0
  119. package/kits/docs/evolcore/msg.md +7 -7
  120. package/kits/docs/evolcore/stats.md +1 -1
  121. package/kits/docs/evolcore/thread-chain.md +30 -14
  122. package/kits/docs/prompt-loading-architecture.md +0 -1
  123. package/kits/eck_manifest.auxiliary.json +9 -0
  124. package/kits/eck_manifest.json +20 -20
  125. package/kits/eck_manifest.memory.json +20 -12
  126. package/kits/rules/01-overview.md +11 -0
  127. package/kits/rules/03-identity.md +6 -3
  128. package/kits/rules/06-channel.md +1 -1
  129. package/kits/rules/07-memory-and-thread.md +13 -16
  130. package/kits/schemas/_meta.json +7 -1
  131. package/kits/schemas/agent-config.schema.13.json +1 -1
  132. package/kits/schemas/daemon.schema.7.json +140 -0
  133. package/kits/skills/ec-group/SKILL.md +8 -5
  134. package/kits/skills/ec-skill/scripts/cli.mjs +1 -1
  135. package/kits/skills/ec-skill/scripts/skill.mjs +1 -1
  136. package/kits/skills/thread-ops/SKILL.md +18 -18
  137. package/kits/templates/memory-consolidation/forget.md +2 -2
  138. package/kits/templates/memory-consolidation/generalize.md +4 -4
  139. package/kits/templates/memory-consolidation/notes.md +12 -12
  140. package/kits/templates/memory-consolidation/profile.md +3 -3
  141. package/kits/templates/memory-consolidation/promote.md +3 -3
  142. package/kits/templates/memory-consolidation/workflow.md +42 -7
  143. package/kits/templates/message-fragments/handoff-request-to-target.md +1 -1
  144. package/kits/templates/system-fragments/bash-description.md +9 -0
  145. package/kits/templates/system-fragments/bootstrap.md +4 -3
  146. package/kits/templates/system-fragments/channel.md +2 -2
  147. package/kits/templates/system-fragments/memory.md +16 -9
  148. package/kits/templates/system-fragments/reply.md +6 -6
  149. package/kits/templates/system-fragments/session.md +1 -1
  150. package/package.json +4 -3
  151. package/dist/core/memory/memory-session-policy.js +0 -41
  152. package/dist/core/memory/memory-signature.js +0 -257
@@ -3,12 +3,29 @@ import fs from 'fs';
3
3
  import path from 'path';
4
4
  import { agentSessionsDir, agentDir, agentThreadIndexPath, agentThreadSummariesDir, agentThreadSummaryPath } from '../../paths.js';
5
5
  import { atomicWriteJson, atomicReadJson } from '../../utils/atomic-write.js';
6
- import { appendJsonl, readAllJsonlLines, readLastJsonlLineStrict, chatDirPath, tryParseChannelSessionKey } from '../session/session-fs-store.js';
7
- import { sanitizeSessionTitle } from '../session/session-title.js';
6
+ import { appendJsonl, appendJsonlDurable, readAllJsonlLines, readLastJsonlLine, readLastJsonlLineStrict, chatDirPath, scanChatDirs, tryParseChannelSessionKey } from '../session/session-fs-store.js';
7
+ import { sanitizeSessionTitle, SESSION_TITLE_MAX_LEN, SESSION_TITLE_STORED_MAX_LEN, SESSION_TITLE_ELLIPSIS } from '../session/session-title.js';
8
8
  import { ThreadContextStorage } from './thread-context-storage.js';
9
9
  const THREAD_ID_RE = /^thread-[0-9]+-[0-9a-f]{4}-[0-9a-f]{4}$/;
10
10
  const RESERVED_THREAD_IDS = new Set(['current', 'main', 'all']);
11
- const THREAD_NAME_MAX_LEN = 80;
11
+ /**
12
+ * 话题名长度校验:自动生成与手动改名共用 `session-title.ts` 的两个常量。
13
+ *
14
+ * - `SESSION_TITLE_MAX_LEN`(80):正文上限,`sanitizeSessionTitle` 按它截断并补省略号;
15
+ * - `SESSION_TITLE_STORED_MAX_LEN`(83):80 字符正文 + `...`,落盘上限。
16
+ *
17
+ * 校验按落盘上限而不是正文上限:系统自动生成的话题名最长就是 83 字符,
18
+ * 按 80 拒绝会让这些名字「改不回去」。超过 83 的直接拒绝并报出当前长度,
19
+ * 不静默截断——让调用方以为完整名称生效了比报错更糟。
20
+ */
21
+ function assertThreadNameLength(rawName, sanitized) {
22
+ const raw = rawName.trim();
23
+ if (raw.length <= SESSION_TITLE_STORED_MAX_LEN)
24
+ return;
25
+ throw new ThreadServiceError(`话题名称超过 ${SESSION_TITLE_STORED_MAX_LEN} 字符上限(当前 ${raw.length} 字符,含标点;`
26
+ + `其中前 ${SESSION_TITLE_MAX_LEN} 字符为正文,末尾可带 ${SESSION_TITLE_ELLIPSIS} 省略号)。`
27
+ + `请缩写后重试`, 'NAME_TOO_LONG');
28
+ }
12
29
  const LIST_DEFAULT_LIMIT = 10;
13
30
  const LIST_MAX_LIMIT = 200;
14
31
  export class ThreadServiceError extends Error {
@@ -25,12 +42,41 @@ function newThreadId() {
25
42
  const rand = crypto.randomBytes(2).toString('hex');
26
43
  return `thread-${ms}-${seq}-${rand}`;
27
44
  }
45
+ /**
46
+ * 读取话题索引,兼容 4d938a6f(话题数据目录收敛)之前的旧路径。
47
+ *
48
+ * 旧文件位于 `data/thread-index.json`,新路径是 `data/threads/index.json`。
49
+ * 那次改动只搬了路径、没写迁移,导致老 agent 的索引在新路径上不存在,
50
+ * 依赖索引的命令(peers / list / rename ...)看着像"没有话题"。
51
+ *
52
+ * 新索引缺失或为空时回退读旧文件,转换后写回新路径。写回是幂等的:
53
+ * 成功一次之后新索引存在,后续不会再走旧文件;旧文件保持原样,不删除。
54
+ */
28
55
  function readIndex(indexPath) {
29
56
  const raw = atomicReadJson(indexPath);
30
- if (!raw || !Array.isArray(raw.threads)) {
31
- return { version: 1, threads: [] };
57
+ if (raw && Array.isArray(raw.threads) && raw.threads.length > 0) {
58
+ return raw;
59
+ }
60
+ const legacyPath = path.join(path.dirname(path.dirname(indexPath)), 'thread-index.json');
61
+ if (legacyPath !== indexPath) {
62
+ const legacy = atomicReadJson(legacyPath);
63
+ if (legacy && Array.isArray(legacy.threads) && legacy.threads.length > 0) {
64
+ const migrated = {
65
+ version: 1,
66
+ threads: legacy.threads,
67
+ };
68
+ // 顺手落盘;失败不影响本次读取(例如目录只读),下次仍能走回退。
69
+ try {
70
+ fs.mkdirSync(path.dirname(indexPath), { recursive: true });
71
+ atomicWriteJson(indexPath, migrated);
72
+ }
73
+ catch { /* 写失败不阻断读取 */ }
74
+ return migrated;
75
+ }
32
76
  }
33
- return raw;
77
+ if (raw && Array.isArray(raw.threads))
78
+ return raw;
79
+ return { version: 1, threads: [] };
34
80
  }
35
81
  /**
36
82
  * 把用户传入的 peer 归一成索引里实际存储的形态(对端在该渠道下的原生 ID)。
@@ -117,9 +163,7 @@ export class ThreadService {
117
163
  const sanitized = sanitizeSessionTitle(name);
118
164
  if (!sanitized)
119
165
  throw new ThreadServiceError('话题名称不能为空', 'INVALID_NAME');
120
- if (sanitized.length > THREAD_NAME_MAX_LEN) {
121
- throw new ThreadServiceError(`话题名称超过 ${THREAD_NAME_MAX_LEN} 字符`, 'NAME_TOO_LONG');
122
- }
166
+ assertThreadNameLength(name, sanitized);
123
167
  // Check for duplicate name on same peer
124
168
  const index = this.readThreadIndex(selfAid);
125
169
  const dup = index.threads.find(t => t.peer === peer && t.name === sanitized);
@@ -176,8 +220,20 @@ export class ThreadService {
176
220
  const sanitized = sanitizeSessionTitle(name);
177
221
  if (!sanitized)
178
222
  throw new ThreadServiceError('话题名称不能为空', 'INVALID_NAME');
179
- if (sanitized.length > THREAD_NAME_MAX_LEN) {
180
- throw new ThreadServiceError(`话题名称超过 ${THREAD_NAME_MAX_LEN} 字符`, 'NAME_TOO_LONG');
223
+ assertThreadNameLength(name, sanitized);
224
+ // 主会话(threadId 为空)从不进话题索引,它由会话文件产生。
225
+ // 改名直接落到 active session 上,不走索引与快照。
226
+ // 群里主会话名只有 admin/owner 能改,且在授权层已经拦过;这里照常落本地。
227
+ if (!threadId) {
228
+ const main = await this.showMainSession(selfAid, peer);
229
+ if (!main)
230
+ throw new ThreadServiceError(`与 ${peer} 无活跃主会话`, 'TOPIC_NOT_FOUND');
231
+ if ((main.name ?? '') === sanitized)
232
+ return { changed: false, name: sanitized };
233
+ const renamed = await this.sessionManager.renameSession(main.sessionId, sanitized);
234
+ if (!renamed)
235
+ throw new ThreadServiceError(`主会话改名失败: ${main.sessionId}`, 'RENAME_FAILED');
236
+ return { changed: true, name: sanitized, nameUpdatedAt: Date.now() };
181
237
  }
182
238
  const index = this.readThreadIndex(selfAid);
183
239
  const entry = index.threads.find(t => t.peer === peer && t.threadId === threadId);
@@ -185,8 +241,10 @@ export class ThreadService {
185
241
  throw new ThreadServiceError(`话题不存在: ${threadId}`, 'TOPIC_NOT_FOUND');
186
242
  if (entry.name === sanitized)
187
243
  return { changed: false, name: sanitized };
244
+ const nameUpdatedAt = Date.now();
188
245
  entry.name = sanitized;
189
- entry.lastActiveAt = Date.now();
246
+ entry.nameUpdatedAt = nameUpdatedAt;
247
+ entry.lastActiveAt = nameUpdatedAt;
190
248
  this.writeThreadIndex(selfAid, index);
191
249
  this.appendThreadSnapshot(selfAid, entry);
192
250
  // Sync to session
@@ -194,7 +252,50 @@ export class ThreadService {
194
252
  if (session) {
195
253
  await this.sessionManager.renameSession(session.id, sanitized);
196
254
  }
197
- return { changed: true, name: sanitized };
255
+ return { changed: true, name: sanitized, nameUpdatedAt };
256
+ }
257
+ /**
258
+ * 应用对端发来的改名通知(`topic-renamed`)。
259
+ *
260
+ * 判据是最新更新时间先到先赢:通知里的 `nameUpdatedAt` 只有**严格大于**本地
261
+ * 记录时才生效。对端尚未建档时按通知内容建条目(与会话自动建档一致),
262
+ * 这样后续跨话题交互能看到对端已命名的话题。
263
+ */
264
+ async applyRemoteRename(input) {
265
+ const sanitized = sanitizeSessionTitle(input.name);
266
+ if (!sanitized || !input.threadId)
267
+ return { applied: false, reason: 'invalid' };
268
+ const peer = normalizeThreadPeer(input.peer) ?? input.peer;
269
+ const index = this.readThreadIndex(input.selfAid);
270
+ const entry = index.threads.find(t => t.peer === peer && t.threadId === input.threadId);
271
+ const current = entry?.nameUpdatedAt ?? 0;
272
+ if (input.nameUpdatedAt <= current)
273
+ return { applied: false, reason: 'stale' };
274
+ if (!entry) {
275
+ index.threads.push({
276
+ threadId: input.threadId,
277
+ sessionId: '',
278
+ peer,
279
+ channelType: 'aun',
280
+ peerKind: input.peerKind ?? 'private',
281
+ name: sanitized,
282
+ nameUpdatedAt: input.nameUpdatedAt,
283
+ label: null,
284
+ topicStatus: 'pending',
285
+ createdAt: input.nameUpdatedAt,
286
+ lastActiveAt: input.nameUpdatedAt,
287
+ });
288
+ this.writeThreadIndex(input.selfAid, index);
289
+ return { applied: true };
290
+ }
291
+ entry.name = sanitized;
292
+ entry.nameUpdatedAt = input.nameUpdatedAt;
293
+ this.writeThreadIndex(input.selfAid, index);
294
+ this.appendThreadSnapshot(input.selfAid, entry);
295
+ const session = await this.sessionManager.getThreadSession(entryChannelType(entry), entry.peer, input.threadId, input.selfAid);
296
+ if (session)
297
+ await this.sessionManager.renameSession(session.id, sanitized);
298
+ return { applied: true };
198
299
  }
199
300
  // ── P1a.1.6: 话题标签 ──
200
301
  async labelThread(input) {
@@ -237,6 +338,10 @@ export class ThreadService {
237
338
  * 活跃时间取自该对端消息日志的最后一行,读尾行(不整文件加载);
238
339
  * 无消息日志或读取失败时退回索引里的管理动作时间。
239
340
  * 内部话题(daemon 命名空间的会话记录)不是真实联系人,不在此列。
341
+ *
342
+ * 候选集 = 索引条目 ∪ 磁盘上真实存在的 chat 目录:
343
+ * 主会话从不进索引(它由会话文件而非 createThread 产生),只靠索引会漏掉
344
+ * "只聊过主会话"的对端。磁盘扫描兜住这一类,threadCount 至少记 1。
240
345
  */
241
346
  listPeers(selfAid) {
242
347
  const index = this.readThreadIndex(selfAid);
@@ -252,6 +357,7 @@ export class ThreadService {
252
357
  threadCount: 1,
253
358
  lastActiveAt: entry.lastActiveAt,
254
359
  channelType: entryChannelType(entry),
360
+ indexed: true,
255
361
  });
256
362
  }
257
363
  else {
@@ -260,6 +366,45 @@ export class ThreadService {
260
366
  existing.lastActiveAt = entry.lastActiveAt;
261
367
  }
262
368
  }
369
+ // 索引之外的兜底:磁盘上真实存在、且有消息或会话存档的 chat 目录。
370
+ // 只加索引里没有的对端——索引已经有了的,子话题数已算过,不再重复计主会话。
371
+ // 从目录名解析 peer 与 channelType,避免信赖可能过期的索引字段。
372
+ try {
373
+ const agentsRoot = path.dirname(agentDir(selfAid));
374
+ for (const chat of scanChatDirs(agentsRoot)) {
375
+ if (chat.selfAID !== selfAid)
376
+ continue;
377
+ const peer = chat.channelId;
378
+ if (!peer || isInternalThreadPeer(peer))
379
+ continue;
380
+ const existing = map.get(peer);
381
+ if (existing) {
382
+ // 索引里有的对端:只更正渠道类型(索引历史条目可能缺字段)
383
+ if (!existing.channelType)
384
+ existing.channelType = chat.channelType;
385
+ continue;
386
+ }
387
+ // 无消息、无会话存档的空目录不算对端
388
+ const hasPayload = fs.existsSync(path.join(chat.dirPath, 'messages.jsonl'))
389
+ || fs.existsSync(path.join(chat.dirPath, 'active.json'));
390
+ if (!hasPayload)
391
+ continue;
392
+ // 用当前主会话判定群/私聊;没有会话文件时按目录名兜底为私聊
393
+ const active = this.sessionManager
394
+ ? this.sessionManager.getActiveSessionSync(chat.channelType, peer, chat.channelType, selfAid)
395
+ : undefined;
396
+ map.set(peer, {
397
+ peerKind: active?.chatType === 'group' ? 'group' : 'private',
398
+ threadCount: 1,
399
+ lastActiveAt: active?.updatedAt ?? 0,
400
+ channelType: chat.channelType,
401
+ indexed: false,
402
+ });
403
+ }
404
+ }
405
+ catch {
406
+ // 扫描失败不影响索引结果
407
+ }
263
408
  return Array.from(map.entries())
264
409
  .map(([peer, v]) => {
265
410
  // 真实活跃时间 = 消息日志末行的时间戳。
@@ -296,6 +441,55 @@ export class ThreadService {
296
441
  return false;
297
442
  return true;
298
443
  });
444
+ // 主会话是 threadId 为 `''` 的话题:它由会话文件而非 createThread 产生,
445
+ // 所以从不进话题索引。只列子话题会让「和某对端聊过什么」在列表里看不到主线,
446
+ // 而 `history <peer>` 不传 threadId 读的恰恰是主会话消息。指定对端时补一条
447
+ // 合成记录,让 list 与 history 的口径一致。
448
+ if (peer) {
449
+ const hasMain = threads.some(t => !t.threadId);
450
+ if (!hasMain) {
451
+ const chatDir = this.chatDirFor(selfAid, peer);
452
+ const logPath = path.join(chatDir, 'messages.jsonl');
453
+ let lastTs = 0;
454
+ if (fs.existsSync(logPath)) {
455
+ try {
456
+ for (const m of readAllJsonlLines(logPath)) {
457
+ if (!m.threadId && typeof m.ts === 'number' && m.ts > lastTs)
458
+ lastTs = m.ts;
459
+ }
460
+ }
461
+ catch { /* skip corrupt file */ }
462
+ }
463
+ // 群标识与私聊 AID 语法相同,只能靠会话记录里的 chatType 判定;
464
+ // 没有会话文件时退回私聊(`ec thread list <peer>` 的常见用法)。
465
+ const active = this.sessionManager.getActiveSessionSync('aun', peer, 'aun', selfAid);
466
+ const peerKind = active?.chatType === 'group' ? 'group' : 'private';
467
+ const synthetic = {
468
+ threadId: '',
469
+ sessionId: active?.id ?? '',
470
+ peer,
471
+ channelType: 'aun',
472
+ peerKind,
473
+ // 主会话有自己的名字(创建时写 `默认会话`,可经 ec thread rename 修改)。
474
+ // 读真实值而不是写死 `主会话`:否则列表里的名字和会话存档对不上,
475
+ // 用户也没法从列表确认改名是否生效。`[主会话]` 前缀由 CLI 渲染。
476
+ name: active?.name || '主会话',
477
+ label: null,
478
+ topicStatus: 'pending',
479
+ createdAt: lastTs,
480
+ lastActiveAt: lastTs,
481
+ mainSession: true,
482
+ internal: false,
483
+ msgTotal: 0,
484
+ msg24h: 0,
485
+ lastMsgSnippet: '',
486
+ };
487
+ // 该对端没有任何主会话往来时不造空行;状态/标签过滤对主会话也无意义,
488
+ // 只在未过滤时加入。
489
+ if (lastTs > 0 && !status && !label && !unlabeled)
490
+ threads = [synthetic, ...threads];
491
+ }
492
+ }
299
493
  // 先读消息日志并派生真实活跃时间,再排序、再分页——
300
494
  // 否则 recent 排序用的是索引里的管理动作时间,与实际展示的活跃时间不一致。
301
495
  const ts24h = Date.now() - 86400000;
@@ -315,7 +509,7 @@ export class ThreadService {
315
509
  ? (payload.text ?? payload.content ?? '')
316
510
  : (m.content ?? '');
317
511
  msgs.push({
318
- threadId: m.threadId ?? null,
512
+ threadId: m.threadId ?? '',
319
513
  ts: m.ts ?? 0,
320
514
  snippet: String(text).replace(/\n/g, ' ').slice(0, 50),
321
515
  });
@@ -332,10 +526,12 @@ export class ThreadService {
332
526
  const lastMsgOf = new Map();
333
527
  for (const t of threads) {
334
528
  const msgs = (peerMessages.get(`${entryChannelType(t)}\u0000${t.peer}`) ?? [])
335
- .filter(m => m.threadId === t.threadId);
529
+ .filter(m => (m.threadId ?? '') === (t.threadId ?? ''));
336
530
  const lastMsg = msgs.reduce((acc, m) => (!acc || m.ts > acc.ts) ? m : acc, null);
531
+ // 合成的主会话条目用 `main` 作 threadId,日志里主会话记的是 null。
532
+ const key = t.threadId ?? '';
337
533
  if (lastMsg)
338
- lastMsgOf.set(t.threadId, lastMsg);
534
+ lastMsgOf.set(key, lastMsg);
339
535
  }
340
536
  if (sort === 'created') {
341
537
  threads.sort((a, b) => b.createdAt - a.createdAt);
@@ -349,10 +545,10 @@ export class ThreadService {
349
545
  threads = threads.slice(offset, offset + limit);
350
546
  threads = threads.map(t => {
351
547
  const msgs = (peerMessages.get(`${entryChannelType(t)}\u0000${t.peer}`) ?? [])
352
- .filter(m => m.threadId === t.threadId);
548
+ .filter(m => (m.threadId ?? '') === (t.threadId ?? ''));
353
549
  const msgTotal = msgs.length;
354
550
  const msg24h = msgs.filter(m => m.ts >= ts24h).length;
355
- const lastMsg = lastMsgOf.get(t.threadId);
551
+ const lastMsg = lastMsgOf.get(t.threadId ?? '');
356
552
  const lastActiveAt = lastMsg?.ts || t.lastActiveAt;
357
553
  // 读该话题的 summary(当前最新版本)
358
554
  const summaryEntry = this.showSummary(selfAid, t.sessionId);
@@ -409,11 +605,11 @@ export class ThreadService {
409
605
  return { peer, messages: [] };
410
606
  const messages = readAllJsonlLines(logPath)
411
607
  .filter((m) => {
412
- // threadId 为 undefined/null 时读主会话消息(threadId === null 的行),
413
- // 否则按 threadId 精确匹配子话题消息。
608
+ // 不传 threadId 时读主会话消息:主会话的行 threadId 为 ''(历史残留 null
609
+ // 也按主会话处理),否则按 threadId 精确匹配子话题消息。
414
610
  if (threadId)
415
611
  return m.threadId === threadId;
416
- return m.threadId == null;
612
+ return !m.threadId;
417
613
  })
418
614
  .map((m) => {
419
615
  // 消息体优先取 payloadSummary.text(加密消息的明文摘要),
@@ -631,33 +827,26 @@ export class ThreadService {
631
827
  /**
632
828
  * 把话题的当前元数据追加一行到 _threads/<sessionId>.jsonl,
633
829
  * 使 rebuildIndex 读最后一行时能拿到 rename/label/status 后的最新值。
634
- * 追加的行包含 fileToSession 所需的所有字段(从第一行继承),
830
+ * 追加的行以最后一行(当前权威快照)为底,只覆盖话题字段,
635
831
  * 确保 readMetaLatest 读最后一行时不报 "baseagent is empty"。
832
+ *
833
+ * 不能从第一行继承:第一行是创建记录,agentSessionId 为 null、没有 turnState,
834
+ * 继承它会把已绑定的后端会话冲掉;而且 meta 文件超限压缩后第一行也不再是创建记录。
636
835
  */
637
836
  appendThreadSnapshot(selfAid, entry) {
638
837
  const chatDir = this.chatDirFor(selfAid, entry.peer, entry.channelType);
639
838
  const metaPath = path.join(chatDir, '_threads', `${entry.sessionId}.jsonl`);
640
839
  if (!fs.existsSync(metaPath))
641
840
  return;
642
- // Read the first line to inherit required session fields (id, baseagent, channel, etc.)
643
- // so that fileToSession never sees a line missing mandatory fields.
644
- const allLines = readAllJsonlLines(metaPath);
645
- const firstLine = allLines[0] ?? {};
841
+ const latest = readLastJsonlLine(metaPath) ?? {};
646
842
  const now = entry.lastActiveAt;
647
- appendJsonl(metaPath, {
648
- // required session fields — inherited from the original creation record
649
- id: firstLine.id ?? entry.sessionId,
650
- channel: firstLine.channel,
651
- channelType: firstLine.channelType,
652
- channelId: firstLine.channelId,
653
- sessionKey: firstLine.sessionKey,
654
- selfAID: firstLine.selfAID,
655
- baseagent: firstLine.baseagent,
656
- projectPath: firstLine.projectPath ?? '',
657
- agentSessionId: firstLine.agentSessionId,
843
+ appendJsonlDurable(metaPath, {
844
+ // session fields (id, baseagent, agentSessionId, metadata, activeTask...) — from the latest snapshot
845
+ ...latest,
846
+ id: latest.id ?? entry.sessionId,
847
+ projectPath: latest.projectPath ?? '',
658
848
  chatType: entry.peerKind === 'group' ? 'group' : 'private',
659
- metadata: firstLine.metadata ?? {},
660
- activeTask: null,
849
+ metadata: latest.metadata ?? {},
661
850
  // topic metadata — updated values
662
851
  threadId: entry.threadId,
663
852
  channelIdAlias: entry.peer,
@@ -665,7 +854,7 @@ export class ThreadService {
665
854
  label: entry.label,
666
855
  topicStatus: entry.topicStatus,
667
856
  createdAt: entry.createdAt,
668
- createdAtStr: firstLine.createdAtStr ?? '',
857
+ createdAtStr: latest.createdAtStr ?? '',
669
858
  updatedAt: now,
670
859
  updatedAtStr: new Date(now).toISOString(),
671
860
  });
@@ -702,16 +891,16 @@ export class ThreadService {
702
891
  * 作为"这条摘要覆盖到哪"的游标。下次总结时用它作 `history --after` 起点,
703
892
  * 只读增量。话题为空(或主会话无消息)时不记录该字段。
704
893
  */
705
- setSummary(selfAid, sessionId, content, ifRev) {
894
+ async setSummary(selfAid, sessionId, content, ifRev) {
706
895
  const dir = agentThreadSummariesDir(selfAid);
707
896
  fs.mkdirSync(dir, { recursive: true });
708
897
  const filePath = agentThreadSummaryPath(selfAid, sessionId);
709
- const existing = this._readSummaryLines(filePath);
898
+ const coveredThroughTs = await this.resolveCoveredThroughTs(selfAid, sessionId);
899
+ const existing = this._readAllSummaryLines(filePath);
710
900
  const currentRev = existing.length;
711
901
  if (ifRev !== undefined && ifRev !== currentRev) {
712
902
  throw new ThreadServiceError(`summary CAS 冲突:期望版本 ${ifRev},当前版本 ${currentRev}`, 'SUMMARY_CAS_CONFLICT');
713
903
  }
714
- const coveredThroughTs = this.resolveCoveredThroughTs(selfAid, sessionId);
715
904
  const entry = {
716
905
  rev: currentRev + 1,
717
906
  content,
@@ -726,12 +915,25 @@ export class ThreadService {
726
915
  *
727
916
  * 话题内总结时这就是"当前消息",跨话题总结时(先读全增量再写)也是同一语义。
728
917
  * 话题不存在或无消息时返回 undefined——此时摘要没有游标,下次总结只能全量读。
918
+ *
919
+ * 主会话不进话题索引(它由会话文件产生),索引里查不到时按 sessionId 回退到
920
+ * session 本身:peer 取 channelId,threadId 取 session.threadId(主会话为空串)。
921
+ * 不回退的话主会话摘要永远没有游标,增量总结在主会话上不成立。
729
922
  */
730
- resolveCoveredThroughTs(selfAid, sessionId) {
923
+ async resolveCoveredThroughTs(selfAid, sessionId) {
731
924
  const entry = this.readThreadIndex(selfAid).threads.find(t => t.sessionId === sessionId);
732
- if (!entry)
925
+ let peer = entry?.peer;
926
+ let threadId = entry?.threadId;
927
+ if (!entry) {
928
+ const session = await this.sessionManager.getSessionById(sessionId);
929
+ if (!session)
930
+ return undefined;
931
+ peer = session.channelId;
932
+ threadId = session.threadId ?? '';
933
+ }
934
+ if (!peer)
733
935
  return undefined;
734
- const { messages } = this._readThreadMessages(selfAid, entry.peer, entry.threadId);
936
+ const { messages } = this._readThreadMessages(selfAid, peer, threadId);
735
937
  if (messages.length === 0)
736
938
  return undefined;
737
939
  return messages[messages.length - 1].timestamp;
@@ -760,18 +962,23 @@ export class ThreadService {
760
962
  */
761
963
  clearSummary(selfAid, sessionId) {
762
964
  const filePath = agentThreadSummaryPath(selfAid, sessionId);
763
- const existing = this._readSummaryLines(filePath);
965
+ // 计数必须含墓碑:rev 是文件行序号,过滤后会与已有墓碑撞号。
966
+ const existing = this._readAllSummaryLines(filePath);
764
967
  if (existing.length === 0)
765
968
  return; // 已无内容,幂等
766
969
  const dir = agentThreadSummariesDir(selfAid);
767
970
  fs.mkdirSync(dir, { recursive: true });
768
971
  appendJsonl(filePath, { rev: existing.length + 1, content: null, updatedAt: Date.now() });
769
972
  }
770
- _readSummaryLines(filePath) {
973
+ /** 全部记录,含 clear 墓碑(content=null)。rev/CAS 计数一律基于这个。 */
974
+ _readAllSummaryLines(filePath) {
771
975
  if (!fs.existsSync(filePath))
772
976
  return [];
773
- const all = readAllJsonlLines(filePath);
774
- // 过滤掉 clear 墓碑(content=null),只返回实际写入的版本
775
- return all.filter(l => l.content !== null);
977
+ return readAllJsonlLines(filePath);
978
+ }
979
+ /** 只含实际写入的版本,不含 clear 墓碑。仅用于展示历史。 */
980
+ _readSummaryLines(filePath) {
981
+ return this._readAllSummaryLines(filePath)
982
+ .filter(l => l.content !== null);
776
983
  }
777
984
  }
@@ -6,6 +6,7 @@ import { logger } from '../utils/logger.js';
6
6
  import { clearRoleStoreCache } from '../config/role-store.js';
7
7
  import { resolveAgentLifecycle } from '../config/lifecycle.js';
8
8
  import { loadManifest, loadManifestMeta, invalidateManifestCache, evaluateWhen, renderTemplate, renderLoopSection, resolvePathWithDiag, loadSectionFiles, loadChildTemplate, buildPathMappings, shortenPath, } from './manifest-engine.js';
9
+ import { SYSTEM_SESSION_TYPES } from '../core/system-session/types.js';
9
10
  export const DEFAULT_MANIFEST_FILE = 'eck_manifest.json';
10
11
  /**
11
12
  * 会话原型(sessionType)→ manifest 文件的内置映射。
@@ -16,9 +17,13 @@ export const BUILTIN_SESSION_MANIFESTS = {
16
17
  memory: 'eck_manifest.memory.json',
17
18
  auxiliary: 'eck_manifest.auxiliary.json',
18
19
  };
19
- /** 选择顺序:config 覆盖 → 内置映射 → 主 manifest。 */
20
+ /**
21
+ * 选择顺序:config 覆盖 → 内置映射 → 主 manifest。
22
+ * 系统会话类型不接受 config 覆盖:其 manifest 由 system-session 模块决定。
23
+ */
20
24
  export function resolveSessionManifest(sessionType, overrides) {
21
- return overrides?.[sessionType] ?? BUILTIN_SESSION_MANIFESTS[sessionType] ?? DEFAULT_MANIFEST_FILE;
25
+ const override = SYSTEM_SESSION_TYPES.includes(sessionType) ? undefined : overrides?.[sessionType];
26
+ return override ?? BUILTIN_SESSION_MANIFESTS[sessionType] ?? DEFAULT_MANIFEST_FILE;
22
27
  }
23
28
  // ── Caches ──
24
29
  const _sessionPathCache = new Map();
@@ -3,9 +3,10 @@
3
3
  // when 求值、模板渲染、路径解析、manifest 加载/缓存原语,避免两套实现漂移。
4
4
  import fs from 'fs';
5
5
  import path from 'path';
6
- import { kitsDir, resolveRoot, getPackageRoot } from '../paths.js';
6
+ import { kitsDir, resolveRoot, getPackageRoot, isDevMode } from '../paths.js';
7
7
  import { logger } from '../utils/logger.js';
8
8
  import { fileCache } from '../core/daemon-file-cache.js';
9
+ import { readVerifiedText, isShippedPath } from '../core/file-integrity/read.js';
9
10
  // ── 限额默认值 ──
10
11
  /** 单目录段默认最多文件数 */
11
12
  export const DIR_MAX_FILES = 20;
@@ -365,12 +366,31 @@ function sectionCacheOptions(resolvedPath, vars) {
365
366
  }
366
367
  return { policy: 'mtime', group: 'eck-runtime' };
367
368
  }
369
+ /**
370
+ * 读取一个 kits 文档,安装模式下先过台账校验。
371
+ *
372
+ * 为什么校验包在 loader 里:这些内容走 FileCache 的 `on-reload` 策略,缓存命中时
373
+ * 不会碰磁盘,校验只有包在这里才与"实际注入的那份内容"对齐;被篡改且恢复不了时
374
+ * 返回 null,条目本身也不会写进缓存。开发模式下 kits 由 agent 直接改写,不校验。
375
+ */
376
+ function loadKitsDoc(resolved, vars) {
377
+ const opts = sectionCacheOptions(resolved, vars);
378
+ if (!isShippedPath(resolved)) {
379
+ return fileCache.getText(resolved, opts);
380
+ }
381
+ const verify = !isDevMode();
382
+ return fileCache.get(resolved, () => {
383
+ const checked = readVerifiedText(resolved, { verify, tag: 'eck' });
384
+ // 校验不过就不进缓存:返回 null → fileCache 存 null,该段被丢弃
385
+ return checked ? checked.text : null;
386
+ }, opts);
387
+ }
368
388
  /** 加载 loop 的 child 子模板文件内容(路径解析 + fileCache)。找不到返回 null。 */
369
389
  export function loadChildTemplate(childFile, vars) {
370
390
  const resolved = resolvePath(childFile, vars);
371
391
  if (!resolved)
372
392
  return null;
373
- return fileCache.getText(resolved, sectionCacheOptions(resolved, vars));
393
+ return loadKitsDoc(resolved, vars);
374
394
  }
375
395
  /**
376
396
  * 返回 [filePath, rawContent][]。kits 文件跨 session 缓存到 reload;运行时文件
@@ -401,7 +421,7 @@ function loadFileSection(filePath, vars, sessionCache) {
401
421
  const resolved = resolvePath(filePath, vars);
402
422
  if (!resolved)
403
423
  return null;
404
- const content = fileCache.getText(resolved, sectionCacheOptions(resolved, vars));
424
+ const content = loadKitsDoc(resolved, vars);
405
425
  return content === null ? null : [resolved, content];
406
426
  }
407
427
  /**
@@ -435,7 +455,7 @@ function readDirectoryFiles(dirPath, pattern, maxFiles, maxBytes, vars) {
435
455
  break;
436
456
  }
437
457
  const fp = path.join(dirPath, f);
438
- const content = fileCache.getText(fp, sectionCacheOptions(fp, vars));
458
+ const content = loadKitsDoc(fp, vars);
439
459
  if (content === null)
440
460
  continue;
441
461
  // 字节限额(至少加载一个文件,避免单个超大文件导致空目录)
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // 【重要】最先加载环境变量,确保后续模块初始化时可用
2
2
  import dotenv from 'dotenv';
3
3
  import path from 'path';
4
- import { ensureDataDirs, resolvePaths, eckDebugDir, daemonControlDir, daemonQueuePath, daemonMessageDedupPath } from './paths.js';
4
+ import { ensureDataDirs, resolvePaths, eckDebugDir, daemonControlDir, daemonQueuePath, daemonMessageDedupPath, isDevMode } from './paths.js';
5
5
  // 立即加载 .env 文件(在其他模块导入之前)
6
6
  try {
7
7
  dotenv.config({ path: path.join(resolvePaths().root, '.env') });
@@ -57,6 +57,7 @@ import { StatsCollector } from './utils/stats.js';
57
57
  import { AidStatsCollector } from './utils/stats.js';
58
58
  import { initializeStatsPersistence } from './stats/writer.js';
59
59
  import { PermissionGateway } from './core/permission/approval-gateway.js';
60
+ import { releaseSessionPathGrants } from './core/permission/session-path-grants.js';
60
61
  import { InteractionRouter } from './core/interaction-router.js';
61
62
  import { GroupInviteReviewService } from './aun/group/invite-service.js';
62
63
  import { AgentDelegationRegistry, } from './core/auth/agent-delegation.js';
@@ -138,8 +139,10 @@ async function main() {
138
139
  }
139
140
  // 立即登记自己(让其他并发启动者能看见我)
140
141
  const launchedBy = process.env.EVOLCORE_LAUNCHED_BY || 'start';
142
+ // 本次运行模式在这里定下(首次求值时会读走并删除 `ec start --as-mode` 的覆盖文件)
143
+ const devMode = isDevMode();
141
144
  writeMain(launchedBy);
142
- logger.info(`✓ Instance record written: main-${process.pid}.json`);
145
+ logger.info(`✓ Instance record written: main-${process.pid}.json runMode=${devMode ? 'dev' : 'install'}`);
143
146
  // post-write 自检:写完 record 后再扫一次,发现并发对手时按 (startedAt, pid) 选赢家
144
147
  {
145
148
  const verdict = isMainWinner();
@@ -240,6 +243,12 @@ async function main() {
240
243
  if (event.nameChanged)
241
244
  agentRegistry?.invalidateAgentDisplayCache?.(event.aid);
242
245
  });
246
+ // 运行时路径授权以会话为归属:会话被删除后不再有身份去消费它,随之释放。
247
+ // 只挂 session:deleted——session:idle 只表示空闲,会话仍会继续服务。
248
+ eventBus.subscribe('session:deleted', (event) => {
249
+ if ('sessionId' in event && event.sessionId)
250
+ releaseSessionPathGrants(event.sessionId);
251
+ });
243
252
  // 统计收集器(近 1 小时滚动统计)
244
253
  const statsCollector = new StatsCollector(eventBus);
245
254
  // Per-AID 消息统计收集器(累计,供 watch aid 实时展示)