evolcore 0.0.39 → 0.0.40

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 (102) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/README.md +31 -20
  3. package/bin/ec-safe-output.js +14 -2
  4. package/dist/agents/baseagent-availability.js +1 -0
  5. package/dist/agents/claude-runner.js +32 -46
  6. package/dist/agents/codex-runner.js +51 -27
  7. package/dist/agents/ecagent-runner.js +18 -7
  8. package/dist/agents/gemini-runner.js +1 -2
  9. package/dist/agents/runner-types.js +28 -6
  10. package/dist/aun/aid/client.js +1 -1
  11. package/dist/aun/aid/control-aid.js +23 -98
  12. package/dist/aun/aid/ownership-operation.js +1 -3
  13. package/dist/aun/group/invite-service.js +6 -0
  14. package/dist/aun/msg/p2p.js +8 -12
  15. package/dist/channels/aun.js +66 -48
  16. package/dist/channels/dingtalk.js +8 -2
  17. package/dist/channels/feishu.js +6 -1
  18. package/dist/channels/wecom.js +7 -3
  19. package/dist/cli/agent-create.js +23 -4
  20. package/dist/cli/aid-bind-command.js +4 -1
  21. package/dist/cli/daemon-commands.js +242 -17
  22. package/dist/cli/index.js +25 -13
  23. package/dist/cli/memory-command.js +33 -14
  24. package/dist/cli/restart-monitor.js +35 -13
  25. package/dist/cli/task-context.js +1 -0
  26. package/dist/config/config-field-policy.js +1 -0
  27. package/dist/contracts/ecweb-service-protocol.js +44 -0
  28. package/dist/core/agent-runtime.js +6 -0
  29. package/dist/core/auth/agent-delegation.js +7 -4
  30. package/dist/core/auth/auth-gateway.js +3 -1
  31. package/dist/core/auth/authorization-audit.js +5 -1
  32. package/dist/core/auth/debug-context.js +5 -1
  33. package/dist/core/auth/operation-catalog.js +16 -0
  34. package/dist/core/command/command-handler.js +7 -1
  35. package/dist/core/command/menu-catalog.js +48 -12
  36. package/dist/core/command/menu-handler.js +72 -4
  37. package/dist/core/command/menu-protocol.js +20 -1
  38. package/dist/core/command/slash-gate.js +17 -3
  39. package/dist/core/command/slash-handler.js +90 -7
  40. package/dist/core/event-catalog.js +2 -0
  41. package/dist/core/interaction-router.js +2 -1
  42. package/dist/core/ipc-registration.js +26 -7
  43. package/dist/core/memory/batch-meta.js +2 -0
  44. package/dist/core/memory/consolidation-context.js +42 -51
  45. package/dist/core/memory/consolidation-ops-records.js +39 -7
  46. package/dist/core/memory/consolidation-trigger.js +71 -64
  47. package/dist/core/memory/consolidation.js +226 -20
  48. package/dist/core/memory/memory-writer.js +160 -34
  49. package/dist/core/memory/relation-idle-monitor.js +287 -0
  50. package/dist/core/memory/relation-sweep.js +206 -0
  51. package/dist/core/message/aun-ipc-sender.js +5 -0
  52. package/dist/core/message/message-bridge.js +64 -55
  53. package/dist/core/message/message-log.js +17 -0
  54. package/dist/core/message/message-queue.js +6 -0
  55. package/dist/core/message/outbound.js +70 -0
  56. package/dist/core/message/peer-mode.js +7 -2
  57. package/dist/core/message/response-engine.js +341 -73
  58. package/dist/core/ownership/owner-resolver.js +384 -178
  59. package/dist/core/permission/ec-command-parser.js +19 -3
  60. package/dist/core/permission/tool-policy.js +7 -7
  61. package/dist/core/role/runtime-policy.js +64 -0
  62. package/dist/core/session/session-manager.js +25 -5
  63. package/dist/core/system-session/memory.js +3 -18
  64. package/dist/ecweb/basic-settings-service.js +709 -0
  65. package/dist/ecweb/providers-service.js +172 -0
  66. package/dist/index.js +92 -4
  67. package/dist/ipc.js +78 -11
  68. package/dist/stats/db.js +33 -0
  69. package/dist/stats/index.js +2 -2
  70. package/dist/stats/query.js +246 -0
  71. package/dist/stats/writer.js +41 -1
  72. package/dist/trigger/application-service.js +12 -0
  73. package/dist/trigger/authorization.js +2 -2
  74. package/dist/trigger/history.js +36 -0
  75. package/dist/trigger/scheduler.js +3 -0
  76. package/dist/utils/ecweb-supervisor.js +31 -10
  77. package/dist/utils/error-utils.js +8 -4
  78. package/dist/utils/evolcore-version.js +3 -2
  79. package/dist/utils/host-info.js +52 -0
  80. package/ecagent/dist/harness/agent-harness.js +4 -1
  81. package/kits/docs/evolcore/INDEX.md +1 -1
  82. package/kits/docs/evolcore/agent.md +1 -1
  83. package/kits/docs/evolcore/aid.md +29 -12
  84. package/kits/docs/evolcore/config.md +9 -7
  85. package/kits/docs/evolcore/memory-architecture.md +3 -4
  86. package/kits/docs/evolcore/memory-consolidation.md +28 -7
  87. package/kits/docs/evolcore/memory-tags.md +1 -1
  88. package/kits/docs/evolcore/memory.md +1 -1
  89. package/kits/docs/evolcore/menu-client.md +1 -1
  90. package/kits/docs/evolcore/menu-protocol.md +6 -0
  91. package/kits/docs/evolcore/msg.md +1 -1
  92. package/kits/eck_manifest.json +3 -3
  93. package/kits/rules/04-relation.md +3 -2
  94. package/kits/schemas/agent-config.schema.13.json +5 -1
  95. package/kits/templates/memory-consolidation/generalize.md +32 -4
  96. package/kits/templates/memory-consolidation/notes.md +2 -2
  97. package/kits/templates/memory-consolidation/promote.md +10 -2
  98. package/kits/templates/system-fragments/baseagent.md +5 -0
  99. package/kits/templates/system-fragments/memory.md +12 -3
  100. package/kits/templates/system-fragments/session.md +1 -1
  101. package/kits/{rules/07-memory-and-thread.md → templates/system-fragments/thread.md} +17 -80
  102. package/package.json +3 -3
@@ -35,10 +35,8 @@ export function resolveScopeDirs(scope, taskCtx, ownerAid, peerKeyOverride) {
35
35
  const peerKey = peerKeyOverride ?? taskCtx.peerKey;
36
36
  if (!peerKey)
37
37
  throw new Error('relation 作用域需要 peerKey(当前会话无对端信息)');
38
- // 防止路径穿越:peerKey 不能含路径分隔符或 ..
39
- if (peerKey.includes('..') || peerKey.includes('/') || peerKey.includes('\\')) {
40
- throw new Error(`INVALID_PEER_KEY: peerKey 格式非法: "${peerKey}"`);
41
- }
38
+ // 防止路径穿越:peerKey 不能含路径分隔符或 ..(校验逻辑见 assertPeerKey)
39
+ assertPeerKey(peerKey);
42
40
  const memoryDir = path.join(root, 'agents', selfAid, 'relations', peerKey, 'memory');
43
41
  return { memoryDir, consolidatedDir: path.join(memoryDir, 'consolidated') };
44
42
  }
@@ -47,11 +45,82 @@ export function resolveScopeDirs(scope, taskCtx, ownerAid, peerKeyOverride) {
47
45
  const memoryDir = path.join(root, 'memory', 'owner', ownerAid);
48
46
  return { memoryDir, consolidatedDir: path.join(memoryDir, 'consolidated') };
49
47
  }
48
+ /**
49
+ * peerKey 校验。单一实现,避免多处各写一份、强度漂移。
50
+ *
51
+ * peerKey 会拼进文件路径,是外部输入面(来自 note 的 speakers、`<peerKey>/consolidated/x.md`
52
+ * 形式的候选路径),所以这里比"拒 .. / \ / /"更严:空串会让 path.join 折叠成
53
+ * relations/memory,`C:` 在 Windows 上是 drive-relative,NUL 会截断 fs 调用。
54
+ */
55
+ export function assertPeerKey(peerKey) {
56
+ if (!peerKey)
57
+ throw new Error('INVALID_PEER_KEY: peerKey 不能为空');
58
+ if (peerKey.length > 128)
59
+ throw new Error(`INVALID_PEER_KEY: peerKey 过长(${peerKey.length} > 128)`);
60
+ if (peerKey.includes('..') || peerKey.includes('/') || peerKey.includes('\\')) {
61
+ throw new Error(`INVALID_PEER_KEY: peerKey 格式非法: "${peerKey}"`);
62
+ }
63
+ if (/^[A-Za-z]:/.test(peerKey) || path.isAbsolute(peerKey)) {
64
+ throw new Error(`INVALID_PEER_KEY: peerKey 不能是绝对路径: "${peerKey}"`);
65
+ }
66
+ // 控制字符(含 NUL)会让 fs 调用截断或抛错
67
+ if (/[\x00-\x1f\x7f]/.test(peerKey)) {
68
+ throw new Error(`INVALID_PEER_KEY: peerKey 含控制字符: "${peerKey}"`);
69
+ }
70
+ return peerKey;
71
+ }
72
+ /** 某个关系的 memory 根:agents/<selfAid>/relations/<peerKey>/memory */
73
+ function relationMemoryDirOf(selfAid, peerKey) {
74
+ assertPeerKey(peerKey);
75
+ return path.join(resolveRoot(), 'agents', selfAid, 'relations', peerKey, 'memory');
76
+ }
77
+ /**
78
+ * 把 `<peerKey>/consolidated/<file>` 形式的路径解析成该关系 consolidated/ 下的绝对路径。
79
+ *
80
+ * 这是 generalize / promote 跨层操作本关系源文件的解析原语。与"任意绝对路径"相比,
81
+ * 它要求路径显式带上来源关系,因此不会有"这是哪个关系"的歧义——同一个
82
+ * `consolidated/x.md` 在 agent 作用域下会被解析成 agent 级的同名文件,改错对象
83
+ * 还不报错,正是这个前缀要消除的问题。
84
+ *
85
+ * 返回 undefined 表示不是这个形态(交给调用方继续走其他分支)。
86
+ */
87
+ function resolvePeerPrefixedPath(selfAid, allowedPeerKey, input) {
88
+ const normalized = input.replace(/\\/g, '/');
89
+ const parts = normalized.split('/');
90
+ // 至少 <peerKey>/consolidated/<file>
91
+ if (parts.length < 3 || parts[1] !== 'consolidated')
92
+ return undefined;
93
+ const peerKey = parts[0];
94
+ if (!peerKey)
95
+ return undefined;
96
+ // peerKey 里本来就可能带 `#`(aun#xxx),不能靠字符判断形态,靠段结构。
97
+ if (peerKey === 'consolidated' || peerKey === 'pending' || peerKey === 'archive')
98
+ return undefined;
99
+ // 整理一律按关系进行:只允许操作本次会话所属关系。会话没有所属关系(如 owner 层 forget)
100
+ // 时一律拒绝——放开等于让它对读不到的关系目录做 archive/update,绕过 grants 模型。
101
+ if (!allowedPeerKey || peerKey !== allowedPeerKey) {
102
+ throw new Error(`ROLE_ACCESS_DENIED: 只能操作本次整理会话所属关系(${allowedPeerKey ?? '无'})的记忆,收到: ${peerKey}`);
103
+ }
104
+ const memoryDir = relationMemoryDirOf(selfAid, peerKey);
105
+ const consolidatedDir = path.join(memoryDir, 'consolidated');
106
+ const abs = path.resolve(consolidatedDir, parts.slice(2).join('/'));
107
+ // 包含性校验用 path.relative,不用 startsWith(Windows 大小写不一致时行为不定)
108
+ const rel = path.relative(consolidatedDir, abs);
109
+ if (rel.startsWith('..') || path.isAbsolute(rel)) {
110
+ throw new Error(`ROLE_ACCESS_DENIED: 路径越界: ${input}`);
111
+ }
112
+ return abs;
113
+ }
50
114
  /**
51
115
  * 把用户给的路径解析成 consolidated/ 下的绝对路径。
52
- * 允许 slug / <slug>.md / 相对或绝对路径(须落在本作用域的 consolidated/ 内)。
53
116
  *
54
- * forget-agent 模式特殊处理:允许路径落在任意 relation 的 consolidated/ 下。
117
+ * 接受的形态:
118
+ * - `slug` / `<slug>.md` 本作用域
119
+ * - `consolidated/xxx.md` 本作用域(相对 memory 根)
120
+ * - `<peerKey>/consolidated/x.md` 本次会话所属关系的 consolidated/(见 resolvePeerPrefixedPath)
121
+ * - 落在上述范围内的绝对路径
122
+ *
123
+ * generalize / promote 产出在 agent / owner 层,靠 `<peerKey>/consolidated/` 前缀跨层操作本关系的源文件。
55
124
  */
56
125
  export function resolveConsolidatedPath(dirs, input, taskCtx) {
57
126
  const base = path.resolve(dirs.consolidatedDir);
@@ -61,6 +130,13 @@ export function resolveConsolidatedPath(dirs, input, taskCtx) {
61
130
  assertSlug(slug);
62
131
  return path.join(base, `${slug}.md`);
63
132
  }
133
+ // 跨关系形态优先:`<peerKey>/consolidated/x.md` 在 agent 作用域下若走下面的
134
+ // 本作用域候选,会被解析成 agent 级的同名文件,静默改错对象。
135
+ if (taskCtx?.selfAid) {
136
+ const crossRelation = resolvePeerPrefixedPath(taskCtx.selfAid, taskCtx.peerKey, input);
137
+ if (crossRelation)
138
+ return crossRelation;
139
+ }
64
140
  // 允许 "consolidated/xxx.md" 这种相对 MEMORY.md 的写法
65
141
  const candidates = [path.resolve(input), path.resolve(dirs.memoryDir, input)];
66
142
  for (const abs of candidates) {
@@ -68,21 +144,8 @@ export function resolveConsolidatedPath(dirs, input, taskCtx) {
68
144
  if (!rel.startsWith('..') && !path.isAbsolute(rel))
69
145
  return abs;
70
146
  }
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
- }
85
- throw new Error(`ROLE_ACCESS_DENIED: 只能操作本作用域 consolidated/ 下的记忆(${base}),当前路径越界: ${input}`);
147
+ throw new Error(`ROLE_ACCESS_DENIED: 只能操作本作用域 consolidated/ 下的记忆(${base}),当前路径越界: ${input}。`
148
+ + `跨关系操作用 \`<peerKey>/consolidated/<文件名>\` 形式(peerKey 必须是本次会话所属关系)。`);
86
149
  }
87
150
  function assertSlug(slug) {
88
151
  if (!SLUG_RE.test(slug)) {
@@ -143,8 +206,10 @@ export async function createMemory(req) {
143
206
  setMemoryField(document, 'name', req.slug);
144
207
  setMemoryField(document, 'description', req.description.trim());
145
208
  setMemoryField(document, 'scope', req.scope);
146
- if (req.scope === 'relation' && req.taskCtx.peerKey)
147
- setMemoryField(document, 'peerKey', req.taskCtx.peerKey);
209
+ // 与 resolveScopeDirs 取同一个值:frontmatter 记的必须是实际落盘目录的关系
210
+ const relationPeerKey = req.targetPeerKey ?? req.taskCtx.peerKey;
211
+ if (req.scope === 'relation' && relationPeerKey)
212
+ setMemoryField(document, 'peerKey', relationPeerKey);
148
213
  if (req.scope === 'owner' && req.ownerAid)
149
214
  setMemoryField(document, 'ownerAid', req.ownerAid);
150
215
  setMemoryField(document, 'createdAt', createdAt);
@@ -158,8 +223,13 @@ export async function createMemory(req) {
158
223
  setMemoryField(document, 'tags', req.tags);
159
224
  if (req.keywords?.length)
160
225
  setMemoryField(document, 'keywords', req.keywords);
161
- if (replacePaths.length > 0)
162
- setMemoryField(document, 'replaces', replacePaths.map(p => path.relative(dirs.memoryDir, p)));
226
+ // replaces 基准统一到 agents/<selfAid>/,与 generalizedFrom / promotedFrom 一致。
227
+ // 用 dirs.memoryDir 做基准时,跨关系的条目会变成 `../../relations/x/...`,
228
+ // 同一个文件里两种基准,将来读它的人得先猜。
229
+ if (replacePaths.length > 0) {
230
+ const agentRoot = path.join(resolveRoot(), 'agents', req.taskCtx.selfAid ?? '');
231
+ setMemoryField(document, 'replaces', replacePaths.map(p => path.relative(agentRoot, p)));
232
+ }
163
233
  document.body = req.content.trimEnd();
164
234
  const tmp = targetPath + '.tmp';
165
235
  fs.writeFileSync(tmp, serializeMemoryDocument(document), 'utf-8');
@@ -167,23 +237,34 @@ export async function createMemory(req) {
167
237
  registerFile(targetPath, req.taskCtx.selfAid);
168
238
  // 归档被替换的旧文件(在写入成功之后做,失败不影响新文件)
169
239
  const archived = [];
240
+ const archivedSources = [];
170
241
  for (const p of replacePaths) {
171
242
  if (!fs.existsSync(p))
172
243
  continue;
173
244
  if (path.resolve(p) === path.resolve(targetPath))
174
245
  continue;
175
- const dest = archivePath(dirs.memoryDir, p);
246
+ // 跟随源文件所在层归档,跨关系时落进该关系自己的 archive/
247
+ const srcMemoryDir = sourceMemoryDirOf(p);
248
+ const dest = archivePath(srcMemoryDir, p);
176
249
  fs.mkdirSync(path.dirname(dest), { recursive: true });
177
250
  try {
178
251
  fs.renameSync(p, dest);
179
252
  archived.push(dest);
253
+ archivedSources.push(p);
180
254
  moveRecord(p, dest);
181
255
  }
182
256
  catch { /* 归档失败不影响主流程,由下次整理清理 */ }
183
257
  }
184
- // 刷新本作用域的 _meta.json(扫描优化用;失败不影响主流程)
258
+ // 刷新 _meta.json(扫描优化用;失败不影响主流程)。
259
+ // 跨关系归档时源关系也要刷,否则它 _meta.json 里的标签计数不更新,
260
+ // 定时检查会反复拉起同一个整理会话。
185
261
  refreshMemoryMeta(dirs.memoryDir);
186
- return { ok: true, filePath: targetPath, archived, scopeDirs: dirs };
262
+ for (const src of archivedSources) {
263
+ const srcMemoryDir = sourceMemoryDirOf(src);
264
+ if (path.resolve(srcMemoryDir) !== path.resolve(dirs.memoryDir))
265
+ refreshMemoryMeta(srcMemoryDir);
266
+ }
267
+ return { ok: true, filePath: targetPath, archived, archivedSources, scopeDirs: dirs };
187
268
  }
188
269
  /** 列出本作用域内除 exceptPath 之外、带 profile 标签的记忆文件。 */
189
270
  function findProfileMemories(dirs, exceptPath) {
@@ -214,9 +295,34 @@ function readExistingCreatedAt(filePath) {
214
295
  catch { /* ignore */ }
215
296
  return undefined;
216
297
  }
217
- /** 归档路径:<memory>/archive/consolidated/<原文件名> */
298
+ /**
299
+ * 从一条 consolidated/ 记忆文件的绝对路径反推它所属的 memory 根。
300
+ *
301
+ * 归档必须跟随**源文件**所在层,不能一律用本作用域的 memoryDir:跨关系归档时
302
+ * 后者会把关系级记忆塞进 agent 级 archive/,而 restoreMemoryFile 是按 scope 找
303
+ * 自己的 archive 的,那条记忆就再也捞不回来了。
304
+ */
305
+ export function sourceMemoryDirOf(filePath) {
306
+ const consolidatedDir = path.dirname(filePath);
307
+ if (path.basename(consolidatedDir) !== 'consolidated') {
308
+ throw new Error(`INTERNAL: 记忆文件不在 consolidated/ 下: ${filePath}`);
309
+ }
310
+ return path.dirname(consolidatedDir);
311
+ }
312
+ /**
313
+ * 归档路径:<源文件的 memory 根>/archive/consolidated/<原文件名>。
314
+ *
315
+ * 目标已存在时加时间戳后缀,不静默覆盖——同一关系里两次归档同名文件是可能的
316
+ * (比如先归档又被重建再归档),覆盖会让第一次归档的内容无声消失。
317
+ */
218
318
  function archivePath(memoryDir, filePath) {
219
- return path.join(memoryDir, 'archive', 'consolidated', path.basename(filePath));
319
+ const dir = path.join(memoryDir, 'archive', 'consolidated');
320
+ const base = path.join(dir, path.basename(filePath));
321
+ if (!fs.existsSync(base))
322
+ return base;
323
+ const stem = path.basename(filePath).replace(/\.md$/, '');
324
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
325
+ return path.join(dir, `${stem}.${stamp}.md`);
220
326
  }
221
327
  /**
222
328
  * 把 agent 级或 owner 级记忆降级回上一层(agent→relation 或 owner→agent)。
@@ -294,25 +400,45 @@ export async function updateMemory(req) {
294
400
  fs.writeFileSync(tmp, serializeMemoryDocument(document), 'utf-8');
295
401
  fs.renameSync(tmp, filePath);
296
402
  registerFile(filePath, req.taskCtx.selfAid);
297
- refreshMemoryMeta(dirs.memoryDir);
298
- return { ok: true, filePath, prevTags, scopeDirs: dirs };
403
+ // 跨层 update(如泛化时去掉关系级条目的 to-generalize 标签)时,
404
+ // 要刷的是**该文件所在层**的 _meta.json,不是本作用域那份。
405
+ const targetMemoryDir = sourceMemoryDirOf(filePath);
406
+ refreshMemoryMeta(targetMemoryDir);
407
+ return { ok: true, filePath, prevTags, scopeDirs: dirs, targetMemoryDir };
299
408
  }
300
409
  // ── archive ────────────────────────────────────────────────────────────────
301
410
  export async function archiveMemory(req) {
302
411
  const dirs = resolveScopeDirs(req.scope, req.taskCtx, req.ownerAid);
303
412
  const archived = [];
413
+ const archivedSources = [];
304
414
  for (const t of req.targets) {
305
415
  const p = resolveConsolidatedPath(dirs, t, req.taskCtx);
306
416
  if (!fs.existsSync(p))
307
417
  throw new Error(`找不到记忆文件: ${p}`);
308
- const dest = archivePath(dirs.memoryDir, p);
418
+ // 只归档普通文件:renameSync 一个目录会把它整个搬进 archive/,
419
+ // 符号链接则会让后续读取跟到别处(与 grants.checkWriteTargets 的判据对齐)。
420
+ const stat = fs.lstatSync(p);
421
+ if (!stat.isFile()) {
422
+ throw new Error(`不是普通文件,拒绝归档: ${p}`);
423
+ }
424
+ // 跟随源文件所在层归档,跨关系时落进该关系自己的 archive/
425
+ const srcMemoryDir = sourceMemoryDirOf(p);
426
+ const dest = archivePath(srcMemoryDir, p);
309
427
  fs.mkdirSync(path.dirname(dest), { recursive: true });
310
428
  fs.renameSync(p, dest);
311
429
  moveRecord(p, dest);
312
430
  archived.push(dest);
431
+ archivedSources.push(p);
313
432
  }
433
+ // 跨关系归档时源关系也要刷 _meta.json,否则它 _meta.json 里的标签计数不更新,
434
+ // 定时检查会反复拉起同一个整理会话。
314
435
  refreshMemoryMeta(dirs.memoryDir);
315
- return { ok: true, archived, scopeDirs: dirs };
436
+ for (const src of archivedSources) {
437
+ const srcMemoryDir = sourceMemoryDirOf(src);
438
+ if (path.resolve(srcMemoryDir) !== path.resolve(dirs.memoryDir))
439
+ refreshMemoryMeta(srcMemoryDir);
440
+ }
441
+ return { ok: true, archived, archivedSources, scopeDirs: dirs };
316
442
  }
317
443
  const NOTE_MAX_CONTENT_BYTES = 4 * 1024;
318
444
  function assertMemoryEnabled(memoryConfig) {
@@ -0,0 +1,287 @@
1
+ /**
2
+ * relation-idle-monitor.ts — 关系级空闲 / 兜底计时
3
+ *
4
+ * 记忆整理一律以关系为单位(selfAid + peerKey;群聊按 groupId 算一个关系)。
5
+ * 每个关系一个计时器,到期时间取两者较早者:
6
+ *
7
+ * 空闲 lastInboundAt + idleHours(默认 1) 最后一条入站后没有新入站
8
+ * 兜底 firstUnsweptInboundAt + maxUnconsolidatedHours(默认 8) 最早一条未整理入站等太久
9
+ *
10
+ * 兜底从「最早未整理入站」起算而不是「上次整理时间」:沉寂多日的关系一来消息不会立刻被强整。
11
+ * 只计真实入站:owner 插话(owner-inject)、系统渠道、后台会话、trigger 不算。
12
+ *
13
+ * 到期后交给 startSweep(关系整理链,见 relation-sweep.ts),链跑完回调 onSweepFinished。
14
+ * 同一关系同时只跑一条链;链开始时清空 firstUnsweptInboundAt,期间的入站算新一轮。
15
+ * 状态落盘,重启后恢复;中断的链按「有未整理入站」处理,启动后立即重跑。
16
+ */
17
+ import fs from 'fs';
18
+ import path from 'path';
19
+ import { formatPeerKey } from '../relation/peer-identity.js';
20
+ import { isNonAgentSessionChannel } from '../system-channels.js';
21
+ import { atomicWriteJson, decodeDirSegment, tryParseChannelSessionKey } from '../session/session-fs-store.js';
22
+ import { logger } from '../../utils/logger.js';
23
+ import { DEFAULT_MAX_UNCONSOLIDATED_HOURS, DEFAULT_RELATION_IDLE_HOURS } from './consolidation-trigger.js';
24
+ const HOUR_MS = 60 * 60 * 1000;
25
+ /** setTimeout 的上限约 24.8 天;更远的到期时间分段等。 */
26
+ const MAX_TIMER_MS = 2 ** 31 - 1;
27
+ /** 没有待整理入站、超过这么久没动静的条目不再落盘(路由信息下次入站会重新带上)。 */
28
+ const PRUNE_AFTER_MS = 7 * 24 * HOUR_MS;
29
+ const SAVE_DEBOUNCE_MS = 1000;
30
+ /**
31
+ * 把一条入站日志换算成关系入站信号;不是真实关系入站时返回 undefined。
32
+ *
33
+ * chatDir 形如 `<sessionsDir>/<selfAid>/sessions/<type>#<name>#<channelId>`,
34
+ * channelType / 渠道实例名 / selfAid 都从路径取——日志条目本身只有 from/to。
35
+ */
36
+ export function relationInboundFromLog(chatDir, entry) {
37
+ if (entry.dir !== 'in')
38
+ return undefined;
39
+ if (entry.source === 'owner-inject')
40
+ return undefined;
41
+ if (entry.peerType === 'system')
42
+ return undefined;
43
+ const parsed = tryParseChannelSessionKey(path.basename(chatDir));
44
+ if (!parsed)
45
+ return undefined;
46
+ const { channelType, channelName, channelId } = parsed;
47
+ if (isNonAgentSessionChannel(channelType))
48
+ return undefined;
49
+ if (channelId.startsWith('bg:') || channelId.startsWith('trigger:'))
50
+ return undefined;
51
+ const sessionsSeg = path.basename(path.dirname(chatDir));
52
+ if (sessionsSeg !== 'sessions')
53
+ return undefined;
54
+ const selfAid = decodeDirSegment(path.basename(path.dirname(path.dirname(chatDir))));
55
+ if (!selfAid || selfAid === '_unknown')
56
+ return undefined;
57
+ const chatType = entry.chatType === 'group' ? 'group' : 'private';
58
+ // 群聊按群算一个关系;私聊按发送者(AUN 下 from 即对端 AID,其他渠道 from 是用户 ID)
59
+ const peerId = chatType === 'group'
60
+ ? (entry.groupId || channelId)
61
+ : (entry.from && entry.from !== 'unknown' ? entry.from : channelId);
62
+ if (!peerId || peerId.startsWith('bg:') || peerId.startsWith('trigger:'))
63
+ return undefined;
64
+ return {
65
+ selfAid,
66
+ peerKey: formatPeerKey(channelType, peerId),
67
+ route: {
68
+ channel: channelType,
69
+ channelId,
70
+ chatType,
71
+ peerId,
72
+ channelKey: `${channelType}#${selfAid}#${channelName}`,
73
+ ...(entry.threadId ? { threadId: entry.threadId } : {}),
74
+ },
75
+ };
76
+ }
77
+ function relationKey(selfAid, peerKey) {
78
+ return `${selfAid}\u0000${peerKey}`;
79
+ }
80
+ function positiveHours(value, fallback) {
81
+ return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : fallback;
82
+ }
83
+ export class RelationIdleMonitor {
84
+ deps;
85
+ entries = new Map();
86
+ active = false;
87
+ saveTimer;
88
+ now;
89
+ constructor(deps) {
90
+ this.deps = deps;
91
+ this.now = deps.now ?? Date.now;
92
+ }
93
+ /** 加载落盘状态。中断的链按「有未整理入站」恢复。 */
94
+ load() {
95
+ let raw;
96
+ try {
97
+ raw = JSON.parse(fs.readFileSync(this.deps.statePath, 'utf-8'));
98
+ }
99
+ catch {
100
+ return;
101
+ }
102
+ if (!raw || raw.version !== 1 || !Array.isArray(raw.entries))
103
+ return;
104
+ for (const item of raw.entries) {
105
+ if (!item?.selfAid || !item?.peerKey)
106
+ continue;
107
+ const entry = {
108
+ selfAid: item.selfAid,
109
+ peerKey: item.peerKey,
110
+ ...(typeof item.lastInboundAt === 'number' ? { lastInboundAt: item.lastInboundAt } : {}),
111
+ ...(typeof item.firstUnsweptAt === 'number' ? { firstUnsweptAt: item.firstUnsweptAt } : {}),
112
+ ...(item.route ? { route: item.route } : {}),
113
+ };
114
+ // 上次进程退出时链还在跑:那一轮没跑完,当作它覆盖的入站还没整理
115
+ if (typeof item.sweepingSince === 'number') {
116
+ entry.firstUnsweptAt = Math.min(entry.firstUnsweptAt ?? item.sweepingSince, item.sweepingSince);
117
+ }
118
+ this.entries.set(relationKey(entry.selfAid, entry.peerKey), entry);
119
+ }
120
+ }
121
+ /** 开始计时(启动流程末尾调用)。已过期的立即触发。 */
122
+ start() {
123
+ this.active = true;
124
+ for (const entry of this.entries.values())
125
+ this.schedule(entry);
126
+ }
127
+ stop() {
128
+ this.active = false;
129
+ for (const entry of this.entries.values()) {
130
+ if (entry.timer)
131
+ clearTimeout(entry.timer);
132
+ entry.timer = undefined;
133
+ }
134
+ if (this.saveTimer)
135
+ clearTimeout(this.saveTimer);
136
+ this.saveTimer = undefined;
137
+ this.saveNow();
138
+ }
139
+ /** 关系有一条真实入站。 */
140
+ recordInbound(inbound) {
141
+ const at = this.now();
142
+ const entry = this.ensureEntry(inbound.selfAid, inbound.peerKey);
143
+ entry.lastInboundAt = at;
144
+ if (entry.firstUnsweptAt === undefined)
145
+ entry.firstUnsweptAt = at;
146
+ entry.route = inbound.route;
147
+ this.schedule(entry);
148
+ this.scheduleSave();
149
+ }
150
+ /**
151
+ * 要求尽快整理某个关系(群链写入了发言人关系、pending 堆积兜底等),不等计时。
152
+ * 链在跑时记一笔,链结束后再跑一轮。
153
+ */
154
+ requestSweep(selfAid, peerKey, propagate) {
155
+ // 与计时到期同一道门:开关关闭、首次设定未完成时不起链
156
+ if (!this.deps.canSweep(selfAid, peerKey))
157
+ return;
158
+ const entry = this.ensureEntry(selfAid, peerKey);
159
+ if (entry.sweepingSince !== undefined) {
160
+ entry.sweepRequested = true;
161
+ return;
162
+ }
163
+ this.begin(entry, propagate);
164
+ }
165
+ /** 整理链结束(无论成败、无论起没起会话)。 */
166
+ onSweepFinished(selfAid, peerKey) {
167
+ const entry = this.entries.get(relationKey(selfAid, peerKey));
168
+ if (!entry || entry.sweepingSince === undefined)
169
+ return;
170
+ entry.sweepingSince = undefined;
171
+ if (entry.sweepRequested) {
172
+ entry.sweepRequested = false;
173
+ this.begin(entry, false);
174
+ return;
175
+ }
176
+ this.schedule(entry);
177
+ this.scheduleSave();
178
+ }
179
+ /** 测试与诊断用:当前状态快照。 */
180
+ snapshot(selfAid, peerKey) {
181
+ const entry = this.entries.get(relationKey(selfAid, peerKey));
182
+ if (!entry)
183
+ return undefined;
184
+ const { timer: _timer, ...rest } = entry;
185
+ return { ...rest, dueAt: this.dueAt(entry) };
186
+ }
187
+ ensureEntry(selfAid, peerKey) {
188
+ const key = relationKey(selfAid, peerKey);
189
+ let entry = this.entries.get(key);
190
+ if (!entry) {
191
+ entry = { selfAid, peerKey };
192
+ this.entries.set(key, entry);
193
+ }
194
+ return entry;
195
+ }
196
+ /** 没有待整理入站时不计时。 */
197
+ dueAt(entry) {
198
+ if (entry.firstUnsweptAt === undefined)
199
+ return undefined;
200
+ const timing = this.deps.getTiming(entry.selfAid);
201
+ const idleMs = positiveHours(timing?.idleHours, DEFAULT_RELATION_IDLE_HOURS) * HOUR_MS;
202
+ const maxMs = positiveHours(timing?.maxUnconsolidatedHours, DEFAULT_MAX_UNCONSOLIDATED_HOURS) * HOUR_MS;
203
+ const last = entry.lastInboundAt ?? entry.firstUnsweptAt;
204
+ return Math.min(last + idleMs, entry.firstUnsweptAt + maxMs);
205
+ }
206
+ schedule(entry) {
207
+ if (entry.timer)
208
+ clearTimeout(entry.timer);
209
+ entry.timer = undefined;
210
+ if (!this.active || entry.sweepingSince !== undefined)
211
+ return;
212
+ const due = this.dueAt(entry);
213
+ if (due === undefined)
214
+ return;
215
+ const delay = Math.max(0, Math.min(due - this.now(), MAX_TIMER_MS));
216
+ entry.timer = setTimeout(() => {
217
+ entry.timer = undefined;
218
+ // 分段等待:还没到真正的到期时间就再排一次
219
+ if ((this.dueAt(entry) ?? Infinity) > this.now()) {
220
+ this.schedule(entry);
221
+ return;
222
+ }
223
+ this.fire(entry);
224
+ }, delay);
225
+ entry.timer.unref?.();
226
+ }
227
+ fire(entry) {
228
+ if (!this.active || entry.sweepingSince !== undefined)
229
+ return;
230
+ if (!this.deps.canSweep(entry.selfAid, entry.peerKey)) {
231
+ // 现在整理不了(开关关闭、首次设定未完成等):这一轮作废,等下一条入站重新计时
232
+ entry.firstUnsweptAt = undefined;
233
+ this.scheduleSave();
234
+ return;
235
+ }
236
+ this.begin(entry, true);
237
+ }
238
+ begin(entry, propagate) {
239
+ if (entry.timer)
240
+ clearTimeout(entry.timer);
241
+ entry.timer = undefined;
242
+ entry.sweepingSince = this.now();
243
+ entry.firstUnsweptAt = undefined;
244
+ this.scheduleSave();
245
+ logger.info(`[Memory] 关系整理链开始(aid=${entry.selfAid},peerKey=${entry.peerKey})`);
246
+ try {
247
+ this.deps.startSweep({ selfAid: entry.selfAid, peerKey: entry.peerKey, route: entry.route, propagate });
248
+ }
249
+ catch (error) {
250
+ logger.warn(`[Memory] 关系整理链启动失败(peerKey=${entry.peerKey}): ${error?.message}`);
251
+ this.onSweepFinished(entry.selfAid, entry.peerKey);
252
+ }
253
+ }
254
+ scheduleSave() {
255
+ if (this.saveTimer)
256
+ return;
257
+ this.saveTimer = setTimeout(() => {
258
+ this.saveTimer = undefined;
259
+ this.saveNow();
260
+ }, SAVE_DEBOUNCE_MS);
261
+ this.saveTimer.unref?.();
262
+ }
263
+ saveNow() {
264
+ const now = this.now();
265
+ const entries = [];
266
+ for (const entry of this.entries.values()) {
267
+ const pending = entry.firstUnsweptAt !== undefined || entry.sweepingSince !== undefined;
268
+ if (!pending && (entry.lastInboundAt === undefined || now - entry.lastInboundAt > PRUNE_AFTER_MS))
269
+ continue;
270
+ entries.push({
271
+ selfAid: entry.selfAid,
272
+ peerKey: entry.peerKey,
273
+ ...(entry.lastInboundAt !== undefined ? { lastInboundAt: entry.lastInboundAt } : {}),
274
+ ...(entry.firstUnsweptAt !== undefined ? { firstUnsweptAt: entry.firstUnsweptAt } : {}),
275
+ ...(entry.sweepingSince !== undefined ? { sweepingSince: entry.sweepingSince } : {}),
276
+ ...(entry.route ? { route: entry.route } : {}),
277
+ });
278
+ }
279
+ try {
280
+ fs.mkdirSync(path.dirname(this.deps.statePath), { recursive: true });
281
+ atomicWriteJson(this.deps.statePath, { version: 1, entries });
282
+ }
283
+ catch (error) {
284
+ logger.warn(`[Memory] 关系空闲状态落盘失败: ${error?.message}`);
285
+ }
286
+ }
287
+ }