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.
- package/CHANGELOG.md +49 -0
- package/README.md +31 -20
- package/bin/ec-safe-output.js +14 -2
- package/dist/agents/baseagent-availability.js +1 -0
- package/dist/agents/claude-runner.js +32 -46
- package/dist/agents/codex-runner.js +51 -27
- package/dist/agents/ecagent-runner.js +18 -7
- package/dist/agents/gemini-runner.js +1 -2
- package/dist/agents/runner-types.js +28 -6
- package/dist/aun/aid/client.js +1 -1
- package/dist/aun/aid/control-aid.js +23 -98
- package/dist/aun/aid/ownership-operation.js +1 -3
- package/dist/aun/group/invite-service.js +6 -0
- package/dist/aun/msg/p2p.js +8 -12
- package/dist/channels/aun.js +66 -48
- package/dist/channels/dingtalk.js +8 -2
- package/dist/channels/feishu.js +6 -1
- package/dist/channels/wecom.js +7 -3
- package/dist/cli/agent-create.js +23 -4
- package/dist/cli/aid-bind-command.js +4 -1
- package/dist/cli/daemon-commands.js +242 -17
- package/dist/cli/index.js +25 -13
- package/dist/cli/memory-command.js +33 -14
- package/dist/cli/restart-monitor.js +35 -13
- package/dist/cli/task-context.js +1 -0
- package/dist/config/config-field-policy.js +1 -0
- package/dist/contracts/ecweb-service-protocol.js +44 -0
- package/dist/core/agent-runtime.js +6 -0
- package/dist/core/auth/agent-delegation.js +7 -4
- package/dist/core/auth/auth-gateway.js +3 -1
- package/dist/core/auth/authorization-audit.js +5 -1
- package/dist/core/auth/debug-context.js +5 -1
- package/dist/core/auth/operation-catalog.js +16 -0
- package/dist/core/command/command-handler.js +7 -1
- package/dist/core/command/menu-catalog.js +48 -12
- package/dist/core/command/menu-handler.js +72 -4
- package/dist/core/command/menu-protocol.js +20 -1
- package/dist/core/command/slash-gate.js +17 -3
- package/dist/core/command/slash-handler.js +90 -7
- package/dist/core/event-catalog.js +2 -0
- package/dist/core/interaction-router.js +2 -1
- package/dist/core/ipc-registration.js +26 -7
- package/dist/core/memory/batch-meta.js +2 -0
- package/dist/core/memory/consolidation-context.js +42 -51
- package/dist/core/memory/consolidation-ops-records.js +39 -7
- package/dist/core/memory/consolidation-trigger.js +71 -64
- package/dist/core/memory/consolidation.js +226 -20
- package/dist/core/memory/memory-writer.js +160 -34
- package/dist/core/memory/relation-idle-monitor.js +287 -0
- package/dist/core/memory/relation-sweep.js +206 -0
- package/dist/core/message/aun-ipc-sender.js +5 -0
- package/dist/core/message/message-bridge.js +64 -55
- package/dist/core/message/message-log.js +17 -0
- package/dist/core/message/message-queue.js +6 -0
- package/dist/core/message/outbound.js +70 -0
- package/dist/core/message/peer-mode.js +7 -2
- package/dist/core/message/response-engine.js +341 -73
- package/dist/core/ownership/owner-resolver.js +384 -178
- package/dist/core/permission/ec-command-parser.js +19 -3
- package/dist/core/permission/tool-policy.js +7 -7
- package/dist/core/role/runtime-policy.js +64 -0
- package/dist/core/session/session-manager.js +25 -5
- package/dist/core/system-session/memory.js +3 -18
- package/dist/ecweb/basic-settings-service.js +709 -0
- package/dist/ecweb/providers-service.js +172 -0
- package/dist/index.js +92 -4
- package/dist/ipc.js +78 -11
- package/dist/stats/db.js +33 -0
- package/dist/stats/index.js +2 -2
- package/dist/stats/query.js +246 -0
- package/dist/stats/writer.js +41 -1
- package/dist/trigger/application-service.js +12 -0
- package/dist/trigger/authorization.js +2 -2
- package/dist/trigger/history.js +36 -0
- package/dist/trigger/scheduler.js +3 -0
- package/dist/utils/ecweb-supervisor.js +31 -10
- package/dist/utils/error-utils.js +8 -4
- package/dist/utils/evolcore-version.js +3 -2
- package/dist/utils/host-info.js +52 -0
- package/ecagent/dist/harness/agent-harness.js +4 -1
- package/kits/docs/evolcore/INDEX.md +1 -1
- package/kits/docs/evolcore/agent.md +1 -1
- package/kits/docs/evolcore/aid.md +29 -12
- package/kits/docs/evolcore/config.md +9 -7
- package/kits/docs/evolcore/memory-architecture.md +3 -4
- package/kits/docs/evolcore/memory-consolidation.md +28 -7
- package/kits/docs/evolcore/memory-tags.md +1 -1
- package/kits/docs/evolcore/memory.md +1 -1
- package/kits/docs/evolcore/menu-client.md +1 -1
- package/kits/docs/evolcore/menu-protocol.md +6 -0
- package/kits/docs/evolcore/msg.md +1 -1
- package/kits/eck_manifest.json +3 -3
- package/kits/rules/04-relation.md +3 -2
- package/kits/schemas/agent-config.schema.13.json +5 -1
- package/kits/templates/memory-consolidation/generalize.md +32 -4
- package/kits/templates/memory-consolidation/notes.md +2 -2
- package/kits/templates/memory-consolidation/promote.md +10 -2
- package/kits/templates/system-fragments/baseagent.md +5 -0
- package/kits/templates/system-fragments/memory.md +12 -3
- package/kits/templates/system-fragments/session.md +1 -1
- package/kits/{rules/07-memory-and-thread.md → templates/system-fragments/thread.md} +17 -80
- 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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
72
|
-
|
|
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
|
-
|
|
147
|
-
|
|
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
|
-
|
|
162
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
258
|
+
// 刷新 _meta.json(扫描优化用;失败不影响主流程)。
|
|
259
|
+
// 跨关系归档时源关系也要刷,否则它 _meta.json 里的标签计数不更新,
|
|
260
|
+
// 定时检查会反复拉起同一个整理会话。
|
|
185
261
|
refreshMemoryMeta(dirs.memoryDir);
|
|
186
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
298
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|