claude-simple 0.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 (50) hide show
  1. package/README.md +225 -0
  2. package/client.js +139 -0
  3. package/dist/addon-fit.min.js +2 -0
  4. package/dist/app.0.1.0.min.js +825 -0
  5. package/dist/highlight.min.css +10 -0
  6. package/dist/highlight.min.js +1244 -0
  7. package/dist/index.html +16 -0
  8. package/dist/marked.min.js +6 -0
  9. package/dist/style.0.1.0.min.css +1050 -0
  10. package/dist/xterm.min.css +209 -0
  11. package/dist/xterm.min.js +2 -0
  12. package/gateway.js +349 -0
  13. package/package.json +41 -0
  14. package/public/addon-fit.min.js +2 -0
  15. package/public/agent-panel.js +1079 -0
  16. package/public/api.js +78 -0
  17. package/public/app.js +95 -0
  18. package/public/base.css +4 -0
  19. package/public/cache.js +27 -0
  20. package/public/chat.js +1087 -0
  21. package/public/files.js +476 -0
  22. package/public/git.js +130 -0
  23. package/public/index.html +61 -0
  24. package/public/lib.js +2 -0
  25. package/public/router.js +71 -0
  26. package/public/settings.js +90 -0
  27. package/public/shell/boot.js +159 -0
  28. package/public/shell/dashboard.js +269 -0
  29. package/public/shell/hub.js +192 -0
  30. package/public/shell/layout.js +195 -0
  31. package/public/shell/notes.js +323 -0
  32. package/public/shell/session-context.js +19 -0
  33. package/public/shell/session-list.js +442 -0
  34. package/public/shell/session-nav.js +299 -0
  35. package/public/shell/templates.js +342 -0
  36. package/public/shell/util.js +63 -0
  37. package/public/shell.js +589 -0
  38. package/public/state.js +362 -0
  39. package/public/style.css +1048 -0
  40. package/public/terminal.js +177 -0
  41. package/public/xterm.min.css +209 -0
  42. package/public/xterm.min.js +2 -0
  43. package/server.js +12 -0
  44. package/src/agents/claude.js +58 -0
  45. package/src/agents/codex.js +284 -0
  46. package/src/agents/grok.js +318 -0
  47. package/src/agents/meta-agent.js +1952 -0
  48. package/src/agents/models.js +339 -0
  49. package/src/agents/sessions.js +1508 -0
  50. package/src/app.js +1282 -0
@@ -0,0 +1,1952 @@
1
+ /**
2
+ * meta-agent.js — Built-in OpenAI-compatible agent with tools.
3
+ *
4
+ * Capabilities:
5
+ * - Activity / project Q&A (sessions, messages, notes, git, files)
6
+ * - Daily work reports (manual or scheduled via client)
7
+ * - Code gen + run (run_command / write_file / read_file)
8
+ * - VLM via analyze_images only (user pastes → [imgN](path) text refs)
9
+ * - VLM multi-turn threads (thread_id) reuse images + prior Q&A
10
+ *
11
+ * Config: .ai_config.json (API keys stay server-side).
12
+ * Reports: .ai_reports.json
13
+ * Chat sessions (same sessionId owns chat + VLM):
14
+ * .ai/sessions/index.json
15
+ * .ai/sessions/{sessionId}/
16
+ * meta.json
17
+ * messages.jsonl # chat turns (one JSON object per line)
18
+ * vlm/{threadId}.jsonl # each image-analysis thread is its own jsonl
19
+ * # (multi-image / multi-thread / multi-turn)
20
+ * Legacy layouts auto-migrated.
21
+ */
22
+
23
+ import { promises as fs } from 'fs';
24
+ import fsSync from 'fs';
25
+ import path from 'path';
26
+ import os from 'os';
27
+ import crypto from 'crypto';
28
+ import { spawn } from 'child_process';
29
+
30
+ export const AI_DEFAULTS = {
31
+ url: 'https://api.deepseek.com/chat/completions',
32
+ model: 'deepseek-chat',
33
+ vlm_url: '',
34
+ vlm_model: '',
35
+ temperature: 0.3,
36
+ auto_report: false,
37
+ auto_report_hour: 18,
38
+ auto_report_days: 1,
39
+ };
40
+
41
+ export const DEFAULT_SYSTEM = `你是 Claude Simple UI 的内置工作助手(Meta Agent)。你能访问本机上的 Claude / Codex / Grok 会话、项目笔记、Git 与文件,并生成「我做了啥」的工作报告。
42
+
43
+ 规则:
44
+ 1. 需要会话 id / 项目路径时,先 list_projects 或 search_activity / list_sessions,再深入 get_session_summary。
45
+ 2. 回答要具体:引用项目路径、会话标题、时间范围;不确定就再查。
46
+ 3. 写日报/周报用 generate_activity_digest 拉快照,再组织成清晰 Markdown(亮点、按项目、未完成、建议)。
47
+ 4. 改文件或跑命令前说明意图;危险命令拒绝(rm -rf /、格式化磁盘等)。
48
+ 5. 图片约定(重要):
49
+ - 用户粘贴图片后,正文会出现 [img1](/path/to.png) 或 ![name](/path) 形式的**路径引用**,不会直接把像素发给你。
50
+ - 需要看图/OCR/分析时,调用 analyze_images,传入 image_paths(从引用里提取绝对路径)和 prompt。
51
+ - 同一批图的追问:传入上次返回的 thread_id + 新 prompt,可复用图片与历史问答,不必重复传路径(也可继续传 paths 以补图)。
52
+ - 不要假装已经看到图片;没调用工具就说「根据路径无法看见内容」。
53
+ 6. 需要验证时:write_file 写代码 → run_command 执行 → 根据输出迭代。
54
+ 7. 回复简洁有结构,中文优先(用户用中文时)。`;
55
+
56
+ export const REPORT_SYSTEM = `你是工作复盘助手。根据提供的 JSON 活动快照,写一份清晰的工作报告(Markdown)。
57
+ 结构建议:
58
+ ## 概览(时段、会话数、项目数、token 粗估)
59
+ ## 今日/本期亮点(3-8 条)
60
+ ## 按项目
61
+ ### 项目名
62
+ - 做了什么(结合会话标题与摘录)
63
+ - 未竟事项(若有)
64
+ ## 观察与建议
65
+ 只基于数据,不编造。`;
66
+
67
+ const MUTATING = new Set([
68
+ 'set_project_notes', 'write_file', 'run_command', 'save_report',
69
+ ]);
70
+
71
+ const TOOLS = [
72
+ {
73
+ type: 'function',
74
+ function: {
75
+ name: 'list_sessions',
76
+ description: '列出最近 agent 会话(Claude/Codex/Grok)元数据',
77
+ parameters: {
78
+ type: 'object',
79
+ properties: {
80
+ days: { type: 'integer', description: '只看最近 N 天,0=全部,默认 14' },
81
+ agent: { type: 'string', enum: ['claude', 'codex', 'grok'], description: '可选过滤' },
82
+ cwd: { type: 'string', description: '按工作目录过滤' },
83
+ limit: { type: 'integer', description: '最多返回条数,默认 40' },
84
+ },
85
+ },
86
+ },
87
+ },
88
+ {
89
+ type: 'function',
90
+ function: {
91
+ name: 'list_projects',
92
+ description: '按项目(cwd)聚合会话,看最近在哪些项目工作',
93
+ parameters: {
94
+ type: 'object',
95
+ properties: {
96
+ days: { type: 'integer', description: '默认 14' },
97
+ agent: { type: 'string' },
98
+ limit: { type: 'integer', description: '默认 30' },
99
+ },
100
+ },
101
+ },
102
+ },
103
+ {
104
+ type: 'function',
105
+ function: {
106
+ name: 'search_activity',
107
+ description: '搜索最近活动(元数据 + 可选内容窥视)',
108
+ parameters: {
109
+ type: 'object',
110
+ properties: {
111
+ q: { type: 'string', description: '关键词' },
112
+ days: { type: 'integer', description: '默认 14' },
113
+ agent: { type: 'string' },
114
+ limit: { type: 'integer', description: '默认 40' },
115
+ deep: { type: 'boolean', description: '是否深读会话内容,默认 true' },
116
+ },
117
+ },
118
+ },
119
+ },
120
+ {
121
+ type: 'function',
122
+ function: {
123
+ name: 'get_session_summary',
124
+ description: '读取某会话消息摘要(用户话 + assistant 摘录)',
125
+ parameters: {
126
+ type: 'object',
127
+ required: ['sessionId'],
128
+ properties: {
129
+ sessionId: { type: 'string' },
130
+ agent: { type: 'string' },
131
+ maxMessages: { type: 'integer', description: '默认 30' },
132
+ },
133
+ },
134
+ },
135
+ },
136
+ {
137
+ type: 'function',
138
+ function: {
139
+ name: 'get_project_notes',
140
+ description: '读取项目 goal / notes',
141
+ parameters: {
142
+ type: 'object',
143
+ required: ['cwd'],
144
+ properties: { cwd: { type: 'string' } },
145
+ },
146
+ },
147
+ },
148
+ {
149
+ type: 'function',
150
+ function: {
151
+ name: 'set_project_notes',
152
+ description: '更新项目 goal 和/或 notes',
153
+ parameters: {
154
+ type: 'object',
155
+ required: ['cwd'],
156
+ properties: {
157
+ cwd: { type: 'string' },
158
+ goal: { type: 'string' },
159
+ notes: { type: 'string' },
160
+ },
161
+ },
162
+ },
163
+ },
164
+ {
165
+ type: 'function',
166
+ function: {
167
+ name: 'git_status',
168
+ description: '查看目录 git status',
169
+ parameters: {
170
+ type: 'object',
171
+ required: ['cwd'],
172
+ properties: { cwd: { type: 'string' } },
173
+ },
174
+ },
175
+ },
176
+ {
177
+ type: 'function',
178
+ function: {
179
+ name: 'git_log',
180
+ description: '查看 git 最近提交',
181
+ parameters: {
182
+ type: 'object',
183
+ required: ['cwd'],
184
+ properties: {
185
+ cwd: { type: 'string' },
186
+ limit: { type: 'integer', description: '默认 15' },
187
+ },
188
+ },
189
+ },
190
+ },
191
+ {
192
+ type: 'function',
193
+ function: {
194
+ name: 'list_files',
195
+ description: '列出目录内容(绝对路径)',
196
+ parameters: {
197
+ type: 'object',
198
+ required: ['path'],
199
+ properties: {
200
+ path: { type: 'string' },
201
+ max: { type: 'integer', description: '默认 80' },
202
+ },
203
+ },
204
+ },
205
+ },
206
+ {
207
+ type: 'function',
208
+ function: {
209
+ name: 'read_file',
210
+ description: '读取文本文件(有大小上限)',
211
+ parameters: {
212
+ type: 'object',
213
+ required: ['path'],
214
+ properties: {
215
+ path: { type: 'string' },
216
+ maxBytes: { type: 'integer', description: '默认 120000' },
217
+ },
218
+ },
219
+ },
220
+ },
221
+ {
222
+ type: 'function',
223
+ function: {
224
+ name: 'write_file',
225
+ description: '写入/创建文本文件(用于生成代码脚本等)',
226
+ parameters: {
227
+ type: 'object',
228
+ required: ['path', 'content'],
229
+ properties: {
230
+ path: { type: 'string' },
231
+ content: { type: 'string' },
232
+ },
233
+ },
234
+ },
235
+ },
236
+ {
237
+ type: 'function',
238
+ function: {
239
+ name: 'run_command',
240
+ description: '在指定目录执行 shell 命令(有超时与危险命令拦截)',
241
+ parameters: {
242
+ type: 'object',
243
+ required: ['command'],
244
+ properties: {
245
+ command: { type: 'string' },
246
+ cwd: { type: 'string', description: '工作目录,默认 home' },
247
+ timeoutMs: { type: 'integer', description: '默认 60000,最大 180000' },
248
+ },
249
+ },
250
+ },
251
+ },
252
+ {
253
+ type: 'function',
254
+ function: {
255
+ name: 'analyze_images',
256
+ description:
257
+ '用 VLM 分析图片。用户消息里的 [imgN](绝对路径) / ![name](路径) 需提取路径后调用。' +
258
+ '首次分析传 image_paths + prompt,返回 thread_id;追问同一批图时传 thread_id + 新 prompt 即可复用上下文。',
259
+ parameters: {
260
+ type: 'object',
261
+ required: ['prompt'],
262
+ properties: {
263
+ image_paths: {
264
+ type: 'array',
265
+ items: { type: 'string' },
266
+ description: '磁盘绝对路径列表;首次必填(除非 thread_id 已绑定图片)',
267
+ },
268
+ images: {
269
+ type: 'array',
270
+ items: { type: 'string' },
271
+ description: '同 image_paths(兼容别名)',
272
+ },
273
+ prompt: { type: 'string', description: '本次问题/分析要求' },
274
+ thread_id: {
275
+ type: 'string',
276
+ description: '已有 VLM 线程 id,用于追问;省略则新建线程',
277
+ },
278
+ },
279
+ },
280
+ },
281
+ },
282
+ {
283
+ type: 'function',
284
+ function: {
285
+ name: 'generate_activity_digest',
286
+ description: '生成结构化活动快照(供写报告/问答)',
287
+ parameters: {
288
+ type: 'object',
289
+ properties: {
290
+ days: { type: 'integer', description: '默认 1(今天起算)' },
291
+ agent: { type: 'string' },
292
+ includeMessages: { type: 'boolean', description: '是否抽会话内容,默认 true' },
293
+ },
294
+ },
295
+ },
296
+ },
297
+ {
298
+ type: 'function',
299
+ function: {
300
+ name: 'save_report',
301
+ description: '把生成好的报告存盘',
302
+ parameters: {
303
+ type: 'object',
304
+ required: ['markdown'],
305
+ properties: {
306
+ day: { type: 'string', description: 'YYYY-MM-DD,默认今天' },
307
+ markdown: { type: 'string' },
308
+ title: { type: 'string' },
309
+ },
310
+ },
311
+ },
312
+ },
313
+ {
314
+ type: 'function',
315
+ function: {
316
+ name: 'list_reports',
317
+ description: '列出已保存的工作报告',
318
+ parameters: {
319
+ type: 'object',
320
+ properties: { limit: { type: 'integer' } },
321
+ },
322
+ },
323
+ },
324
+ {
325
+ type: 'function',
326
+ function: {
327
+ name: 'get_report',
328
+ description: '读取某日报告',
329
+ parameters: {
330
+ type: 'object',
331
+ properties: { day: { type: 'string' } },
332
+ },
333
+ },
334
+ },
335
+ ];
336
+
337
+ function expandPath(p) {
338
+ if (!p) return p;
339
+ return String(p).replace(/^~(?=$|[/\\])/, os.homedir());
340
+ }
341
+
342
+ function pad(n) {
343
+ return String(n).padStart(2, '0');
344
+ }
345
+
346
+ export function todayKey(d = new Date()) {
347
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
348
+ }
349
+
350
+ function dayStartMs(daysAgo = 0) {
351
+ const d = new Date();
352
+ d.setHours(0, 0, 0, 0);
353
+ d.setDate(d.getDate() - daysAgo);
354
+ return d.getTime();
355
+ }
356
+
357
+ function shortTitle(s, n = 80) {
358
+ const t = String(s || '').replace(/\s+/g, ' ').trim();
359
+ return t.length > n ? t.slice(0, n) + '…' : t;
360
+ }
361
+
362
+ function extractTextParts(content) {
363
+ if (content == null) return '';
364
+ if (typeof content === 'string') return content;
365
+ if (Array.isArray(content)) {
366
+ return content
367
+ .map((p) => {
368
+ if (typeof p === 'string') return p;
369
+ if (p?.type === 'text') return p.text || '';
370
+ if (p?.text) return p.text;
371
+ return '';
372
+ })
373
+ .filter(Boolean)
374
+ .join('\n');
375
+ }
376
+ if (typeof content === 'object' && content.text) return content.text;
377
+ return '';
378
+ }
379
+
380
+ function dangerousCommand(cmd) {
381
+ const s = String(cmd || '');
382
+ const patterns = [
383
+ /\brm\s+(-[a-zA-Z]*f[a-zA-Z]*\s+)?\/\s*$/,
384
+ /\brm\s+-rf\s+\/(?!\w)/,
385
+ /\bmkfs\b/,
386
+ /\bdd\s+if=.*of=\/dev\//,
387
+ /:\(\)\s*\{\s*:\|:\s*&\s*\}\s*;/,
388
+ /\bshutdown\b/,
389
+ /\breboot\b/,
390
+ />\s*\/dev\/sd[a-z]/,
391
+ /\bcurl\b.*\|\s*(ba)?sh\b/,
392
+ ];
393
+ return patterns.some((re) => re.test(s));
394
+ }
395
+
396
+ export function createMetaAgent(deps) {
397
+ const {
398
+ rootDir,
399
+ listSessions,
400
+ searchActivity,
401
+ loadSessionMessages,
402
+ groupByProject,
403
+ toClientSession,
404
+ getProjectNotesStore,
405
+ saveProjectNotesStore,
406
+ normalizeProjectEntry,
407
+ git,
408
+ isGitRepo,
409
+ claudeConfigDirs,
410
+ codexHome,
411
+ grokHome,
412
+ } = deps;
413
+
414
+ const configFile = path.join(rootDir, '.ai_config.json');
415
+ const reportsFile = path.join(rootDir, '.ai_reports.json');
416
+ const legacySessionsFile = path.join(rootDir, '.ai_sessions.json');
417
+ const aiDir = process.env.AI_DATA_DIR
418
+ ? expandPath(process.env.AI_DATA_DIR)
419
+ : path.join(rootDir, '.ai');
420
+ const sessionsDir = path.join(aiDir, 'sessions');
421
+ /** @deprecated global vlm — only for one-time migration */
422
+ const legacyGlobalVlmDir = path.join(aiDir, 'vlm');
423
+ const sessionsIndexFile = path.join(sessionsDir, 'index.json');
424
+
425
+ const MAX_SESSIONS = 200;
426
+ const MAX_VLM_THREADS_PER_SESSION = 40;
427
+ const VLM_THREAD_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7d on disk
428
+ const ANON_SESSION_ID = '_anonymous';
429
+
430
+ let configCache = null;
431
+ let reportsCache = null;
432
+ /** @type {{ version: number, sessions: Array<{id,title,createdAt,updatedAt,messageCount}> } | null} */
433
+ let sessionsIndexCache = null;
434
+ /** in-memory cache of full sessions (id → session) */
435
+ const sessionBodyCache = new Map();
436
+
437
+ /**
438
+ * VLM threads keyed by `${sessionId}::${threadId}`
439
+ * @type {Map<string, { id: string, sessionId: string, imagePaths: string[], messages: any[], updatedAt: number }>}
440
+ */
441
+ const vlmThreads = new Map();
442
+ let layoutMigrated = false;
443
+
444
+ async function ensureAiDirs() {
445
+ await fs.mkdir(sessionsDir, { recursive: true });
446
+ }
447
+
448
+ async function atomicWriteText(filePath, text) {
449
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
450
+ const tmp = `${filePath}.${process.pid}.${Date.now()}.tmp`;
451
+ await fs.writeFile(tmp, text, 'utf8');
452
+ await fs.rename(tmp, filePath);
453
+ }
454
+
455
+ async function atomicWriteJson(filePath, data) {
456
+ await atomicWriteText(filePath, JSON.stringify(data, null, 2));
457
+ }
458
+
459
+ /** Rewrite array as JSONL (one JSON object per line). */
460
+ async function writeJsonl(filePath, rows) {
461
+ const lines = (rows || []).map((r) => JSON.stringify(r));
462
+ const body = lines.length ? `${lines.join('\n')}\n` : '';
463
+ await atomicWriteText(filePath, body);
464
+ }
465
+
466
+ async function readJsonl(filePath) {
467
+ try {
468
+ const raw = await fs.readFile(filePath, 'utf8');
469
+ if (!raw.trim()) return [];
470
+ const out = [];
471
+ for (const line of raw.split('\n')) {
472
+ const s = line.trim();
473
+ if (!s) continue;
474
+ try {
475
+ out.push(JSON.parse(s));
476
+ } catch { /* skip bad line */ }
477
+ }
478
+ return out;
479
+ } catch {
480
+ return [];
481
+ }
482
+ }
483
+
484
+ function safeId(id) {
485
+ return String(id).replace(/[^a-zA-Z0-9._-]/g, '_');
486
+ }
487
+
488
+ function sessionDir(id) {
489
+ return path.join(sessionsDir, safeId(id));
490
+ }
491
+
492
+ function metaPath(id) {
493
+ return path.join(sessionDir(id), 'meta.json');
494
+ }
495
+
496
+ function messagesJsonlPath(id) {
497
+ return path.join(sessionDir(id), 'messages.jsonl');
498
+ }
499
+
500
+ function vlmDir(sessionId) {
501
+ return path.join(sessionDir(sessionId || ANON_SESSION_ID), 'vlm');
502
+ }
503
+
504
+ /** Per-thread log: sessions/{sessionId}/vlm/{threadId}.jsonl */
505
+ function vlmThreadJsonlPath(sessionId, threadId) {
506
+ return path.join(vlmDir(sessionId), `${safeId(threadId)}.jsonl`);
507
+ }
508
+
509
+ /** @deprecated monolithic session vlm.jsonl */
510
+ function legacySessionVlmJsonl(sessionId) {
511
+ return path.join(sessionDir(sessionId || ANON_SESSION_ID), 'vlm.jsonl');
512
+ }
513
+
514
+ /** @deprecated legacy single-file session */
515
+ function legacySessionJsonPath(id) {
516
+ return path.join(sessionDir(id), 'session.json');
517
+ }
518
+
519
+ function vlmMemKey(sessionId, threadId) {
520
+ return `${sessionId || ANON_SESSION_ID}::${threadId}`;
521
+ }
522
+
523
+ function slimVlmMessages(messages) {
524
+ return (messages || []).map((m) => {
525
+ if (typeof m.content === 'string') return { role: m.role, content: m.content };
526
+ if (Array.isArray(m.content)) {
527
+ return {
528
+ role: m.role,
529
+ content: m.content.map((p) => {
530
+ if (p?.type === 'image_url') {
531
+ return { type: 'image_url', image_url: { url: '__from_path__' } };
532
+ }
533
+ return p;
534
+ }),
535
+ ...(m._paths ? { _paths: m._paths } : {}),
536
+ };
537
+ }
538
+ return { role: m.role, content: m.content };
539
+ });
540
+ }
541
+
542
+ /**
543
+ * Serialize one VLM thread to jsonl lines:
544
+ * {"type":"meta","id","sessionId","imagePaths","updatedAt"}
545
+ * {"type":"message","role","content",...} × N turns
546
+ */
547
+ function vlmThreadToJsonlRows(thread) {
548
+ const sid = thread.sessionId || ANON_SESSION_ID;
549
+ const rows = [{
550
+ type: 'meta',
551
+ id: thread.id,
552
+ sessionId: sid,
553
+ imagePaths: thread.imagePaths || [],
554
+ updatedAt: thread.updatedAt || Date.now(),
555
+ }];
556
+ for (const m of slimVlmMessages(thread.messages)) {
557
+ rows.push({ type: 'message', ...m });
558
+ }
559
+ return rows;
560
+ }
561
+
562
+ function vlmThreadFromJsonlRows(rows, fallbackSessionId, fallbackThreadId) {
563
+ let id = fallbackThreadId;
564
+ let sessionId = fallbackSessionId;
565
+ let imagePaths = [];
566
+ let updatedAt = 0;
567
+ const messages = [];
568
+ for (const row of rows || []) {
569
+ if (!row || typeof row !== 'object') continue;
570
+ if (row.type === 'meta') {
571
+ id = row.id || id;
572
+ sessionId = row.sessionId || sessionId;
573
+ imagePaths = row.imagePaths || imagePaths;
574
+ updatedAt = row.updatedAt || updatedAt;
575
+ continue;
576
+ }
577
+ // full-thread snapshot (legacy single-line objects)
578
+ if (row.type !== 'message' && row.messages && row.id) {
579
+ return {
580
+ id: row.id,
581
+ sessionId: row.sessionId || sessionId,
582
+ imagePaths: row.imagePaths || [],
583
+ messages: row.messages || [],
584
+ updatedAt: row.updatedAt || Date.now(),
585
+ };
586
+ }
587
+ if (row.role) {
588
+ const { type: _t, ...rest } = row;
589
+ messages.push(rest);
590
+ }
591
+ }
592
+ return {
593
+ id,
594
+ sessionId,
595
+ imagePaths,
596
+ messages,
597
+ updatedAt: updatedAt || Date.now(),
598
+ };
599
+ }
600
+
601
+ async function rmrf(p) {
602
+ try {
603
+ await fs.rm(p, { recursive: true, force: true });
604
+ } catch { /* ignore */ }
605
+ }
606
+
607
+ /**
608
+ * Persist full session on disk:
609
+ * sessions/{id}/meta.json + messages.jsonl
610
+ */
611
+ async function writeSessionBundle(row) {
612
+ const id = row.id;
613
+ await fs.mkdir(sessionDir(id), { recursive: true });
614
+ const meta = {
615
+ id: row.id,
616
+ title: row.title || '对话',
617
+ createdAt: row.createdAt,
618
+ updatedAt: row.updatedAt,
619
+ messageCount: Array.isArray(row.messages) ? row.messages.length : (row.messageCount || 0),
620
+ preview: row.preview || previewFromMessages(row.messages) || '',
621
+ model: row.model || null,
622
+ vlmThreadIds: row.vlmThreadIds || [],
623
+ };
624
+ await atomicWriteJson(metaPath(id), meta);
625
+ await writeJsonl(messagesJsonlPath(id), row.messages || []);
626
+ // drop legacy monolith if present
627
+ try { await fs.unlink(legacySessionJsonPath(id)); } catch { /* ignore */ }
628
+ return { ...row, ...meta, messages: row.messages || [] };
629
+ }
630
+
631
+ async function readSessionBundle(id) {
632
+ // preferred: meta + messages.jsonl
633
+ try {
634
+ const meta = JSON.parse(await fs.readFile(metaPath(id), 'utf8'));
635
+ const messages = await readJsonl(messagesJsonlPath(id));
636
+ return {
637
+ id: meta.id || id,
638
+ title: meta.title || '对话',
639
+ createdAt: meta.createdAt,
640
+ updatedAt: meta.updatedAt,
641
+ model: meta.model || null,
642
+ vlmThreadIds: meta.vlmThreadIds || [],
643
+ preview: meta.preview || '',
644
+ messages,
645
+ };
646
+ } catch { /* fall through */ }
647
+
648
+ // legacy: session.json
649
+ try {
650
+ const row = JSON.parse(await fs.readFile(legacySessionJsonPath(id), 'utf8'));
651
+ if (row?.id || row?.messages) {
652
+ const bundle = {
653
+ id: row.id || id,
654
+ title: row.title || '对话',
655
+ messages: row.messages || [],
656
+ createdAt: row.createdAt || row.updatedAt || Date.now(),
657
+ updatedAt: row.updatedAt || Date.now(),
658
+ model: row.model || null,
659
+ vlmThreadIds: row.vlmThreadIds || [],
660
+ preview: row.preview || previewFromMessages(row.messages),
661
+ };
662
+ // upgrade in place to jsonl layout
663
+ await writeSessionBundle(bundle);
664
+ return bundle;
665
+ }
666
+ } catch { /* fall through */ }
667
+
668
+ // legacy flat: sessions/{id}.json
669
+ try {
670
+ const flat = path.join(sessionsDir, `${safeId(id)}.json`);
671
+ const row = JSON.parse(await fs.readFile(flat, 'utf8'));
672
+ const bundle = {
673
+ id: row.id || id,
674
+ title: row.title || '对话',
675
+ messages: row.messages || [],
676
+ createdAt: row.createdAt || row.updatedAt || Date.now(),
677
+ updatedAt: row.updatedAt || Date.now(),
678
+ model: row.model || null,
679
+ vlmThreadIds: row.vlmThreadIds || [],
680
+ preview: row.preview || previewFromMessages(row.messages),
681
+ };
682
+ await writeSessionBundle(bundle);
683
+ try { await fs.unlink(flat); } catch { /* ignore */ }
684
+ return bundle;
685
+ } catch {
686
+ return null;
687
+ }
688
+ }
689
+
690
+ async function listVlmThreadFiles(sessionId) {
691
+ const sid = sessionId || ANON_SESSION_ID;
692
+ try {
693
+ const files = await fs.readdir(vlmDir(sid));
694
+ return files.filter((f) => f.endsWith('.jsonl') || f.endsWith('.json'));
695
+ } catch {
696
+ return [];
697
+ }
698
+ }
699
+
700
+ /** Read one thread from sessions/{sid}/vlm/{threadId}.jsonl (or .json). */
701
+ async function readVlmThreadFile(sessionId, threadId) {
702
+ const sid = sessionId || ANON_SESSION_ID;
703
+ // preferred jsonl
704
+ const jl = vlmThreadJsonlPath(sid, threadId);
705
+ try {
706
+ const rows = await readJsonl(jl);
707
+ if (rows.length) {
708
+ return vlmThreadFromJsonlRows(rows, sid, threadId);
709
+ }
710
+ } catch { /* miss */ }
711
+ // legacy single json object
712
+ try {
713
+ const jp = path.join(vlmDir(sid), `${safeId(threadId)}.json`);
714
+ const t = JSON.parse(await fs.readFile(jp, 'utf8'));
715
+ return {
716
+ id: t.id || threadId,
717
+ sessionId: t.sessionId || sid,
718
+ imagePaths: t.imagePaths || [],
719
+ messages: t.messages || [],
720
+ updatedAt: t.updatedAt || Date.now(),
721
+ };
722
+ } catch {
723
+ return null;
724
+ }
725
+ }
726
+
727
+ /** Load all VLM threads under sessions/{sid}/vlm/ */
728
+ async function loadVlmThreadsFromSession(sessionId) {
729
+ const sid = sessionId || ANON_SESSION_ID;
730
+ const byId = new Map();
731
+
732
+ // per-thread files: vlm/{threadId}.jsonl | .json
733
+ for (const f of await listVlmThreadFiles(sid)) {
734
+ const tid = f.replace(/\.jsonl?$/, '');
735
+ try {
736
+ const t = await readVlmThreadFile(sid, tid);
737
+ if (t?.id) byId.set(t.id, { ...t, sessionId: t.sessionId || sid });
738
+ } catch { /* skip */ }
739
+ }
740
+
741
+ // legacy monolithic sessions/{sid}/vlm.jsonl (one thread object per line)
742
+ try {
743
+ const mono = await readJsonl(legacySessionVlmJsonl(sid));
744
+ for (const row of mono) {
745
+ if (!row) continue;
746
+ if (row.id && (row.messages || row.type === 'meta')) {
747
+ const t = row.messages
748
+ ? {
749
+ id: row.id,
750
+ sessionId: row.sessionId || sid,
751
+ imagePaths: row.imagePaths || [],
752
+ messages: row.messages || [],
753
+ updatedAt: row.updatedAt || Date.now(),
754
+ }
755
+ : null;
756
+ if (t) byId.set(t.id, t);
757
+ }
758
+ }
759
+ } catch { /* ignore */ }
760
+
761
+ return [...byId.values()];
762
+ }
763
+
764
+ /** Write sessions/{sid}/vlm/{threadId}.jsonl */
765
+ async function writeVlmThreadFile(thread) {
766
+ if (!thread?.id) return;
767
+ const sid = thread.sessionId || ANON_SESSION_ID;
768
+ thread.sessionId = sid;
769
+ await fs.mkdir(vlmDir(sid), { recursive: true });
770
+ await writeJsonl(vlmThreadJsonlPath(sid, thread.id), vlmThreadToJsonlRows(thread));
771
+ // remove legacy single-json for this thread if any
772
+ try {
773
+ await fs.unlink(path.join(vlmDir(sid), `${safeId(thread.id)}.json`));
774
+ } catch { /* ignore */ }
775
+ }
776
+
777
+ /** One-time layout migrations → meta + messages.jsonl + vlm/{threadId}.jsonl */
778
+ async function migrateSessionLayout() {
779
+ if (layoutMigrated) return;
780
+ layoutMigrated = true;
781
+ await ensureAiDirs();
782
+ let entries = [];
783
+ try {
784
+ entries = await fs.readdir(sessionsDir, { withFileTypes: true });
785
+ } catch {
786
+ return;
787
+ }
788
+
789
+ // flat sessions/{id}.json → folder jsonl
790
+ for (const ent of entries) {
791
+ if (!ent.isFile() || !ent.name.endsWith('.json') || ent.name === 'index.json') continue;
792
+ const id = ent.name.slice(0, -5);
793
+ try {
794
+ await readSessionBundle(id);
795
+ console.log(`[meta-agent] layout: ${ent.name} → sessions/${id}/messages.jsonl`);
796
+ } catch (e) {
797
+ console.warn('[meta-agent] layout migrate flat', id, e.message);
798
+ }
799
+ }
800
+
801
+ // sessions/{id}/session.json + vlm/* + vlm.jsonl → per-thread jsonl
802
+ try {
803
+ entries = await fs.readdir(sessionsDir, { withFileTypes: true });
804
+ } catch {
805
+ return;
806
+ }
807
+ for (const ent of entries) {
808
+ if (!ent.isDirectory()) continue;
809
+ const id = ent.name;
810
+ try {
811
+ if (fsSync.existsSync(legacySessionJsonPath(id)) && !fsSync.existsSync(messagesJsonlPath(id))) {
812
+ await readSessionBundle(id);
813
+ }
814
+ const threads = await loadVlmThreadsFromSession(id);
815
+ for (const t of threads) {
816
+ t.sessionId = t.sessionId || id;
817
+ await writeVlmThreadFile(t);
818
+ vlmThreads.set(vlmMemKey(id, t.id), t);
819
+ }
820
+ // drop monolithic vlm.jsonl after split
821
+ try { await fs.unlink(legacySessionVlmJsonl(id)); } catch { /* ignore */ }
822
+ } catch (e) {
823
+ console.warn('[meta-agent] layout migrate dir', id, e.message);
824
+ }
825
+ }
826
+
827
+ // global .ai/vlm → sessions/{id}/vlm/{threadId}.jsonl
828
+ try {
829
+ const vfiles = await fs.readdir(legacyGlobalVlmDir);
830
+ if (!vfiles.length) return;
831
+ const map = new Map();
832
+ for (const ent of await fs.readdir(sessionsDir, { withFileTypes: true })) {
833
+ if (!ent.isDirectory()) continue;
834
+ try {
835
+ const meta = JSON.parse(await fs.readFile(metaPath(ent.name), 'utf8'));
836
+ for (const tid of meta.vlmThreadIds || []) map.set(tid, meta.id || ent.name);
837
+ } catch {
838
+ try {
839
+ const body = JSON.parse(await fs.readFile(legacySessionJsonPath(ent.name), 'utf8'));
840
+ for (const tid of body.vlmThreadIds || []) map.set(tid, body.id || ent.name);
841
+ } catch { /* skip */ }
842
+ }
843
+ }
844
+ for (const f of vfiles) {
845
+ if (!f.endsWith('.json') && !f.endsWith('.jsonl')) continue;
846
+ const src = path.join(legacyGlobalVlmDir, f);
847
+ try {
848
+ if (f.endsWith('.jsonl')) {
849
+ const rows = await readJsonl(src);
850
+ const tid = f.replace(/\.jsonl$/, '');
851
+ const t = vlmThreadFromJsonlRows(rows, map.get(tid) || '_orphan', tid);
852
+ t.sessionId = t.sessionId || map.get(t.id) || '_orphan';
853
+ await writeVlmThreadFile(t);
854
+ } else {
855
+ const t = JSON.parse(await fs.readFile(src, 'utf8'));
856
+ const tid = t.id || f.slice(0, -5);
857
+ t.id = tid;
858
+ t.sessionId = t.sessionId || map.get(tid) || '_orphan';
859
+ await writeVlmThreadFile(t);
860
+ }
861
+ await fs.unlink(src);
862
+ } catch (e) {
863
+ console.warn('[meta-agent] migrate global vlm', f, e.message);
864
+ }
865
+ }
866
+ try {
867
+ const left = await fs.readdir(legacyGlobalVlmDir);
868
+ if (!left.length) await fs.rmdir(legacyGlobalVlmDir);
869
+ } catch { /* ignore */ }
870
+ console.log('[meta-agent] layout: global vlm → sessions/{id}/vlm/{threadId}.jsonl');
871
+ } catch { /* no global vlm */ }
872
+ }
873
+
874
+ function previewFromMessages(messages) {
875
+ if (!Array.isArray(messages)) return '';
876
+ for (const m of messages) {
877
+ if (m.role !== 'user') continue;
878
+ let t = '';
879
+ if (typeof m.content === 'string') t = m.content;
880
+ else if (Array.isArray(m.content)) {
881
+ t = m.content.filter((p) => p?.type === 'text').map((p) => p.text).join(' ');
882
+ }
883
+ t = String(t || '').replace(/\s+/g, ' ').trim();
884
+ if (t) return shortTitle(t, 80);
885
+ }
886
+ return '';
887
+ }
888
+
889
+ function indexEntryFromSession(s) {
890
+ return {
891
+ id: s.id,
892
+ title: s.title || '对话',
893
+ createdAt: s.createdAt || s.updatedAt || Date.now(),
894
+ updatedAt: s.updatedAt || Date.now(),
895
+ messageCount: Array.isArray(s.messages) ? s.messages.length : (s.messageCount || 0),
896
+ preview: s.preview || previewFromMessages(s.messages) || '',
897
+ };
898
+ }
899
+
900
+ async function loadSessionsIndex() {
901
+ if (sessionsIndexCache) return sessionsIndexCache;
902
+ await ensureAiDirs();
903
+ await migrateSessionLayout();
904
+ try {
905
+ const raw = JSON.parse(await fs.readFile(sessionsIndexFile, 'utf8'));
906
+ sessionsIndexCache = {
907
+ version: raw.version || 3,
908
+ sessions: Array.isArray(raw.sessions) ? raw.sessions : [],
909
+ };
910
+ } catch {
911
+ sessionsIndexCache = { version: 3, sessions: [] };
912
+ }
913
+ // one-time migrate from legacy .ai_sessions.json
914
+ if (!sessionsIndexCache.sessions.length) {
915
+ await migrateLegacySessions();
916
+ }
917
+ return sessionsIndexCache;
918
+ }
919
+
920
+ async function saveSessionsIndex() {
921
+ if (!sessionsIndexCache) return;
922
+ await atomicWriteJson(sessionsIndexFile, sessionsIndexCache);
923
+ }
924
+
925
+ async function migrateLegacySessions() {
926
+ try {
927
+ const raw = JSON.parse(await fs.readFile(legacySessionsFile, 'utf8'));
928
+ const list = Array.isArray(raw.sessions) ? raw.sessions : [];
929
+ if (!list.length) return;
930
+ await ensureAiDirs();
931
+ const index = { version: 3, sessions: [] };
932
+ for (const s of list) {
933
+ if (!s?.id) continue;
934
+ const row = {
935
+ id: s.id,
936
+ title: s.title || '对话',
937
+ messages: s.messages || [],
938
+ createdAt: s.createdAt || s.updatedAt || Date.now(),
939
+ updatedAt: s.updatedAt || Date.now(),
940
+ };
941
+ await writeSessionBundle(row);
942
+ sessionBodyCache.set(row.id, row);
943
+ index.sessions.push(indexEntryFromSession(row));
944
+ }
945
+ index.sessions.sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0));
946
+ sessionsIndexCache = index;
947
+ await saveSessionsIndex();
948
+ try {
949
+ await fs.rename(legacySessionsFile, `${legacySessionsFile}.migrated`);
950
+ } catch { /* ignore */ }
951
+ console.log(`[meta-agent] migrated ${index.sessions.length} sessions → ${sessionsDir} (jsonl)`);
952
+ } catch {
953
+ /* no legacy file */
954
+ }
955
+ }
956
+
957
+ async function pruneSessionFiles() {
958
+ const index = await loadSessionsIndex();
959
+ if (index.sessions.length <= MAX_SESSIONS) return;
960
+ index.sessions.sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0));
961
+ const drop = index.sessions.slice(MAX_SESSIONS);
962
+ index.sessions = index.sessions.slice(0, MAX_SESSIONS);
963
+ for (const s of drop) {
964
+ sessionBodyCache.delete(s.id);
965
+ await rmrf(sessionDir(s.id));
966
+ for (const key of [...vlmThreads.keys()]) {
967
+ if (key.startsWith(`${s.id}::`)) vlmThreads.delete(key);
968
+ }
969
+ }
970
+ await saveSessionsIndex();
971
+ }
972
+
973
+ async function persistVlmThread(thread) {
974
+ if (!thread?.id) return;
975
+ const sid = thread.sessionId || ANON_SESSION_ID;
976
+ thread.sessionId = sid;
977
+ thread.updatedAt = thread.updatedAt || Date.now();
978
+ vlmThreads.set(vlmMemKey(sid, thread.id), thread);
979
+ // one file per thread — multi-image / multi-thread don't clobber each other
980
+ await writeVlmThreadFile(thread);
981
+ }
982
+
983
+ async function loadVlmThread(sessionId, threadId) {
984
+ if (!threadId) return null;
985
+ const sid = sessionId || ANON_SESSION_ID;
986
+ const key = vlmMemKey(sid, threadId);
987
+ if (vlmThreads.has(key)) {
988
+ return hydrateVlmThreadMessages(vlmThreads.get(key));
989
+ }
990
+
991
+ let raw = await readVlmThreadFile(sid, threadId);
992
+ if (!raw) {
993
+ // legacy global
994
+ try {
995
+ raw = JSON.parse(
996
+ await fs.readFile(path.join(legacyGlobalVlmDir, `${safeId(threadId)}.json`), 'utf8'),
997
+ );
998
+ raw.sessionId = raw.sessionId || sid;
999
+ raw = await hydrateVlmThreadMessages(raw);
1000
+ await persistVlmThread(raw);
1001
+ try {
1002
+ await fs.unlink(path.join(legacyGlobalVlmDir, `${safeId(threadId)}.json`));
1003
+ } catch { /* ignore */ }
1004
+ return raw;
1005
+ } catch { /* miss */ }
1006
+
1007
+ // scan other sessions' vlm/
1008
+ try {
1009
+ const entries = await fs.readdir(sessionsDir, { withFileTypes: true });
1010
+ for (const ent of entries) {
1011
+ if (!ent.isDirectory() || ent.name === safeId(sid)) continue;
1012
+ const hit = await readVlmThreadFile(ent.name, threadId);
1013
+ if (hit) {
1014
+ const ownSid = hit.sessionId || ent.name;
1015
+ const hydrated = await hydrateVlmThreadMessages(hit);
1016
+ vlmThreads.set(vlmMemKey(ownSid, threadId), hydrated);
1017
+ return hydrated;
1018
+ }
1019
+ }
1020
+ } catch { /* ignore */ }
1021
+ return null;
1022
+ }
1023
+
1024
+ const hydrated = await hydrateVlmThreadMessages(raw);
1025
+ vlmThreads.set(key, hydrated);
1026
+ return hydrated;
1027
+ }
1028
+
1029
+ async function pruneVlmThreadsForSession(sessionId) {
1030
+ const sid = sessionId || ANON_SESSION_ID;
1031
+ let threads = await loadVlmThreadsFromSession(sid);
1032
+ const now = Date.now();
1033
+ const keep = [];
1034
+ for (const t of threads) {
1035
+ if (now - (t.updatedAt || 0) > VLM_THREAD_TTL_MS) {
1036
+ if (t.id) {
1037
+ vlmThreads.delete(vlmMemKey(sid, t.id));
1038
+ try {
1039
+ await fs.unlink(vlmThreadJsonlPath(sid, t.id));
1040
+ } catch { /* ignore */ }
1041
+ try {
1042
+ await fs.unlink(path.join(vlmDir(sid), `${safeId(t.id)}.json`));
1043
+ } catch { /* ignore */ }
1044
+ }
1045
+ continue;
1046
+ }
1047
+ keep.push(t);
1048
+ }
1049
+ if (keep.length > MAX_VLM_THREADS_PER_SESSION) {
1050
+ keep.sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0));
1051
+ const drop = keep.splice(MAX_VLM_THREADS_PER_SESSION);
1052
+ for (const t of drop) {
1053
+ if (!t.id) continue;
1054
+ vlmThreads.delete(vlmMemKey(sid, t.id));
1055
+ try {
1056
+ await fs.unlink(vlmThreadJsonlPath(sid, t.id));
1057
+ } catch { /* ignore */ }
1058
+ }
1059
+ }
1060
+ }
1061
+
1062
+ /** Rebuild multimodal content from paths after loading slim VLM thread from disk */
1063
+ async function hydrateVlmThreadMessages(thread) {
1064
+ if (!thread?.messages?.length) return thread;
1065
+ const paths = thread.imagePaths || [];
1066
+ const first = thread.messages[0];
1067
+ if (!first || typeof first.content === 'string') return thread;
1068
+ if (!Array.isArray(first.content)) return thread;
1069
+ const needsHydrate = first.content.some(
1070
+ (p) => p?.type === 'image_url' && p?.image_url?.url === '__from_path__',
1071
+ );
1072
+ if (!needsHydrate) return thread;
1073
+ const dataUrls = [];
1074
+ for (const p of paths) {
1075
+ try { dataUrls.push(await imageToDataUrl(p)); }
1076
+ catch { /* missing file */ }
1077
+ }
1078
+ let urlIdx = 0;
1079
+ thread.messages = thread.messages.map((m, mi) => {
1080
+ if (!Array.isArray(m.content)) return m;
1081
+ const content = m.content.map((p) => {
1082
+ if (p?.type === 'image_url' && p?.image_url?.url === '__from_path__') {
1083
+ const url = dataUrls[urlIdx] || dataUrls[0];
1084
+ urlIdx += 1;
1085
+ return { type: 'image_url', image_url: { url: url || '' } };
1086
+ }
1087
+ return p;
1088
+ });
1089
+ // first message: if we have paths but no image parts left, rebuild
1090
+ if (mi === 0 && dataUrls.length && !content.some((p) => p?.type === 'image_url')) {
1091
+ const textPart = content.find((p) => p?.type === 'text') || { type: 'text', text: '' };
1092
+ return {
1093
+ ...m,
1094
+ content: [
1095
+ textPart,
1096
+ ...dataUrls.map((url) => ({ type: 'image_url', image_url: { url } })),
1097
+ ],
1098
+ _paths: paths,
1099
+ };
1100
+ }
1101
+ return { ...m, content };
1102
+ });
1103
+ return thread;
1104
+ }
1105
+
1106
+ /** Extract absolute paths from [imgN](path) / ![alt](path) / bare paths in text */
1107
+ function extractImagePathsFromText(text) {
1108
+ const s = String(text || '');
1109
+ const found = [];
1110
+ const re = /!\[[^\]]*\]\(([^)]+)\)|\[img\d*\]\(([^)]+)\)/gi;
1111
+ let m;
1112
+ while ((m = re.exec(s))) {
1113
+ const p = (m[1] || m[2] || '').trim().replace(/^<|>$/g, '');
1114
+ if (p && !p.startsWith('http') && !found.includes(p)) found.push(expandPath(p));
1115
+ }
1116
+ return found;
1117
+ }
1118
+
1119
+ async function loadConfig() {
1120
+ if (configCache) return configCache;
1121
+ try {
1122
+ configCache = { ...AI_DEFAULTS, ...JSON.parse(await fs.readFile(configFile, 'utf8')) };
1123
+ } catch {
1124
+ configCache = { ...AI_DEFAULTS, key: '', vlm_key: '', system: DEFAULT_SYSTEM };
1125
+ }
1126
+ if (!configCache.system) configCache.system = DEFAULT_SYSTEM;
1127
+ return configCache;
1128
+ }
1129
+
1130
+ async function saveConfig(patch) {
1131
+ const cur = await loadConfig();
1132
+ const next = { ...cur, ...patch };
1133
+ // never persist empty key over existing unless explicitly set
1134
+ configCache = next;
1135
+ const toWrite = { ...next };
1136
+ await fs.writeFile(configFile, JSON.stringify(toWrite, null, 2), 'utf8');
1137
+ return publicConfig(toWrite);
1138
+ }
1139
+
1140
+ function publicConfig(c) {
1141
+ return {
1142
+ url: c.url || AI_DEFAULTS.url,
1143
+ model: c.model || AI_DEFAULTS.model,
1144
+ hasKey: !!(c.key && String(c.key).trim()),
1145
+ vlm_url: c.vlm_url || '',
1146
+ vlm_model: c.vlm_model || '',
1147
+ hasVlmKey: !!(c.vlm_key && String(c.vlm_key).trim()) || !!(c.key && String(c.key).trim()),
1148
+ temperature: c.temperature ?? AI_DEFAULTS.temperature,
1149
+ auto_report: !!c.auto_report,
1150
+ auto_report_hour: c.auto_report_hour ?? 18,
1151
+ auto_report_days: c.auto_report_days ?? 1,
1152
+ system: c.system || DEFAULT_SYSTEM,
1153
+ };
1154
+ }
1155
+
1156
+ async function loadReports() {
1157
+ if (reportsCache) return reportsCache;
1158
+ try {
1159
+ reportsCache = JSON.parse(await fs.readFile(reportsFile, 'utf8'));
1160
+ } catch {
1161
+ reportsCache = {};
1162
+ }
1163
+ return reportsCache;
1164
+ }
1165
+
1166
+ async function saveReports() {
1167
+ if (!reportsCache) return;
1168
+ await fs.writeFile(reportsFile, JSON.stringify(reportsCache, null, 2), 'utf8');
1169
+ }
1170
+
1171
+ function sessionOpts() {
1172
+ return {
1173
+ claudeConfigDirs: claudeConfigDirs(),
1174
+ codexHome: codexHome(),
1175
+ grokHome: grokHome(),
1176
+ };
1177
+ }
1178
+
1179
+ async function buildDigest({ days = 1, agent = null, includeMessages = true } = {}) {
1180
+ const since = dayStartMs(Math.max(0, (days || 1) - 1));
1181
+ let sessions = await listSessions(null, agent || null);
1182
+ sessions = sessions
1183
+ .filter((s) => (s.updatedAt || 0) >= since)
1184
+ .sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0));
1185
+
1186
+ const client = sessions.map(toClientSession);
1187
+ const projects = groupByProject(client).slice(0, 40);
1188
+ const notesStore = await getProjectNotesStore();
1189
+
1190
+ const byProject = [];
1191
+ for (const p of projects.slice(0, 25)) {
1192
+ const pe = normalizeProjectEntry(notesStore[p.cwd] || {});
1193
+ const sessBrief = (p.sessions || []).slice(0, 8).map((s) => ({
1194
+ sessionId: s.sessionId,
1195
+ agent: s.agent,
1196
+ title: shortTitle(s.display || s.sessionId),
1197
+ updatedAt: s.updatedAt,
1198
+ turnCount: s.turnCount,
1199
+ totalTokens: s.totalTokens,
1200
+ }));
1201
+
1202
+ let messageSnippets = [];
1203
+ if (includeMessages) {
1204
+ for (const s of sessBrief.slice(0, 4)) {
1205
+ if (!s.sessionId) continue;
1206
+ try {
1207
+ const bundle = await loadSessionMessages(s.sessionId, s.agent, sessionOpts());
1208
+ if (!bundle) continue;
1209
+ const msgs = Array.isArray(bundle) ? bundle : (bundle.messages || []);
1210
+ const userBits = [];
1211
+ for (const m of msgs.slice(-40)) {
1212
+ const role = m.role || m.type;
1213
+ if (role !== 'user' && m.type !== 'user') continue;
1214
+ const text = extractTextParts(m.content ?? m.message?.content ?? m.text);
1215
+ if (!text || text.startsWith('<') || text.startsWith('Caveat:')) continue;
1216
+ userBits.push(shortTitle(text, 160));
1217
+ if (userBits.length >= 3) break;
1218
+ }
1219
+ if (userBits.length) {
1220
+ messageSnippets.push({ sessionId: s.sessionId, agent: s.agent, prompts: userBits });
1221
+ }
1222
+ } catch { /* skip */ }
1223
+ }
1224
+ }
1225
+
1226
+ byProject.push({
1227
+ cwd: p.cwd,
1228
+ name: p.projectName || path.basename(p.cwd || '') || '(no path)',
1229
+ sessionCount: p.sessionCount || (p.sessions || []).length,
1230
+ agents: p.agents || {},
1231
+ goal: pe.goal || '',
1232
+ notes: pe.notes ? shortTitle(pe.notes, 200) : '',
1233
+ sessions: sessBrief,
1234
+ prompts: messageSnippets,
1235
+ });
1236
+ }
1237
+
1238
+ const totalTokens = client.reduce((a, s) => a + (s.totalTokens || 0), 0);
1239
+ return {
1240
+ generatedAt: new Date().toISOString(),
1241
+ range: {
1242
+ days,
1243
+ since: new Date(since).toISOString(),
1244
+ until: new Date().toISOString(),
1245
+ },
1246
+ totals: {
1247
+ sessions: client.length,
1248
+ projects: byProject.length,
1249
+ tokensApprox: totalTokens,
1250
+ },
1251
+ projects: byProject,
1252
+ };
1253
+ }
1254
+
1255
+ async function imageToDataUrl(src) {
1256
+ if (!src) throw new Error('empty image');
1257
+ if (String(src).startsWith('data:')) return src;
1258
+ const p = expandPath(src);
1259
+ const buf = await fs.readFile(p);
1260
+ const ext = path.extname(p).toLowerCase().replace('.', '') || 'png';
1261
+ const mime =
1262
+ ext === 'jpg' || ext === 'jpeg' ? 'image/jpeg'
1263
+ : ext === 'gif' ? 'image/gif'
1264
+ : ext === 'webp' ? 'image/webp'
1265
+ : 'image/png';
1266
+ return `data:${mime};base64,${buf.toString('base64')}`;
1267
+ }
1268
+
1269
+ async function chatCompletions({ url, apiKey, body, signal }) {
1270
+ const res = await fetch(url, {
1271
+ method: 'POST',
1272
+ headers: {
1273
+ 'Content-Type': 'application/json',
1274
+ ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
1275
+ },
1276
+ body: JSON.stringify(body),
1277
+ signal,
1278
+ });
1279
+ if (!res.ok) {
1280
+ const t = await res.text().catch(() => '');
1281
+ throw new Error(`LLM HTTP ${res.status}: ${t.slice(0, 400)}`);
1282
+ }
1283
+ return res;
1284
+ }
1285
+
1286
+ async function chatSync(cfg, body) {
1287
+ const res = await chatCompletions({
1288
+ url: cfg.url || AI_DEFAULTS.url,
1289
+ apiKey: cfg.key || '',
1290
+ body: { ...body, stream: false },
1291
+ });
1292
+ return res.json();
1293
+ }
1294
+
1295
+ async function streamChat(cfg, body, onEvent) {
1296
+ const res = await chatCompletions({
1297
+ url: cfg.url || AI_DEFAULTS.url,
1298
+ apiKey: cfg.key || '',
1299
+ body: { ...body, stream: true },
1300
+ });
1301
+ const reader = res.body.getReader();
1302
+ const dec = new TextDecoder();
1303
+ let buf = '';
1304
+ while (true) {
1305
+ const { done, value } = await reader.read();
1306
+ if (done) break;
1307
+ buf += dec.decode(value, { stream: true });
1308
+ const lines = buf.split('\n');
1309
+ buf = lines.pop() || '';
1310
+ for (const line of lines) {
1311
+ const s = line.trim();
1312
+ if (!s.startsWith('data:')) continue;
1313
+ const raw = s.slice(5).trim();
1314
+ if (!raw || raw === '[DONE]') continue;
1315
+ try {
1316
+ onEvent(JSON.parse(raw));
1317
+ } catch { /* ignore partial */ }
1318
+ }
1319
+ }
1320
+ }
1321
+
1322
+ async function executeTool(name, args, toolCtx = {}) {
1323
+ const chatSessionId = toolCtx.chatSessionId || ANON_SESSION_ID;
1324
+ switch (name) {
1325
+ case 'list_sessions': {
1326
+ const days = args.days ?? 14;
1327
+ const limit = Math.min(args.limit || 40, 100);
1328
+ let sessions = await listSessions(args.cwd || null, args.agent || null);
1329
+ if (days > 0) {
1330
+ const since = Date.now() - days * 86400000;
1331
+ sessions = sessions.filter((s) => (s.updatedAt || 0) >= since);
1332
+ }
1333
+ sessions.sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0));
1334
+ return sessions.slice(0, limit).map(toClientSession).map((s) => ({
1335
+ sessionId: s.sessionId,
1336
+ agent: s.agent,
1337
+ title: shortTitle(s.display || s.title || '', 100),
1338
+ cwd: s.cwd,
1339
+ projectName: s.projectName,
1340
+ updatedAt: s.updatedAt,
1341
+ turnCount: s.turnCount,
1342
+ totalTokens: s.totalTokens,
1343
+ }));
1344
+ }
1345
+ case 'list_projects': {
1346
+ const days = args.days ?? 14;
1347
+ const limit = Math.min(args.limit || 30, 80);
1348
+ let sessions = await listSessions(null, args.agent || null);
1349
+ if (days > 0) {
1350
+ const since = Date.now() - days * 86400000;
1351
+ sessions = sessions.filter((s) => (s.updatedAt || 0) >= since);
1352
+ }
1353
+ const groups = groupByProject(sessions.map(toClientSession)).slice(0, limit);
1354
+ return groups.map((g) => ({
1355
+ cwd: g.cwd,
1356
+ name: g.projectName || path.basename(g.cwd || '') || '(no path)',
1357
+ sessionCount: g.sessionCount || (g.sessions || []).length,
1358
+ agents: g.agents || {},
1359
+ lastTitle: shortTitle(g.latest?.display || '', 80),
1360
+ updatedAt: g.updatedAt,
1361
+ }));
1362
+ }
1363
+ case 'search_activity': {
1364
+ const sessions = await listSessions(null, args.agent || null);
1365
+ const results = await searchActivity(sessions, {
1366
+ q: args.q || '',
1367
+ days: args.days ?? 14,
1368
+ agent: args.agent || null,
1369
+ limit: Math.min(args.limit || 40, 100),
1370
+ deep: args.deep !== false,
1371
+ });
1372
+ return results.map((r) => ({
1373
+ sessionId: r.sessionId,
1374
+ agent: r.agent,
1375
+ title: shortTitle(r.display || r.snippet || '', 100),
1376
+ cwd: r.cwd,
1377
+ updatedAt: r.updatedAt,
1378
+ snippet: r.snippet ? shortTitle(r.snippet, 200) : undefined,
1379
+ match: r.match,
1380
+ }));
1381
+ }
1382
+ case 'get_session_summary': {
1383
+ const sid = args.sessionId || args.id;
1384
+ if (!sid) return { error: 'sessionId required' };
1385
+ const bundle = await loadSessionMessages(
1386
+ sid,
1387
+ args.agent || null,
1388
+ sessionOpts(),
1389
+ );
1390
+ if (!bundle) return { error: 'session not found' };
1391
+ const msgs = Array.isArray(bundle) ? bundle : (bundle.messages || []);
1392
+ const max = Math.min(args.maxMessages || 30, 80);
1393
+ const slice = msgs.slice(-max);
1394
+ const summary = [];
1395
+ for (const m of slice) {
1396
+ const role = m.role || (m.type === 'user' ? 'user' : m.type === 'assistant' ? 'assistant' : m.type);
1397
+ if (role !== 'user' && role !== 'assistant') continue;
1398
+ const text = extractTextParts(m.content ?? m.message?.content ?? m.text);
1399
+ if (!text) continue;
1400
+ if (text.startsWith('Caveat:') || text.startsWith('<command-')) continue;
1401
+ summary.push({
1402
+ role,
1403
+ text: shortTitle(text, role === 'user' ? 400 : 300),
1404
+ });
1405
+ }
1406
+ return {
1407
+ agent: Array.isArray(bundle) ? args.agent : (bundle.agent || args.agent),
1408
+ total: msgs.length,
1409
+ messages: summary,
1410
+ };
1411
+ }
1412
+ case 'get_project_notes': {
1413
+ const store = await getProjectNotesStore();
1414
+ const cwd = expandPath(args.cwd);
1415
+ return { cwd, ...normalizeProjectEntry(store[cwd] || {}) };
1416
+ }
1417
+ case 'set_project_notes': {
1418
+ const store = await getProjectNotesStore();
1419
+ const cwd = expandPath(args.cwd);
1420
+ const cur = normalizeProjectEntry(store[cwd] || {});
1421
+ if (args.goal != null) cur.goal = String(args.goal);
1422
+ if (args.notes != null) cur.notes = String(args.notes);
1423
+ cur.updatedAt = Date.now();
1424
+ store[cwd] = cur;
1425
+ await saveProjectNotesStore();
1426
+ return { ok: true, cwd, ...cur };
1427
+ }
1428
+ case 'git_status': {
1429
+ const cwd = expandPath(args.cwd);
1430
+ if (!(await isGitRepo(cwd))) return { isGit: false };
1431
+ const out = await git(['status', '--porcelain'], cwd);
1432
+ const branch = await git(['branch', '--show-current'], cwd).catch(() => '');
1433
+ const files = out.trim().split('\n').filter(Boolean).slice(0, 80).map((line) => ({
1434
+ status: line.slice(0, 2).trim(),
1435
+ file: line.slice(3),
1436
+ }));
1437
+ return { isGit: true, branch: branch.trim(), files };
1438
+ }
1439
+ case 'git_log': {
1440
+ const cwd = expandPath(args.cwd);
1441
+ if (!(await isGitRepo(cwd))) return { isGit: false, commits: [] };
1442
+ const n = Math.min(args.limit || 15, 40);
1443
+ const out = await git(['log', `--oneline`, `-${n}`], cwd).catch(() => '');
1444
+ return {
1445
+ isGit: true,
1446
+ commits: out.trim().split('\n').filter(Boolean).map((line) => ({
1447
+ hash: line.slice(0, 7),
1448
+ message: line.slice(8),
1449
+ })),
1450
+ };
1451
+ }
1452
+ case 'list_files': {
1453
+ const dir = expandPath(args.path);
1454
+ const max = Math.min(args.max || 80, 200);
1455
+ const entries = await fs.readdir(dir, { withFileTypes: true });
1456
+ return entries.slice(0, max).map((e) => ({
1457
+ name: e.name,
1458
+ isDir: e.isDirectory(),
1459
+ path: path.join(dir, e.name),
1460
+ }));
1461
+ }
1462
+ case 'read_file': {
1463
+ const p = expandPath(args.path);
1464
+ const maxBytes = Math.min(args.maxBytes || 120000, 500000);
1465
+ const st = await fs.stat(p);
1466
+ if (st.size > maxBytes) {
1467
+ const buf = await fs.readFile(p, { encoding: null });
1468
+ return {
1469
+ path: p,
1470
+ truncated: true,
1471
+ size: st.size,
1472
+ content: buf.subarray(0, maxBytes).toString('utf8'),
1473
+ };
1474
+ }
1475
+ const content = await fs.readFile(p, 'utf8');
1476
+ return { path: p, size: st.size, content };
1477
+ }
1478
+ case 'write_file': {
1479
+ const p = expandPath(args.path);
1480
+ if (!path.isAbsolute(p)) return { error: 'path must be absolute' };
1481
+ // block writing into sensitive system dirs
1482
+ if (p === '/' || p.startsWith('/etc') || p.startsWith('/usr') || p.startsWith('/bin')) {
1483
+ return { error: 'refusing to write to system path' };
1484
+ }
1485
+ await fs.mkdir(path.dirname(p), { recursive: true });
1486
+ await fs.writeFile(p, String(args.content ?? ''), 'utf8');
1487
+ return { ok: true, path: p, bytes: Buffer.byteLength(String(args.content ?? '')) };
1488
+ }
1489
+ case 'run_command': {
1490
+ if (dangerousCommand(args.command)) {
1491
+ return { error: 'command blocked as dangerous' };
1492
+ }
1493
+ const cwd = expandPath(args.cwd || os.homedir());
1494
+ const timeoutMs = Math.min(Math.max(args.timeoutMs || 60000, 1000), 180000);
1495
+ return await new Promise((resolve) => {
1496
+ const child = spawn(process.env.SHELL || 'bash', ['-c', args.command], {
1497
+ cwd: fsSync.existsSync(cwd) ? cwd : os.homedir(),
1498
+ env: { ...process.env },
1499
+ shell: false,
1500
+ });
1501
+ let stdout = '';
1502
+ let stderr = '';
1503
+ const timer = setTimeout(() => {
1504
+ try { child.kill('SIGTERM'); } catch {}
1505
+ resolve({
1506
+ error: 'timeout',
1507
+ code: null,
1508
+ stdout: stdout.slice(-8000),
1509
+ stderr: stderr.slice(-4000),
1510
+ });
1511
+ }, timeoutMs);
1512
+ child.stdout.on('data', (d) => {
1513
+ stdout += d;
1514
+ if (stdout.length > 200000) stdout = stdout.slice(-100000);
1515
+ });
1516
+ child.stderr.on('data', (d) => {
1517
+ stderr += d;
1518
+ if (stderr.length > 100000) stderr = stderr.slice(-50000);
1519
+ });
1520
+ child.on('close', (code) => {
1521
+ clearTimeout(timer);
1522
+ resolve({
1523
+ code,
1524
+ stdout: stdout.slice(-12000),
1525
+ stderr: stderr.slice(-6000),
1526
+ });
1527
+ });
1528
+ child.on('error', (e) => {
1529
+ clearTimeout(timer);
1530
+ resolve({ error: e.message });
1531
+ });
1532
+ });
1533
+ }
1534
+ case 'analyze_images': {
1535
+ const cfg = await loadConfig();
1536
+ const prompt = String(args.prompt || '请分析这些图片。').trim();
1537
+ const sid = chatSessionId || ANON_SESSION_ID;
1538
+ await pruneVlmThreadsForSession(sid);
1539
+
1540
+ let thread = null;
1541
+ if (args.thread_id) {
1542
+ thread = await loadVlmThread(sid, args.thread_id);
1543
+ }
1544
+
1545
+ let paths = [...(args.image_paths || args.images || [])]
1546
+ .map((p) => expandPath(String(p).trim()))
1547
+ .filter(Boolean);
1548
+ // allow prompt to carry [imgN](path) refs
1549
+ for (const p of extractImagePathsFromText(prompt)) {
1550
+ if (!paths.includes(p)) paths.push(p);
1551
+ }
1552
+ if (thread && !paths.length) {
1553
+ paths = [...(thread.imagePaths || [])];
1554
+ }
1555
+ if (!paths.length && !thread) {
1556
+ return {
1557
+ error: 'image_paths required (or pass thread_id from a prior analyze_images call)',
1558
+ };
1559
+ }
1560
+
1561
+ // Merge new paths into thread
1562
+ if (thread) {
1563
+ for (const p of paths) {
1564
+ if (!thread.imagePaths.includes(p)) thread.imagePaths.push(p);
1565
+ }
1566
+ paths = [...thread.imagePaths];
1567
+ }
1568
+
1569
+ const dataUrls = [];
1570
+ for (const img of paths) {
1571
+ try {
1572
+ dataUrls.push(await imageToDataUrl(img));
1573
+ } catch (e) {
1574
+ return { error: `read image failed: ${img}: ${e.message}` };
1575
+ }
1576
+ }
1577
+
1578
+ const vlmUrl = (cfg.vlm_url || cfg.url || AI_DEFAULTS.url).trim();
1579
+ const vlmKey = (cfg.vlm_key || cfg.key || '').trim();
1580
+ const vlmModel = (cfg.vlm_model || cfg.model || AI_DEFAULTS.model).trim();
1581
+ if (!vlmKey) return { error: 'VLM API key not configured' };
1582
+
1583
+ // Build multi-turn messages: first user turn carries images; later turns text-only
1584
+ let messages;
1585
+ if (thread && thread.messages?.length) {
1586
+ messages = [...thread.messages];
1587
+ const firstPaths = thread.messages[0]?._paths || [];
1588
+ const added = paths.filter((p) => !firstPaths.includes(p));
1589
+ if (added.length) {
1590
+ const addedUrls = [];
1591
+ for (const p of added) {
1592
+ try { addedUrls.push(await imageToDataUrl(p)); }
1593
+ catch (e) { return { error: `read image failed: ${p}: ${e.message}` }; }
1594
+ }
1595
+ messages.push({
1596
+ role: 'user',
1597
+ content: [
1598
+ { type: 'text', text: prompt },
1599
+ ...addedUrls.map((url) => ({ type: 'image_url', image_url: { url } })),
1600
+ ],
1601
+ });
1602
+ } else {
1603
+ messages.push({ role: 'user', content: prompt });
1604
+ }
1605
+ } else {
1606
+ messages = [{
1607
+ role: 'user',
1608
+ content: [
1609
+ { type: 'text', text: prompt },
1610
+ ...dataUrls.map((url) => ({
1611
+ type: 'image_url',
1612
+ image_url: { url },
1613
+ })),
1614
+ ],
1615
+ _paths: paths,
1616
+ }];
1617
+ }
1618
+
1619
+ const apiMessages = messages.map(({ role, content }) => ({ role, content }));
1620
+
1621
+ const body = {
1622
+ model: vlmModel,
1623
+ messages: apiMessages,
1624
+ temperature: 0.2,
1625
+ };
1626
+ try {
1627
+ const res = await chatCompletions({
1628
+ url: vlmUrl,
1629
+ apiKey: vlmKey,
1630
+ body: { ...body, stream: false },
1631
+ });
1632
+ const json = await res.json();
1633
+ const text = json.choices?.[0]?.message?.content || '';
1634
+ if (!text) return { error: 'VLM returned empty content', raw: json?.error || null };
1635
+
1636
+ // Persist under this AI session: sessions/{sessionId}/vlm/{threadId}.json
1637
+ if (!thread) {
1638
+ const id = crypto.randomUUID();
1639
+ thread = {
1640
+ id,
1641
+ sessionId: sid,
1642
+ imagePaths: paths,
1643
+ messages: [
1644
+ { role: 'user', content: messages[0].content, _paths: paths },
1645
+ { role: 'assistant', content: text },
1646
+ ],
1647
+ updatedAt: Date.now(),
1648
+ };
1649
+ } else {
1650
+ thread.messages = [
1651
+ ...messages.map(({ role, content, _paths }) => ({ role, content, ...(_paths ? { _paths } : {}) })),
1652
+ { role: 'assistant', content: text },
1653
+ ];
1654
+ thread.imagePaths = paths;
1655
+ thread.sessionId = sid;
1656
+ thread.updatedAt = Date.now();
1657
+ }
1658
+ await persistVlmThread(thread);
1659
+ await pruneVlmThreadsForSession(sid);
1660
+
1661
+ return {
1662
+ ok: true,
1663
+ analysis: text,
1664
+ thread_id: thread.id,
1665
+ session_id: sid,
1666
+ image_paths: thread.imagePaths,
1667
+ turns: Math.ceil(thread.messages.length / 2),
1668
+ hint: '追问同一批图时调用 analyze_images({ thread_id, prompt }),无需重复传路径。线程保存在本 AI session 目录下。',
1669
+ };
1670
+ } catch (e) {
1671
+ return { error: String(e.message || e) };
1672
+ }
1673
+ }
1674
+ case 'generate_activity_digest': {
1675
+ return buildDigest({
1676
+ days: args.days ?? 1,
1677
+ agent: args.agent || null,
1678
+ includeMessages: args.includeMessages !== false,
1679
+ });
1680
+ }
1681
+ case 'save_report': {
1682
+ const day = args.day || todayKey();
1683
+ const store = await loadReports();
1684
+ store[day] = {
1685
+ day,
1686
+ title: args.title || `${day} 工作报告`,
1687
+ markdown: args.markdown || '',
1688
+ generatedAt: new Date().toISOString(),
1689
+ };
1690
+ await saveReports();
1691
+ return { ok: true, day };
1692
+ }
1693
+ case 'list_reports': {
1694
+ const store = await loadReports();
1695
+ const limit = Math.min(args.limit || 30, 100);
1696
+ return Object.values(store)
1697
+ .sort((a, b) => String(b.day).localeCompare(String(a.day)))
1698
+ .slice(0, limit)
1699
+ .map((r) => ({
1700
+ day: r.day,
1701
+ title: r.title,
1702
+ generatedAt: r.generatedAt,
1703
+ preview: shortTitle(r.markdown, 120),
1704
+ }));
1705
+ }
1706
+ case 'get_report': {
1707
+ const store = await loadReports();
1708
+ const day = args.day || todayKey();
1709
+ return store[day] || { error: 'not found', day };
1710
+ }
1711
+ default:
1712
+ return { error: `unknown tool ${name}` };
1713
+ }
1714
+ }
1715
+
1716
+ /**
1717
+ * Tool-enabled agent loop with streaming content callbacks.
1718
+ */
1719
+ async function runLoop(messages, { onContent, onTool, cfg, chatSessionId } = {}) {
1720
+ const config = cfg || await loadConfig();
1721
+ if (!config.key) throw new Error('AI API Key 未配置,请在 ⚙️ → Meta Agent 中填写');
1722
+
1723
+ let needsMutate = false;
1724
+ const maxRounds = 12;
1725
+ let content = '';
1726
+ const toolCtx = { chatSessionId: chatSessionId || ANON_SESSION_ID };
1727
+
1728
+ for (let round = 0; round < maxRounds; round++) {
1729
+ content = '';
1730
+ const toolMap = {};
1731
+ const body = {
1732
+ model: config.model || AI_DEFAULTS.model,
1733
+ messages,
1734
+ tools: TOOLS,
1735
+ stream: true,
1736
+ temperature: config.temperature ?? 0.3,
1737
+ };
1738
+ // some providers
1739
+ if (body.model && String(body.model).includes('mimo')) {
1740
+ body.max_completion_tokens = 2048;
1741
+ body.extra_body = { thinking: { type: 'disabled' } };
1742
+ }
1743
+
1744
+ await streamChat(config, body, (chunk) => {
1745
+ const d = chunk.choices?.[0]?.delta;
1746
+ if (d?.content) {
1747
+ content += d.content;
1748
+ onContent?.(content);
1749
+ }
1750
+ if (d?.tool_calls) {
1751
+ for (const tc of d.tool_calls) {
1752
+ const i = tc.index ?? 0;
1753
+ if (!toolMap[i]) toolMap[i] = { id: '', function: { name: '', arguments: '' } };
1754
+ const t = toolMap[i];
1755
+ if (tc.id) t.id += tc.id;
1756
+ if (tc.function?.name) t.function.name += tc.function.name;
1757
+ if (tc.function?.arguments) t.function.arguments += tc.function.arguments;
1758
+ }
1759
+ }
1760
+ });
1761
+
1762
+ const calls = Object.values(toolMap);
1763
+ if (!calls.length) {
1764
+ if (content) messages.push({ role: 'assistant', content });
1765
+ return { content, messages, needsMutate };
1766
+ }
1767
+
1768
+ messages.push({
1769
+ role: 'assistant',
1770
+ content: content || null,
1771
+ tool_calls: calls.map((t, i) => ({
1772
+ id: t.id || `call_${i}`,
1773
+ type: 'function',
1774
+ function: t.function,
1775
+ })),
1776
+ });
1777
+
1778
+ for (let i = 0; i < calls.length; i++) {
1779
+ const call = calls[i];
1780
+ let a = {};
1781
+ try {
1782
+ a = JSON.parse(call.function.arguments || '{}');
1783
+ } catch { /* empty */ }
1784
+ const callId = call.id || `call_${i}`;
1785
+ onTool?.(call.function.name, a, 'start', null, callId);
1786
+ let result;
1787
+ try {
1788
+ result = await executeTool(call.function.name, a, toolCtx);
1789
+ if (MUTATING.has(call.function.name)) needsMutate = true;
1790
+ onTool?.(call.function.name, a, 'done', result, callId);
1791
+ } catch (e) {
1792
+ result = { error: String(e.message || e) };
1793
+ onTool?.(call.function.name, a, 'error', result, callId);
1794
+ }
1795
+ messages.push({
1796
+ role: 'tool',
1797
+ tool_call_id: callId,
1798
+ content: JSON.stringify(result),
1799
+ });
1800
+ }
1801
+ }
1802
+
1803
+ return { content, messages, needsMutate, error: 'max tool rounds' };
1804
+ }
1805
+
1806
+ async function generateReport({ days = 1, onContent, onTool } = {}) {
1807
+ const digest = await buildDigest({ days, includeMessages: true });
1808
+ const messages = [
1809
+ { role: 'system', content: REPORT_SYSTEM },
1810
+ {
1811
+ role: 'user',
1812
+ content:
1813
+ `请根据以下活动快照写工作报告(覆盖最近 ${days} 天)。写完后调用 save_report 保存。\n\n` +
1814
+ JSON.stringify(digest),
1815
+ },
1816
+ ];
1817
+ const result = await runLoop(messages, { onContent, onTool });
1818
+ // ensure saved
1819
+ if (result.content) {
1820
+ const day = todayKey();
1821
+ const store = await loadReports();
1822
+ if (!store[day] || !store[day].markdown) {
1823
+ store[day] = {
1824
+ day,
1825
+ title: days <= 1 ? `${day} 工作日报` : `${day} 近${days}日报告`,
1826
+ markdown: result.content,
1827
+ generatedAt: new Date().toISOString(),
1828
+ digest: {
1829
+ sessions: digest.totals.sessions,
1830
+ projects: digest.totals.projects,
1831
+ },
1832
+ };
1833
+ await saveReports();
1834
+ }
1835
+ }
1836
+ return { ...result, digest };
1837
+ }
1838
+
1839
+ // ── Chat session CRUD (folder: .ai/sessions/) ──────────────────────────────
1840
+ async function listChatSessions() {
1841
+ const index = await loadSessionsIndex();
1842
+ return [...index.sessions]
1843
+ .sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0))
1844
+ .map((s) => ({
1845
+ id: s.id,
1846
+ title: s.title,
1847
+ createdAt: s.createdAt,
1848
+ updatedAt: s.updatedAt,
1849
+ messageCount: s.messageCount || 0,
1850
+ preview: s.preview || '',
1851
+ }));
1852
+ }
1853
+
1854
+ async function getChatSession(id) {
1855
+ if (!id) return null;
1856
+ if (sessionBodyCache.has(id)) return sessionBodyCache.get(id);
1857
+ await ensureAiDirs();
1858
+ await migrateSessionLayout();
1859
+ const row = await readSessionBundle(id);
1860
+ if (row?.id) {
1861
+ sessionBodyCache.set(row.id, row);
1862
+ return row;
1863
+ }
1864
+ return null;
1865
+ }
1866
+
1867
+ async function saveChatSession(session) {
1868
+ if (!session?.id) throw new Error('session.id required');
1869
+ await ensureAiDirs();
1870
+ const prev = await getChatSession(session.id);
1871
+ const now = Date.now();
1872
+ const messages = session.messages || prev?.messages || [];
1873
+ const row = {
1874
+ id: session.id,
1875
+ title: session.title || prev?.title || '对话',
1876
+ messages,
1877
+ createdAt: session.createdAt || prev?.createdAt || now,
1878
+ updatedAt: now,
1879
+ preview: previewFromMessages(messages),
1880
+ model: session.model || prev?.model || null,
1881
+ vlmThreadIds: session.vlmThreadIds || prev?.vlmThreadIds || [],
1882
+ };
1883
+ await writeSessionBundle(row);
1884
+ sessionBodyCache.set(row.id, row);
1885
+
1886
+ const index = await loadSessionsIndex();
1887
+ const entry = indexEntryFromSession(row);
1888
+ const i = index.sessions.findIndex((s) => s.id === row.id);
1889
+ if (i >= 0) index.sessions[i] = entry;
1890
+ else index.sessions.unshift(entry);
1891
+ index.sessions.sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0));
1892
+ await saveSessionsIndex();
1893
+ await pruneSessionFiles();
1894
+ return row;
1895
+ }
1896
+
1897
+ async function deleteChatSession(id) {
1898
+ if (!id) return { ok: false };
1899
+ sessionBodyCache.delete(id);
1900
+ // remove meta.json + messages.jsonl + vlm/*
1901
+ await rmrf(sessionDir(id));
1902
+ for (const key of [...vlmThreads.keys()]) {
1903
+ if (key.startsWith(`${id}::`)) vlmThreads.delete(key);
1904
+ }
1905
+ const index = await loadSessionsIndex();
1906
+ index.sessions = index.sessions.filter((s) => s.id !== id);
1907
+ await saveSessionsIndex();
1908
+ return { ok: true };
1909
+ }
1910
+
1911
+ /** Data root for ops / docs */
1912
+ function getDataPaths() {
1913
+ return {
1914
+ aiDir,
1915
+ sessionsDir,
1916
+ sessionsIndexFile,
1917
+ legacySessionsFile,
1918
+ legacyGlobalVlmDir,
1919
+ layout: 'sessions/{sessionId}/meta.json + messages.jsonl + vlm/{threadId}.jsonl',
1920
+ };
1921
+ }
1922
+
1923
+ return {
1924
+ TOOLS,
1925
+ loadConfig,
1926
+ saveConfig,
1927
+ publicConfig,
1928
+ loadReports,
1929
+ getReport: async (day) => {
1930
+ const store = await loadReports();
1931
+ return store[day || todayKey()] || null;
1932
+ },
1933
+ listReports: async (limit = 30) => {
1934
+ const store = await loadReports();
1935
+ return Object.values(store)
1936
+ .sort((a, b) => String(b.day).localeCompare(String(a.day)))
1937
+ .slice(0, limit);
1938
+ },
1939
+ buildDigest,
1940
+ executeTool,
1941
+ runLoop,
1942
+ generateReport,
1943
+ listChatSessions,
1944
+ getChatSession,
1945
+ saveChatSession,
1946
+ deleteChatSession,
1947
+ getDataPaths,
1948
+ todayKey,
1949
+ DEFAULT_SYSTEM,
1950
+ AI_DEFAULTS,
1951
+ };
1952
+ }