evolclaw 2.8.3 → 3.1.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 (142) hide show
  1. package/README.md +21 -12
  2. package/bin/ec.js +29 -0
  3. package/dist/agents/baseagent-normalize.js +19 -0
  4. package/dist/agents/claude-runner.js +108 -46
  5. package/dist/agents/codex-runner.js +13 -14
  6. package/dist/agents/gemini-runner.js +15 -17
  7. package/dist/agents/kit-renderer.js +281 -0
  8. package/dist/agents/resolve.js +134 -0
  9. package/dist/aun/aid/agentmd.js +186 -0
  10. package/dist/aun/aid/client.js +134 -0
  11. package/dist/aun/aid/identity.js +159 -0
  12. package/dist/aun/aid/index.js +3 -0
  13. package/dist/aun/aid/lifecycle-log.js +33 -0
  14. package/dist/aun/aid/types.js +1 -0
  15. package/dist/aun/aid/validation.js +21 -0
  16. package/dist/aun/msg/group.js +293 -0
  17. package/dist/aun/msg/index.js +4 -0
  18. package/dist/aun/msg/p2p.js +147 -0
  19. package/dist/aun/msg/payload-type.js +27 -0
  20. package/dist/aun/msg/upload.js +98 -0
  21. package/dist/aun/outbox.js +138 -0
  22. package/dist/aun/rpc/caller.js +42 -0
  23. package/dist/aun/rpc/connection.js +34 -0
  24. package/dist/aun/rpc/index.js +2 -0
  25. package/dist/aun/storage/download.js +29 -0
  26. package/dist/aun/storage/index.js +3 -0
  27. package/dist/aun/storage/manage.js +10 -0
  28. package/dist/aun/storage/upload.js +35 -0
  29. package/dist/channels/aun.js +1340 -349
  30. package/dist/channels/dingtalk.js +59 -5
  31. package/dist/channels/feishu.js +381 -32
  32. package/dist/channels/qqbot.js +68 -12
  33. package/dist/channels/wechat.js +63 -4
  34. package/dist/channels/wecom.js +59 -5
  35. package/dist/cli/agent.js +800 -0
  36. package/dist/cli/bench.js +1219 -0
  37. package/dist/cli/index.js +4513 -0
  38. package/dist/{utils → cli}/init-channel.js +211 -621
  39. package/dist/cli/init.js +178 -0
  40. package/dist/cli/link-rules.js +245 -0
  41. package/dist/cli/net-check.js +640 -0
  42. package/dist/cli/watch-msg.js +589 -0
  43. package/dist/config-store.js +645 -0
  44. package/dist/core/{agent-loader.js → baseagent-loader.js} +6 -12
  45. package/dist/core/channel-loader.js +176 -12
  46. package/dist/core/command-handler.js +883 -848
  47. package/dist/core/evolagent-registry.js +191 -371
  48. package/dist/core/evolagent.js +202 -238
  49. package/dist/core/interaction-router.js +52 -5
  50. package/dist/core/message/im-renderer.js +486 -0
  51. package/dist/core/message/items-formatter.js +68 -0
  52. package/dist/core/message/message-bridge.js +109 -56
  53. package/dist/core/message/message-log.js +93 -0
  54. package/dist/core/message/message-processor.js +430 -212
  55. package/dist/core/message/message-queue.js +13 -6
  56. package/dist/core/permission.js +116 -11
  57. package/dist/core/session/adapters/codex-session-file-adapter.js +24 -2
  58. package/dist/core/session/session-fs-store.js +230 -0
  59. package/dist/core/session/session-manager.js +740 -777
  60. package/dist/core/session/session-mapper.js +87 -0
  61. package/dist/core/trigger/manager.js +122 -0
  62. package/dist/core/trigger/parser.js +128 -0
  63. package/dist/core/trigger/scheduler.js +224 -0
  64. package/dist/data/error-dict.json +118 -0
  65. package/dist/eck/baseagent-caps.js +18 -0
  66. package/dist/eck/detect.js +47 -0
  67. package/dist/eck/init.js +77 -0
  68. package/dist/eck/rules-loader.js +28 -0
  69. package/dist/index.js +560 -283
  70. package/dist/ipc.js +49 -0
  71. package/dist/net-check.js +640 -0
  72. package/dist/paths.js +73 -9
  73. package/dist/types.js +8 -2
  74. package/dist/utils/aid-lifecycle-log.js +33 -0
  75. package/dist/utils/atomic-write.js +89 -0
  76. package/dist/utils/channel-helpers.js +46 -0
  77. package/dist/utils/cross-platform.js +17 -26
  78. package/dist/utils/error-utils.js +10 -2
  79. package/dist/utils/instance-registry.js +434 -0
  80. package/dist/utils/log-writer.js +217 -0
  81. package/dist/utils/logger.js +34 -77
  82. package/dist/utils/media-cache.js +23 -0
  83. package/dist/utils/npm-ops.js +163 -0
  84. package/dist/utils/process-introspect.js +122 -0
  85. package/dist/utils/stats.js +192 -0
  86. package/dist/watch-msg.js +544 -0
  87. package/evolclaw-install-aun.md +127 -47
  88. package/kits/docs/GUIDE.md +20 -0
  89. package/kits/docs/INDEX.md +52 -0
  90. package/kits/docs/aun/CHEATSHEET.md +17 -0
  91. package/kits/docs/aun/SYNC_PROTOCOL.md +15 -0
  92. package/kits/docs/channels/aun.md +25 -0
  93. package/kits/docs/channels/feishu.md +27 -0
  94. package/kits/docs/eck_templates/GUIDE.template.md +22 -0
  95. package/kits/docs/eck_templates/INDEX.template.md +28 -0
  96. package/kits/docs/eck_templates/path-registry.template.md +33 -0
  97. package/kits/docs/eck_templates/runtime.template.md +19 -0
  98. package/kits/docs/evolclaw/AGENT_CMD.md +31 -0
  99. package/kits/docs/evolclaw/MSG_GROUP.md +30 -0
  100. package/kits/docs/evolclaw/MSG_PRIVATE.md +25 -0
  101. package/kits/docs/evolclaw/self-summary.md +29 -0
  102. package/kits/docs/evolclaw/tools.md +25 -0
  103. package/kits/docs/identity/AID_PROFILE_SPEC.md +27 -0
  104. package/kits/docs/identity/PATH_OPS.md +16 -0
  105. package/kits/docs/identity/ROLE_DETAIL.md +20 -0
  106. package/kits/docs/identity/identity-tools.md +26 -0
  107. package/kits/docs/path-registry.md +43 -0
  108. package/kits/eck_manifest.json +95 -0
  109. package/kits/rules/01-overview.md +120 -0
  110. package/kits/rules/02-navigation.md +75 -0
  111. package/kits/rules/03-identity.md +34 -0
  112. package/kits/rules/04-relation.md +49 -0
  113. package/kits/rules/05-venue.md +45 -0
  114. package/kits/rules/06-channel.md +43 -0
  115. package/kits/templates/system-fragments/baseagent.md +2 -0
  116. package/kits/templates/system-fragments/channel.md +10 -0
  117. package/kits/templates/system-fragments/identity.md +12 -0
  118. package/kits/templates/system-fragments/relation.md +9 -0
  119. package/kits/templates/system-fragments/runtime.md +19 -0
  120. package/kits/templates/system-fragments/venue.md +5 -0
  121. package/package.json +10 -6
  122. package/data/evolclaw.sample.json +0 -60
  123. package/dist/agents/templates.js +0 -122
  124. package/dist/channels/aun-ops.js +0 -275
  125. package/dist/cli.js +0 -2178
  126. package/dist/config.js +0 -591
  127. package/dist/core/agent-registry.js +0 -450
  128. package/dist/core/evolagent-schema.js +0 -72
  129. package/dist/core/message/stream-flusher.js +0 -238
  130. package/dist/core/message/thought-emitter.js +0 -162
  131. package/dist/core/reload-hooks.js +0 -87
  132. package/dist/prompts/templates.js +0 -122
  133. package/dist/templates/prompts.md +0 -104
  134. package/dist/templates/skills.md +0 -66
  135. package/dist/utils/channel-fingerprint.js +0 -59
  136. package/dist/utils/error-dict.js +0 -63
  137. package/dist/utils/format.js +0 -32
  138. package/dist/utils/init.js +0 -645
  139. package/dist/utils/migrate-project.js +0 -122
  140. package/dist/utils/reload-hooks.js +0 -87
  141. package/dist/utils/stats-collector.js +0 -99
  142. package/dist/utils/upgrade.js +0 -100
@@ -1,6 +1,6 @@
1
1
  import path from 'path';
2
2
  import { logger } from '../../utils/logger.js';
3
- const DEFAULT_AGENT_NAME = '[default]';
3
+ const DEFAULT_AGENT_NAME = '<unknown>';
4
4
  export class MessageQueue {
5
5
  queues = new Map();
6
6
  processing = new Set();
@@ -80,7 +80,8 @@ export class MessageQueue {
80
80
  }
81
81
  const queueKey = this.getQueueKey(sessionKey, projectPath);
82
82
  const agentName = options?.agentName || DEFAULT_AGENT_NAME;
83
- logger.debug(`[Queue] Enqueuing message for ${queueKey} (agent=${agentName})`);
83
+ const isProcessing = this.processing.has(queueKey);
84
+ logger.info(`[Queue] enqueue: key=${queueKey} processing=${isProcessing} queueLen=${this.queues.get(queueKey)?.length ?? 0} agent=${agentName}`);
84
85
  return new Promise((resolve, reject) => {
85
86
  if (!this.queues.has(queueKey)) {
86
87
  this.queues.set(queueKey, []);
@@ -92,7 +93,7 @@ export class MessageQueue {
92
93
  // 单聊:保留中断行为
93
94
  logger.debug(`[Queue] ${queueKey} is processing, triggering interrupt`);
94
95
  this.eventBus?.publish({
95
- type: 'message:interrupted',
96
+ type: 'task:interrupted',
96
97
  sessionId: sessionKey,
97
98
  reason: 'new_message',
98
99
  agentName: this.processingAgent.get(queueKey),
@@ -104,6 +105,12 @@ export class MessageQueue {
104
105
  else {
105
106
  // 群聊:FIFO,不打断
106
107
  logger.debug(`[Queue] ${queueKey} is processing, message queued (FIFO)`);
108
+ this.eventBus?.publish({
109
+ type: 'task:queued',
110
+ channel: message.channel,
111
+ channelId: message.channelId,
112
+ replyContext: message.replyContext,
113
+ });
107
114
  }
108
115
  }
109
116
  else {
@@ -114,7 +121,7 @@ export class MessageQueue {
114
121
  }
115
122
  async processNext(queueKey) {
116
123
  this.processing.add(queueKey);
117
- logger.debug(`[Queue] Processing queue ${queueKey}`);
124
+ logger.info(`[Queue] processNext: start key=${queueKey}`);
118
125
  while (true) {
119
126
  // 等待外部锁释放(/compact, /clear 等快速命令)
120
127
  const lock = this.getExternalLock(queueKey);
@@ -124,7 +131,7 @@ export class MessageQueue {
124
131
  }
125
132
  const queue = this.queues.get(queueKey);
126
133
  if (!queue || queue.length === 0) {
127
- logger.debug(`[Queue] Queue ${queueKey} is empty, stopping`);
134
+ logger.info(`[Queue] processNext: queue empty, releasing key=${queueKey}`);
128
135
  this.processing.delete(queueKey);
129
136
  this.processingAgent.delete(queueKey);
130
137
  this.currentSessionKey = undefined;
@@ -276,7 +283,7 @@ export class MessageQueue {
276
283
  const sessionKey = this.currentSessionKey.split('::')[0];
277
284
  logger.info(`[Queue] Recalled active message ${messageId}, interrupting session ${sessionKey}`);
278
285
  this.eventBus?.publish({
279
- type: 'message:interrupted',
286
+ type: 'task:interrupted',
280
287
  sessionId: sessionKey,
281
288
  reason: 'recalled',
282
289
  agentName: this.processingAgent.get(this.currentSessionKey),
@@ -1,4 +1,7 @@
1
1
  import path from 'path';
2
+ import fs from 'fs';
3
+ import { renderActionAsText } from './interaction-router.js';
4
+ import { buildEnvelope, sendInteractionPayload } from './message/message-processor.js';
2
5
  // 危险命令黑名单(正则表达式)
3
6
  const DANGEROUS_PATTERNS = [
4
7
  // Unix
@@ -87,9 +90,15 @@ export function summarizeToolInput(toolName, input) {
87
90
  return '';
88
91
  const extractors = {
89
92
  'Read': (i) => i.file_path,
90
- 'Edit': (i) => i.file_path,
93
+ 'Edit': (i) => formatEditSummary(i),
91
94
  'Write': (i) => i.file_path,
92
- 'Bash': (i) => i.command?.substring(0, 80),
95
+ 'Bash': (i) => {
96
+ const cmd = i.command?.substring(0, 80) || '';
97
+ const desc = i.description;
98
+ if (desc && cmd)
99
+ return `${cmd} | ${desc}`;
100
+ return cmd || desc;
101
+ },
93
102
  'Grep': (i) => `pattern: ${i.pattern}`,
94
103
  'Glob': (i) => `pattern: ${i.pattern}`,
95
104
  'Agent': (i) => i.description || i.prompt?.substring(0, 80),
@@ -108,6 +117,8 @@ export function summarizeToolInput(toolName, input) {
108
117
  },
109
118
  'TaskCreate': (i) => i.subject || i.description?.substring(0, 80),
110
119
  'TaskUpdate': (i) => i.status ? `${i.taskId} → ${i.status}` : i.taskId,
120
+ 'TaskOutput': (i) => `${i.task_id || '?'}${i.block === false ? ' (non-blocking)' : ''}${i.timeout ? ` timeout=${i.timeout}ms` : ''}`,
121
+ 'TaskStop': (i) => i.task_id || i.shell_id || '?',
111
122
  'NotebookEdit': (i) => i.notebook_path,
112
123
  'WebFetch': (i) => i.url,
113
124
  'WebSearch': (i) => i.query?.substring(0, 80),
@@ -129,6 +140,81 @@ export function summarizeToolInput(toolName, input) {
129
140
  || input.url
130
141
  || '';
131
142
  }
143
+ /** 为 Edit 工具生成 diff 风格摘要 */
144
+ function formatEditSummary(input) {
145
+ const filePath = input.file_path || '';
146
+ const oldStr = typeof input.old_string === 'string' ? input.old_string : '';
147
+ const newStr = typeof input.new_string === 'string' ? input.new_string : '';
148
+ if (!oldStr && !newStr)
149
+ return filePath;
150
+ const MAX_DIFF_LINES = 14;
151
+ const oldLines = oldStr.split('\n');
152
+ const newLines = newStr.split('\n');
153
+ // 尝试从文件中定位 old_string 的起始行号
154
+ let startLine = 0; // 0-based; 0 means unknown
155
+ if (filePath && oldStr) {
156
+ try {
157
+ const content = fs.readFileSync(filePath, 'utf-8');
158
+ const idx = content.indexOf(oldStr);
159
+ if (idx >= 0) {
160
+ startLine = content.slice(0, idx).split('\n').length; // 1-based
161
+ }
162
+ }
163
+ catch {
164
+ // 文件不可读,行号留空
165
+ }
166
+ }
167
+ const diffLines = [];
168
+ // 找公共前缀行数
169
+ let prefixLen = 0;
170
+ while (prefixLen < oldLines.length && prefixLen < newLines.length && oldLines[prefixLen] === newLines[prefixLen]) {
171
+ prefixLen++;
172
+ }
173
+ // 找公共后缀行数
174
+ let suffixLen = 0;
175
+ while (suffixLen < oldLines.length - prefixLen &&
176
+ suffixLen < newLines.length - prefixLen &&
177
+ oldLines[oldLines.length - 1 - suffixLen] === newLines[newLines.length - 1 - suffixLen]) {
178
+ suffixLen++;
179
+ }
180
+ const CONTEXT = 2;
181
+ // 计算行号宽度(用于对齐)
182
+ const maxLineNo = startLine > 0 ? startLine + oldLines.length - 1 : 0;
183
+ const newMaxLineNo = startLine > 0 ? startLine + prefixLen + (newLines.length - suffixLen - prefixLen) - 1 : 0;
184
+ const padWidth = startLine > 0 ? Math.max(maxLineNo, newMaxLineNo).toString().length : 0;
185
+ // 格式化一行:行号 + 标记 + 内容
186
+ // 使用 Unicode 符号避免飞书 Markdown 将 "- " 解析为列表
187
+ const fmtLine = (lineNo, marker, text) => {
188
+ if (startLine > 0) {
189
+ return `${lineNo.toString().padStart(padWidth)} ${marker} ${text}`;
190
+ }
191
+ return `${marker} ${text}`;
192
+ };
193
+ // 上下文前缀(最多 CONTEXT 行)
194
+ const ctxStart = Math.max(0, prefixLen - CONTEXT);
195
+ for (let i = ctxStart; i < prefixLen; i++) {
196
+ diffLines.push(fmtLine(startLine + i, ' ', oldLines[i]));
197
+ }
198
+ // 删除行
199
+ const removedEnd = oldLines.length - suffixLen;
200
+ for (let i = prefixLen; i < removedEnd && diffLines.length < MAX_DIFF_LINES; i++) {
201
+ diffLines.push(fmtLine(startLine + i, '−', oldLines[i]));
202
+ }
203
+ // 新增行(行号从 prefixLen 位置开始递增)
204
+ const addedEnd = newLines.length - suffixLen;
205
+ for (let i = prefixLen; i < addedEnd && diffLines.length < MAX_DIFF_LINES; i++) {
206
+ diffLines.push(fmtLine(startLine + i, '+', newLines[i]));
207
+ }
208
+ // 上下文后缀(最多 CONTEXT 行)
209
+ const ctxEnd = Math.min(oldLines.length, removedEnd + CONTEXT);
210
+ for (let i = removedEnd; i < ctxEnd && diffLines.length < MAX_DIFF_LINES + 2; i++) {
211
+ diffLines.push(fmtLine(startLine + i, ' ', oldLines[i]));
212
+ }
213
+ if (diffLines.length > MAX_DIFF_LINES + 2) {
214
+ diffLines.splice(MAX_DIFF_LINES, diffLines.length, ' ...');
215
+ }
216
+ return `${filePath}\n\`\`\`\n${diffLines.join('\n')}\n\`\`\``;
217
+ }
132
218
  export class PermissionGateway {
133
219
  pending = new Map();
134
220
  timeout = 5 * 60 * 1000;
@@ -192,29 +278,48 @@ export class PermissionGateway {
192
278
  },
193
279
  channelId: context?.channelId || '',
194
280
  sessionId,
281
+ initiatorId: context?.userId,
282
+ fallback: { command: 'perm' },
195
283
  };
196
- // 尝试富交互
284
+ // 尝试富交互(走统一 adapter.send 入口)
197
285
  let interactionSent = false;
198
- if (context?.adapter?.sendInteraction && context.channelId) {
286
+ if (context?.adapter && context.channelId) {
199
287
  try {
200
- const result = await context.adapter.sendInteraction(context.channelId, interaction, context.replyContext);
288
+ const envelope = buildEnvelope({
289
+ taskId: context.taskId,
290
+ channel: context.channel ?? context.adapter.channelName,
291
+ channelId: context.channelId,
292
+ agentName: context.agentName,
293
+ chatmode: context.chatmode,
294
+ replyContext: context.replyContext,
295
+ });
296
+ const fallbackText = `🔐 权限请求 - ${toolName}\n${displaySummary}${reasonLine}\n回复 /perm allow 同意 / /perm always 始终允许 / /perm deny 拒绝`;
297
+ const result = await sendInteractionPayload(context.adapter, envelope, interaction, fallbackText, context.replyContext);
201
298
  interactionSent = !!result;
202
299
  }
203
300
  catch (err) {
204
- // sendInteraction 失败,fallback 到文本
301
+ // sendInteractionPayload 已内部捕获,但保险起见再 try/catch
205
302
  }
206
303
  }
207
304
  // fallback 到文本
208
305
  if (!interactionSent) {
209
- await sendPrompt(`🔐 权限请求\n工具:${toolName}\n操作:${displaySummary}${reasonLine}\n\n回复 /perm allow 本次允许 | always 始终允许 | deny 拒绝`);
306
+ await sendPrompt(renderActionAsText(interaction));
210
307
  }
211
308
  return new Promise((resolve) => {
212
- this.pending.set(requestId, { sessionId, toolName, resolve, timer: setTimeout(() => { }, 0) });
213
- // 如果发了交互卡片,同时注册到 InteractionRouter
214
- if (interactionSent && context?.interactionRouter) {
309
+ const timer = setTimeout(() => {
310
+ const pending = this.pending.get(requestId);
311
+ if (!pending)
312
+ return;
313
+ this.pending.delete(requestId);
314
+ this.eventBus?.publish({ type: 'permission:timeout', sessionId, requestId, toolName });
315
+ pending.resolve('deny');
316
+ }, this.timeout);
317
+ this.pending.set(requestId, { sessionId, toolName, resolve, timer });
318
+ // 注册到 InteractionRouter(卡片和文本降级都注册,统一路由)
319
+ if (context?.interactionRouter) {
215
320
  context.interactionRouter.register(requestId, sessionId, (action) => {
216
321
  this.resolvePermission(sessionId, requestId, action);
217
- });
322
+ }, { initiatorId: context?.userId, fallbackCommand: 'perm' });
218
323
  }
219
324
  });
220
325
  }
@@ -3,12 +3,31 @@
3
3
  *
4
4
  * Reads Codex thread data from ~/.codex/state_*.sqlite (read-only)
5
5
  * and Codex rollout JSONL files for detailed session info.
6
+ *
7
+ * `node:sqlite` 是 Node 22.5+ 的实验性内置模块。低版本 Node 上懒加载会失败,
8
+ * adapter 自动降级到只读 rollout JSONL —— `checkExists/getFileInfo/readFirstMessage/
9
+ * readLastUserMessage` 仍可工作;`scanCliSessions/listSdkSessions` 因依赖 DB 索引
10
+ * 会返回空数组。
6
11
  */
7
- import { DatabaseSync } from 'node:sqlite';
12
+ import { createRequire } from 'module';
8
13
  import { logger } from '../../../utils/logger.js';
9
14
  import path from 'path';
10
15
  import fs from 'fs';
11
16
  import os from 'os';
17
+ const requireFromHere = createRequire(import.meta.url);
18
+ let sqliteModule; // undefined = not tried, null = unavailable
19
+ function loadSqlite() {
20
+ if (sqliteModule !== undefined)
21
+ return sqliteModule;
22
+ try {
23
+ sqliteModule = requireFromHere('node:sqlite');
24
+ }
25
+ catch (e) {
26
+ logger.warn(`[CodexAdapter] node:sqlite unavailable (Node < 22.5?): ${e?.message || e}. Codex session listing falls back to rollout JSONL.`);
27
+ sqliteModule = null;
28
+ }
29
+ return sqliteModule;
30
+ }
12
31
  export class CodexSessionFileAdapter {
13
32
  agentId = 'codex';
14
33
  db = null;
@@ -39,11 +58,14 @@ export class CodexSessionFileAdapter {
39
58
  if (this.dbInitialized)
40
59
  return this.db;
41
60
  this.dbInitialized = true;
61
+ const sqlite = loadSqlite();
62
+ if (!sqlite)
63
+ return null;
42
64
  const dbPath = this.resolveStateDbPath();
43
65
  if (!dbPath)
44
66
  return null;
45
67
  try {
46
- this.db = new DatabaseSync(dbPath, { readOnly: true });
68
+ this.db = new sqlite.DatabaseSync(dbPath, { readOnly: true });
47
69
  logger.debug(`[CodexAdapter] Opened state DB: ${dbPath}`);
48
70
  }
49
71
  catch (error) {
@@ -0,0 +1,230 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ // 文件系统非法字符(Windows 最严格):< > : " / \ | ? *
4
+ // 还有控制字符 0x00-0x1F。我们把这些字符编码为 %XX(hex 大写)。
5
+ // `%` 本身也要转义为 %25,保证可逆。
6
+ const UNSAFE_CHARS_RE = /[<>:"/\\|?*\x00-\x1F%]/g;
7
+ function encodeSegment(s) {
8
+ return s.replace(UNSAFE_CHARS_RE, ch => '%' + ch.charCodeAt(0).toString(16).toUpperCase().padStart(2, '0'));
9
+ }
10
+ function decodeSegment(s) {
11
+ return s.replace(/%([0-9A-Fa-f]{2})/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)));
12
+ }
13
+ /**
14
+ * 计算 chat 目录的完整路径。
15
+ * - AUN:sessionsDir/aun/<urlEncode(selfId)>/<urlEncode(channelId)>/
16
+ * - 其它:sessionsDir/<channelType>/<urlEncode(channelId)>/
17
+ *
18
+ * 注:channelType 自身不编码(限定枚举值,不含非法字符)。
19
+ */
20
+ export function chatDirPath(sessionsDir, channelType, channelId, selfId) {
21
+ if (channelType === 'aun') {
22
+ const self = selfId || '_unknown';
23
+ return path.join(sessionsDir, 'aun', encodeSegment(self), encodeSegment(channelId));
24
+ }
25
+ return path.join(sessionsDir, channelType, encodeSegment(channelId));
26
+ }
27
+ /** 解码目录段(用于扫描时把目录名还原为原始 channelId/selfId) */
28
+ export function decodeDirSegment(seg) {
29
+ return decodeSegment(seg);
30
+ }
31
+ export function generateSessionId(now) {
32
+ const ts = now ?? Date.now();
33
+ const d = new Date(ts);
34
+ const yyyy = d.getFullYear();
35
+ const mm = String(d.getMonth() + 1).padStart(2, '0');
36
+ const dd = String(d.getDate()).padStart(2, '0');
37
+ return `meta_${yyyy}${mm}${dd}_${ts}`;
38
+ }
39
+ export function formatTimestamp(epochMs) {
40
+ const d = new Date(epochMs);
41
+ const yyyy = d.getFullYear();
42
+ const mo = String(d.getMonth() + 1).padStart(2, '0');
43
+ const dd = String(d.getDate()).padStart(2, '0');
44
+ const hh = String(d.getHours()).padStart(2, '0');
45
+ const mi = String(d.getMinutes()).padStart(2, '0');
46
+ const ss = String(d.getSeconds()).padStart(2, '0');
47
+ return `${yyyy}-${mo}-${dd} ${hh}:${mi}:${ss}`;
48
+ }
49
+ export function atomicWriteJson(filePath, data) {
50
+ const tmpPath = filePath + '.tmp';
51
+ const content = JSON.stringify(data, null, 2) + '\n';
52
+ const fd = fs.openSync(tmpPath, 'w');
53
+ fs.writeSync(fd, content);
54
+ fs.fsyncSync(fd);
55
+ fs.closeSync(fd);
56
+ try {
57
+ fs.unlinkSync(filePath);
58
+ }
59
+ catch (e) {
60
+ if (e.code !== 'ENOENT')
61
+ throw e;
62
+ }
63
+ fs.renameSync(tmpPath, filePath);
64
+ }
65
+ export function appendJsonl(filePath, record) {
66
+ const line = JSON.stringify(record) + '\n';
67
+ const fd = fs.openSync(filePath, 'a');
68
+ fs.writeSync(fd, line);
69
+ fs.fsyncSync(fd);
70
+ fs.closeSync(fd);
71
+ }
72
+ export function readJsonFile(filePath) {
73
+ try {
74
+ const content = fs.readFileSync(filePath, 'utf-8');
75
+ return JSON.parse(content);
76
+ }
77
+ catch (e) {
78
+ if (e.code === 'ENOENT')
79
+ return undefined;
80
+ if (e instanceof SyntaxError)
81
+ return undefined;
82
+ throw e;
83
+ }
84
+ }
85
+ export function readLastJsonlLine(filePath) {
86
+ try {
87
+ const content = fs.readFileSync(filePath, 'utf-8');
88
+ const lines = content.trimEnd().split('\n');
89
+ for (let i = lines.length - 1; i >= 0; i--) {
90
+ const line = lines[i].trim();
91
+ if (!line)
92
+ continue;
93
+ try {
94
+ return JSON.parse(line);
95
+ }
96
+ catch {
97
+ continue;
98
+ }
99
+ }
100
+ return undefined;
101
+ }
102
+ catch (e) {
103
+ if (e.code === 'ENOENT')
104
+ return undefined;
105
+ throw e;
106
+ }
107
+ }
108
+ export function readAllJsonlLines(filePath) {
109
+ try {
110
+ const content = fs.readFileSync(filePath, 'utf-8');
111
+ const results = [];
112
+ for (const line of content.split('\n')) {
113
+ const trimmed = line.trim();
114
+ if (!trimmed)
115
+ continue;
116
+ try {
117
+ results.push(JSON.parse(trimmed));
118
+ }
119
+ catch { /* skip corrupt line */ }
120
+ }
121
+ return results;
122
+ }
123
+ catch (e) {
124
+ if (e.code === 'ENOENT')
125
+ return [];
126
+ throw e;
127
+ }
128
+ }
129
+ /**
130
+ * 扫描所有 chat 目录。
131
+ * 顶层是 channelType 目录;aun 下面再有一层 selfId 目录。
132
+ * 返回每个 chat 的:channelType、selfId(仅 aun 有)、channelId、dirPath。
133
+ */
134
+ export function scanChatDirs(sessionsDir) {
135
+ const results = [];
136
+ let typeEntries;
137
+ try {
138
+ typeEntries = fs.readdirSync(sessionsDir, { withFileTypes: true });
139
+ }
140
+ catch (e) {
141
+ if (e.code === 'ENOENT')
142
+ return [];
143
+ throw e;
144
+ }
145
+ for (const typeEntry of typeEntries) {
146
+ if (!typeEntry.isDirectory())
147
+ continue;
148
+ const channelType = typeEntry.name;
149
+ const typeDir = path.join(sessionsDir, channelType);
150
+ if (channelType === 'aun') {
151
+ // aun 下还有一层 selfId
152
+ let selfEntries;
153
+ try {
154
+ selfEntries = fs.readdirSync(typeDir, { withFileTypes: true });
155
+ }
156
+ catch {
157
+ continue;
158
+ }
159
+ for (const selfEntry of selfEntries) {
160
+ if (!selfEntry.isDirectory())
161
+ continue;
162
+ const selfDir = path.join(typeDir, selfEntry.name);
163
+ let chatEntries;
164
+ try {
165
+ chatEntries = fs.readdirSync(selfDir, { withFileTypes: true });
166
+ }
167
+ catch {
168
+ continue;
169
+ }
170
+ for (const chatEntry of chatEntries) {
171
+ if (!chatEntry.isDirectory())
172
+ continue;
173
+ results.push({
174
+ channelType,
175
+ selfId: decodeSegment(selfEntry.name),
176
+ channelId: decodeSegment(chatEntry.name),
177
+ dirPath: path.join(selfDir, chatEntry.name),
178
+ });
179
+ }
180
+ }
181
+ }
182
+ else {
183
+ // 通用 channel:sessionsDir/{channelType}/{encodedChannelId}/
184
+ let chatEntries;
185
+ try {
186
+ chatEntries = fs.readdirSync(typeDir, { withFileTypes: true });
187
+ }
188
+ catch {
189
+ continue;
190
+ }
191
+ for (const chatEntry of chatEntries) {
192
+ if (!chatEntry.isDirectory())
193
+ continue;
194
+ results.push({
195
+ channelType,
196
+ selfId: null,
197
+ channelId: decodeSegment(chatEntry.name),
198
+ dirPath: path.join(typeDir, chatEntry.name),
199
+ });
200
+ }
201
+ }
202
+ }
203
+ return results;
204
+ }
205
+ export function scanMetaFiles(chatDir) {
206
+ try {
207
+ const entries = fs.readdirSync(chatDir);
208
+ return entries
209
+ .filter(f => f.startsWith('meta_') && f.endsWith('.jsonl'))
210
+ .sort();
211
+ }
212
+ catch (e) {
213
+ if (e.code === 'ENOENT')
214
+ return [];
215
+ throw e;
216
+ }
217
+ }
218
+ export function ensureChatDir(sessionsDir, channelType, channelId, selfId) {
219
+ const dir = chatDirPath(sessionsDir, channelType, channelId, selfId);
220
+ fs.mkdirSync(dir, { recursive: true });
221
+ fs.mkdirSync(path.join(dir, '_threads'), { recursive: true });
222
+ fs.mkdirSync(path.join(dir, '_trash'), { recursive: true });
223
+ return dir;
224
+ }
225
+ export function readThreadIndex(chatDir) {
226
+ return readJsonFile(path.join(chatDir, '_threads', 'thread-index.json')) || {};
227
+ }
228
+ export function writeThreadIndex(chatDir, index) {
229
+ atomicWriteJson(path.join(chatDir, '_threads', 'thread-index.json'), index);
230
+ }