collect.mjs 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374
  1. /**
  2. * collect.mjs — 48h 多工具采集扫描 → 采集清单.json
  3. *
  4. * 产出「采集清单」(每条:来源工具 / 时间 / 类型[会话|文件|交付物] / 摘要 / 产出物路径 / URL)
  5. * 先给用户看采集到什么,缺哪类工具提示用户确认路径。
  6. *
  7. * 纪律:所有工具路径「探测存在才扫」,未知工具列「待确认」不阻塞;
  8. * 不伪造任何采集结果——扫不到就报 0 并注明原因。
  9. *
  10. * 用法:node collect.mjs [--hours 48] [--out <path>] [--max-per-tool 200]
  11. * 输出:采集清单 JSON(stdout 或 --out 文件)
  12. */
  13. import fs from 'node:fs';
  14. import path from 'node:path';
  15. import os from 'node:os';
  16. /* ── 工具路径探测表(存在才扫)──────────────────────────────── */
  17. const HOME = os.homedir();
  18. const ALT_HOME = '/opt/data/home'; // 容器常见:getpwuid home ≠ $HOME
  19. function candidatePaths(rel) {
  20. const set = new Set();
  21. for (const base of [HOME, ALT_HOME, '/root']) {
  22. if (base) set.add(path.join(base, rel));
  23. }
  24. if (process.env.STUDY_REPORT_EXTRA_SCAN_ROOT) set.add(path.join(process.env.STUDY_REPORT_EXTRA_SCAN_ROOT, rel));
  25. return [...set];
  26. }
  27. const TOOL_PROBES = [
  28. {
  29. tool: 'Claude Code',
  30. kind: 'session',
  31. paths: candidatePaths('.claude/projects'), // <proj>/*.jsonl
  32. scan: scanClaudeCode,
  33. },
  34. {
  35. tool: 'Codex',
  36. kind: 'session',
  37. paths: [...candidatePaths('.codex/sessions'), ...candidatePaths('.codex/log')],
  38. scan: scanGenericJsonlDir,
  39. },
  40. {
  41. tool: 'WorkBuddy',
  42. kind: 'session',
  43. paths: [...candidatePaths('Library/Application Support/WorkBuddy'), ...candidatePaths('.workbuddy')],
  44. scan: scanGenericJsonlDir,
  45. },
  46. {
  47. tool: 'Trae',
  48. kind: 'session',
  49. paths: [...candidatePaths('.trae/sessions'), ...candidatePaths('.trae')],
  50. scan: scanGenericJsonlDir,
  51. },
  52. {
  53. tool: 'OpenClaw',
  54. kind: 'session',
  55. paths: [...candidatePaths('.openclaw/sessions'), ...candidatePaths('.openclaw')],
  56. scan: scanGenericJsonlDir,
  57. },
  58. {
  59. tool: '元宝',
  60. kind: 'session',
  61. paths: [...candidatePaths('.yuanbao'), ...candidatePaths('Library/Application Support/yuanbao')],
  62. scan: scanGenericJsonlDir,
  63. },
  64. {
  65. tool: 'Hermes Agent',
  66. kind: 'session',
  67. paths: [...candidatePaths('.fmode-harness-agent'), ...candidatePaths('.fmode-harness')],
  68. scan: scanHermes,
  69. },
  70. ];
  71. /* ── 工作区产出物目录 ───────────────────────────────────────── */
  72. const WORKSPACE_DIRS = ['projects', 'git-repos', 'Desktop', 'Documents'];
  73. const WORKSPACE_EXTS = new Set(['.md', '.html', '.pdf', '.docx', '.xlsx', '.pptx']);
  74. const WORKSPACE_SKIP = new Set(['node_modules', '.git', 'dist', '.next', 'build', '.venv', 'venv', '__pycache__']);
  75. /* ── 各工具扫描实现 ─────────────────────────────────────────── */
  76. function* walk(dir, depth = 0, maxDepth = 4) {
  77. if (depth > maxDepth) return;
  78. let entries;
  79. try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
  80. for (const e of entries) {
  81. const p = path.join(dir, e.name);
  82. if (e.isDirectory()) {
  83. if (!WORKSPACE_SKIP.has(e.name)) yield* walk(p, depth + 1, maxDepth);
  84. } else if (e.isFile()) {
  85. yield p;
  86. }
  87. }
  88. }
  89. function withinHours(mtimeMs, hours, now) {
  90. const ageH = (now - mtimeMs) / 3600000;
  91. return ageH >= 0 && ageH <= hours;
  92. }
  93. /** Claude Code:读 <projects>/<proj> 目录下的 .jsonl 会话(提取 user 消息 / 工具结果 / 产出文件路径) */
  94. function scanClaudeCode(dirs, cutoff, now, maxPerTool) {
  95. const items = [];
  96. for (const root of dirs) {
  97. let projDirs;
  98. try { projDirs = fs.readdirSync(root, { withFileTypes: true }).filter(e => e.isDirectory()); } catch { continue; }
  99. for (const proj of projDirs) {
  100. const projPath = path.join(root, proj.name);
  101. let files;
  102. try { files = fs.readdirSync(projPath).filter(f => f.endsWith('.jsonl')); } catch { continue; }
  103. for (const f of files) {
  104. const fp = path.join(projPath, f);
  105. let st;
  106. try { st = fs.statSync(fp); } catch { continue; }
  107. if (!withinHours(st.mtimeMs, (now - cutoff) / 3600000, now)) continue;
  108. const sess = summarizeClaudeSession(fp, st, proj.name);
  109. if (sess) items.push(sess);
  110. }
  111. }
  112. }
  113. return items.sort((a, b) => b.time.localeCompare(a.time)).slice(0, maxPerTool);
  114. }
  115. function summarizeClaudeSession(fp, st, projDir) {
  116. let userMsgs = 0;
  117. let asstMsgs = 0;
  118. let toolUses = 0;
  119. let firstUser = null;
  120. const producedFiles = new Set();
  121. let cwd = null;
  122. let startedAt = null;
  123. try {
  124. const lines = fs.readFileSync(fp, 'utf-8').split('\n');
  125. for (const line of lines) {
  126. if (!line.trim()) continue;
  127. let d;
  128. try { d = JSON.parse(line); } catch { continue; }
  129. if (d.timestamp && !startedAt) startedAt = d.timestamp;
  130. if (d.cwd) cwd = d.cwd;
  131. if (d.type === 'user' && d.message) {
  132. const c = d.message.content;
  133. const text = typeof c === 'string' ? c : Array.isArray(c) ? c.filter(x => x.type === 'text').map(x => x.text).join(' ') : '';
  134. if (text && !text.startsWith('<') && !/system-reminder|tool_result/i.test(text.slice(0, 60))) {
  135. userMsgs++;
  136. if (!firstUser && text.trim().length > 12) firstUser = text.trim().slice(0, 90);
  137. }
  138. } else if (d.type === 'assistant' && d.message) {
  139. asstMsgs++;
  140. const c = d.message.content;
  141. if (Array.isArray(c)) {
  142. for (const x of c) {
  143. if (x.type === 'tool_use') {
  144. toolUses++;
  145. const inp = x.input || {};
  146. const fp2 = inp.file_path || inp.notebook_path;
  147. if (fp2 && /\.(md|html|pdf|docx|xlsx|pptx|mjs|js|py|json)$/i.test(fp2)) producedFiles.add(fp2);
  148. if (x.name === 'Bash' && typeof inp.command === 'string') {
  149. const m = inp.command.match(/([\w./-]+\.(?:html|md|pdf))\b/g);
  150. if (m) m.forEach(f => { if (!/node_modules/.test(f)) producedFiles.add(f); });
  151. }
  152. }
  153. }
  154. }
  155. }
  156. }
  157. } catch { return null; }
  158. if (!firstUser) firstUser = '(会话无可读用户消息)';
  159. return {
  160. source: 'Claude Code',
  161. time: (startedAt || st.mtime.toISOString()),
  162. type: '会话',
  163. summary: `用户消息 ${userMsgs} 条 / 助手 ${asstMsgs} 条 / 工具调用 ${toolUses} 次 — ${firstUser}`,
  164. artifacts: [...producedFiles].slice(0, 12),
  165. path: fp,
  166. project: cwd || projDir.replace(/^-/, '/'),
  167. url: null,
  168. };
  169. }
  170. /** 通用 jsonl/json 会话目录扫描(Codex/Trae/WorkBuddy/OpenClaw/元宝) */
  171. function scanGenericJsonlDir(dirs, cutoff, now, maxPerTool) {
  172. const items = [];
  173. for (const root of dirs) {
  174. for (const fp of walk(root, 0, 3)) {
  175. const ext = path.extname(fp);
  176. if (!['.jsonl', '.json', '.log'].includes(ext)) continue;
  177. let st;
  178. try { st = fs.statSync(fp); } catch { continue; }
  179. if (!withinHours(st.mtimeMs, (now - cutoff) / 3600000, now)) continue;
  180. if (st.size > 5 * 1024 * 1024) continue; // >5MB 只登记不深读
  181. let summary = `${path.basename(fp)}(${(st.size / 1024).toFixed(0)}KB)`;
  182. try {
  183. const head = fs.readFileSync(fp, 'utf-8').slice(0, 4096);
  184. const m = head.match(/"(?:text|content|message|prompt|instruction)"\s*:\s*"([^"]{16,90})/);
  185. if (m) summary = m[1].replace(/\\n/g, ' ');
  186. } catch { /* keep default */ }
  187. items.push({
  188. source: null, // 由调用方填
  189. time: st.mtime.toISOString(),
  190. type: '会话',
  191. summary,
  192. artifacts: [],
  193. path: fp,
  194. project: null,
  195. url: null,
  196. });
  197. }
  198. }
  199. return items.slice(0, maxPerTool);
  200. }
  201. /** Hermes Agent:snapshots + tmp 产物 */
  202. function scanHermes(dirs, cutoff, now, maxPerTool) {
  203. const items = [];
  204. const interesting = ['sessions-recent.md', 'agent-log.json', 'skills-index.txt', 'container-topology.md'];
  205. for (const root of dirs) {
  206. for (const fp of walk(root, 0, 4)) {
  207. const st = (() => { try { return fs.statSync(fp); } catch { return null; } })();
  208. if (!st || !withinHours(st.mtimeMs, (now - cutoff) / 3600000, now)) continue;
  209. const name = path.basename(fp);
  210. let type = '文件';
  211. let summary = `${name}(${(st.size / 1024).toFixed(0)}KB)`;
  212. if (name === 'agent-log.json') {
  213. type = '会话';
  214. try {
  215. const log = JSON.parse(fs.readFileSync(fp, 'utf-8'));
  216. const arr = Array.isArray(log) ? log : Object.values(log);
  217. const last = arr[arr.length - 1];
  218. summary = `agent 心跳日志 ${arr.length} 条,最近:${last && last.agent ? last.agent + ' @ ' + (last.timestamp || '') : '(空)'}`;
  219. } catch { /* keep */ }
  220. } else if (name === 'sessions-recent.md') {
  221. type = '会话';
  222. try {
  223. const head = fs.readFileSync(fp, 'utf-8').slice(0, 600);
  224. const m = head.match(/导出时间:\s*(\S+)/);
  225. summary = `最近会话快照${m ? `(导出 ${m[1]})` : ''}`;
  226. } catch { /* keep */ }
  227. } else if (/\.md$/.test(name)) {
  228. try {
  229. const head = fs.readFileSync(fp, 'utf-8').slice(0, 400);
  230. const t = head.match(/^#\s+(.+)$/m);
  231. if (t) summary = t[1].slice(0, 90);
  232. } catch { /* keep */ }
  233. }
  234. items.push({ source: 'Hermes Agent', time: st.mtime.toISOString(), type, summary, artifacts: [], path: fp, project: null, url: null });
  235. }
  236. }
  237. return items.sort((a, b) => b.time.localeCompare(a.time)).slice(0, maxPerTool);
  238. }
  239. /** 工作区:48h 内 mtime 新文件(.md/.html/.pdf/.docx/.xlsx) */
  240. function scanWorkspaces(hours, now, maxPerTool) {
  241. const items = [];
  242. const roots = [...new Set([...WORKSPACE_DIRS.map(d => path.join(HOME, d)), ...WORKSPACE_DIRS.map(d => path.join(ALT_HOME, d)), '/opt/data/git-repos', '/opt/data/projects'])];
  243. for (const root of roots) {
  244. if (!fs.existsSync(root)) continue;
  245. for (const fp of walk(root, 0, 5)) {
  246. const ext = path.extname(fp).toLowerCase();
  247. if (!WORKSPACE_EXTS.has(ext)) continue;
  248. let st;
  249. try { st = fs.statSync(fp); } catch { continue; }
  250. if (!withinHours(st.mtimeMs, hours, now)) continue;
  251. let summary = `${path.basename(fp)}(${(st.size / 1024).toFixed(0)}KB)`;
  252. if (ext === '.md') {
  253. try {
  254. const head = fs.readFileSync(fp, 'utf-8').slice(0, 500);
  255. const t = head.match(/^#\s+(.+)$/m);
  256. if (t) summary = t[1].slice(0, 90);
  257. } catch { /* keep */ }
  258. }
  259. items.push({
  260. source: '工作区',
  261. time: st.mtime.toISOString(),
  262. type: ext === '.html' ? '交付物' : '文件',
  263. summary,
  264. artifacts: [fp],
  265. path: fp,
  266. project: path.basename(path.dirname(fp)),
  267. url: null,
  268. });
  269. }
  270. }
  271. return items.sort((a, b) => b.time.localeCompare(a.time)).slice(0, maxPerTool);
  272. }
  273. /* ── 汇总 ───────────────────────────────────────────────────── */
  274. export function collect({ hours = 48, maxPerTool = 200 } = {}) {
  275. const now = Date.now();
  276. const cutoff = now - hours * 3600000;
  277. const items = [];
  278. const toolsStatus = [];
  279. for (const probe of TOOL_PROBES) {
  280. const existing = probe.paths.filter(p => fs.existsSync(p));
  281. if (existing.length === 0) {
  282. toolsStatus.push({ tool: probe.tool, status: '未检出(本机无该工具痕迹,跳过;若实际有用请人工确认路径)' });
  283. continue;
  284. }
  285. const found = probe.scan(existing, cutoff, now, maxPerTool);
  286. for (const it of found) if (!it.source) it.source = probe.tool;
  287. items.push(...found);
  288. toolsStatus.push({ tool: probe.tool, status: `已扫描 ${existing.length} 个路径,命中 ${found.length} 条` });
  289. }
  290. const ws = scanWorkspaces(hours, now, maxPerTool);
  291. items.push(...ws);
  292. toolsStatus.push({ tool: '工作区产出物', status: `已扫描工作区目录,命中 ${ws.length} 条` });
  293. // 统计
  294. const bySource = {};
  295. const byType = {};
  296. for (const it of items) {
  297. bySource[it.source] = (bySource[it.source] || 0) + 1;
  298. byType[it.type] = (byType[it.type] || 0) + 1;
  299. }
  300. const artifactPaths = [...new Set(items.flatMap(i => i.artifacts || []))].slice(0, 300);
  301. return {
  302. meta: {
  303. generatedAt: new Date().toISOString(),
  304. windowHours: hours,
  305. windowStart: new Date(cutoff).toISOString(),
  306. host: os.hostname(),
  307. user: process.env.USER || os.userInfo().username,
  308. },
  309. stats: {
  310. total: items.length,
  311. sessionCount: byType['会话'] || 0,
  312. fileCount: (byType['文件'] || 0) + (byType['交付物'] || 0),
  313. bySource,
  314. byType,
  315. artifactCount: artifactPaths.length,
  316. },
  317. toolsStatus,
  318. items,
  319. artifacts: artifactPaths,
  320. };
  321. }
  322. /* ── CLI ────────────────────────────────────────────────────── */
  323. function argVal(flag, dflt) {
  324. const i = process.argv.indexOf(flag);
  325. return i >= 0 && process.argv[i + 1] ? process.argv[i + 1] : dflt;
  326. }
  327. const isMain = process.argv[1] && path.resolve(process.argv[1]) === path.resolve(decodeURIComponent(new URL(import.meta.url).pathname));
  328. if (isMain) {
  329. const hours = Number(argVal('--hours', '48'));
  330. const out = argVal('--out', null);
  331. const result = collect({ hours, maxPerTool: Number(argVal('--max-per-tool', '200')) });
  332. /* 人类可读采集清单 */
  333. console.log(`\n📋 采集清单(近 ${hours}h · 生成于 ${result.meta.generatedAt})`);
  334. console.log(` 会话 ${result.stats.sessionCount} 条 · 文件/交付物 ${result.stats.fileCount} 条 · 合计 ${result.stats.total} 条\n`);
  335. console.log('工具探测:');
  336. for (const t of result.toolsStatus) console.log(` · ${t.tool}: ${t.status}`);
  337. console.log('\n来源分布:', JSON.stringify(result.stats.bySource));
  338. console.log('\n最近条目(前 20):');
  339. for (const it of result.items.slice(0, 20)) {
  340. console.log(` [${it.source}] ${it.time.slice(0, 16).replace('T', ' ')} ${it.type} — ${it.summary.slice(0, 70)}`);
  341. }
  342. if (result.stats.total === 0) {
  343. console.log('\n⚠️ 未采集到任何痕迹:确认工具安装路径,或用 --hours 放宽窗口。不伪造采集结果。');
  344. }
  345. console.log('');
  346. if (out) {
  347. fs.mkdirSync(path.dirname(path.resolve(out)), { recursive: true });
  348. fs.writeFileSync(path.resolve(out), JSON.stringify(result, null, 2));
  349. console.log(`已写出:${path.resolve(out)}`);
  350. } else {
  351. process.stdout.write('\n<!--JSON-BEGIN-->\n' + JSON.stringify(result) + '\n<!--JSON-END-->\n');
  352. }
  353. }