-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathserver.js
More file actions
executable file
·2834 lines (2727 loc) · 128 KB
/
Copy pathserver.js
File metadata and controls
executable file
·2834 lines (2727 loc) · 128 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env node
/**
* BotCom Workbench — local file and AI-ops cockpit backend.
*
* Pure Node backend. It binds to 127.0.0.1 and is intended for local desktop use.
*/
'use strict';
const http = require('http');
const fs = require('fs');
const fsp = require('fs/promises');
const path = require('path');
const os = require('os');
const crypto = require('crypto');
const { exec, spawn, execFile } = require('child_process');
const { URL } = require('url');
const HOME = os.homedir();
const PORT = Number(process.env.BOTCOM_WORKBENCH_PORT || process.env.PORT) || 4570;
const CONFIG_DIR = path.join(HOME, '.botcom-workbench');
const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
const AI_PROFILES_FILE = path.join(CONFIG_DIR, 'ai-profiles.json');
const AI_ENV_DIR = path.join(CONFIG_DIR, 'ai-env');
const THUMB_DIR = path.join(CONFIG_DIR, 'thumbs');
const PUBLIC = path.join(__dirname, 'public');
const PLATFORM = process.platform;
const APP_NAME = process.env.BOTCOM_APP_NAME || 'BotCom Workbench';
const BOTCOM_HOME = process.env.BOTCOM_HOME || path.join(HOME, 'BotCom');
const BOTCOM_ROOTS = {
workbench: process.env.BOTCOM_WORKBENCH_ROOT || path.join(BOTCOM_HOME, 'AI-Workbench'),
mediaOps: process.env.BOTCOM_MEDIA_OPS_ROOT || path.join(BOTCOM_HOME, 'media-ops'),
adapters: process.env.BOTCOM_ADAPTERS_ROOT || path.join(BOTCOM_HOME, 'adapters'),
app: __dirname,
};
const BOTCOM_MODULES = [
['positioning', '定位 / 产品'],
['acquisition', '获客 / 增长'],
['content', '内容 / 分发'],
['customer', '客户 / 社群'],
['delivery', '交付 / 项目'],
['revenue', '收入 / 商业化'],
['assets', '资产 / 知识库'],
['automation', '自动化 / 复盘'],
];
const BOTCOM_MODULE_ALIASES = new Map([
['positioning', 'positioning'], ['strategy', 'positioning'], ['offer', 'positioning'], ['offers', 'positioning'], ['product', 'positioning'],
['acquisition', 'acquisition'], ['growth', 'acquisition'], ['lead', 'acquisition'], ['leads', 'acquisition'], ['marketing', 'acquisition'],
['content', 'content'], ['media', 'content'], ['distribution', 'content'], ['publishing', 'content'],
['customer', 'customer'], ['customers', 'customer'], ['crm', 'customer'], ['community', 'customer'], ['support', 'customer'],
['delivery', 'delivery'], ['project', 'delivery'], ['projects', 'delivery'], ['fulfillment', 'delivery'],
['revenue', 'revenue'], ['monetization', 'revenue'], ['sales', 'revenue'], ['finance', 'revenue'], ['pnl', 'revenue'],
['assets', 'assets'], ['asset', 'assets'], ['knowledge', 'assets'], ['knowledge_base', 'assets'], ['kb', 'assets'],
['automation', 'automation'], ['automations', 'automation'], ['ops', 'automation'], ['review', 'automation'], ['intelligence', 'automation'],
]);
// 搜索 / 遍历时跳过的重目录,避免 vibe coding 项目里 node_modules 拖垮速度
const IGNORE_DIRS = new Set([
'node_modules', '.git', '.next', 'dist', 'build', '.cache', '.venv', 'venv',
'__pycache__', '.DS_Store', 'Pods', '.gradle', 'target', '.idea', '.vscode-test',
'DerivedData', '.expo', '.turbo', 'vendor', '.svn', '.hg',
]);
const TEXT_EXT = new Set([
'txt', 'md', 'markdown', 'js', 'jsx', 'ts', 'tsx', 'mjs', 'cjs', 'json', 'json5',
'html', 'htm', 'css', 'scss', 'less', 'py', 'rb', 'go', 'rs', 'java', 'kt', 'swift',
'c', 'h', 'cpp', 'hpp', 'cc', 'm', 'mm', 'sh', 'bash', 'zsh', 'fish', 'sql', 'yml',
'yaml', 'toml', 'ini', 'env', 'conf', 'xml', 'svg', 'vue', 'astro', 'php', 'lua',
'r', 'dart', 'gradle', 'properties', 'gitignore', 'dockerfile', 'makefile', 'log',
'csv', 'tsv', 'gql', 'graphql', 'prisma', 'plist', 'tex', 'rtf', 'srt', 'vtt', 'ass',
]);
const IMAGE_EXT = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'ico', 'avif', 'heic', 'heif', 'tiff', 'tif']);
const VIDEO_EXT = new Set(['mp4', 'webm', 'mov', 'm4v', 'ogv']);
const AUDIO_EXT = new Set(['mp3', 'wav', 'ogg', 'm4a', 'flac', 'aac']);
const PDF_EXT = new Set(['pdf']);
const ARCHIVE_EXT = new Set(['zip', 'jar', 'tar', 'tgz', 'gz', 'bz2', 'xz', '7z', 'rar']);
const MIME = {
html: 'text/html; charset=utf-8', htm: 'text/html; charset=utf-8',
js: 'application/javascript; charset=utf-8', css: 'text/css; charset=utf-8',
json: 'application/json; charset=utf-8', svg: 'image/svg+xml',
png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif',
webp: 'image/webp', bmp: 'image/bmp', ico: 'image/x-icon', avif: 'image/avif',
mp4: 'video/mp4', webm: 'video/webm', mov: 'video/quicktime', m4v: 'video/mp4',
ogv: 'video/ogg', mp3: 'audio/mpeg', wav: 'audio/wav', ogg: 'audio/ogg',
m4a: 'audio/mp4', flac: 'audio/flac', aac: 'audio/aac', pdf: 'application/pdf',
ttf: 'font/ttf', woff: 'font/woff', woff2: 'font/woff2',
};
// ---------- 工具函数 ----------
function ext(name) {
const i = name.lastIndexOf('.');
if (i <= 0) return '';
return name.slice(i + 1).toLowerCase();
}
// 从一组文件/目录名推断项目类型(签名文件),供当前目录徽章 + 子目录浅探共用
function projectOf(names) {
if (names.has('package.json')) return 'node';
if (names.has('index.html')) return 'web';
if (names.has('requirements.txt') || names.has('pyproject.toml')) return 'python';
if (names.has('Cargo.toml')) return 'rust';
if (names.has('go.mod')) return 'go';
if (names.has('.git')) return 'git';
return null;
}
function kindOf(name, isDir) {
if (isDir) return 'dir';
const e = ext(name);
if (IMAGE_EXT.has(e)) return 'image';
if (VIDEO_EXT.has(e)) return 'video';
if (AUDIO_EXT.has(e)) return 'audio';
if (PDF_EXT.has(e)) return 'pdf';
if (ARCHIVE_EXT.has(e)) return 'archive';
if (TEXT_EXT.has(e) || /^(dockerfile|makefile|readme|license|\.[a-z]+rc)$/i.test(name)) return 'text';
return 'other';
}
// 把任意请求路径规整成绝对真实路径;非绝对路径回退到 HOME。该服务仅绑定本机回环地址,
// 不作为远程多用户文件网关使用。
// 同时拒绝空字节这种明显异常输入。
function resolvePath(p) {
if (!p || typeof p !== 'string') return HOME;
if (p.includes('\0')) throw new Error('非法路径');
let abs = p.startsWith('~') ? path.join(HOME, p.slice(1)) : p;
if (!path.isAbsolute(abs)) abs = path.join(HOME, abs);
return path.normalize(abs);
}
async function readConfig() {
try {
const raw = await fsp.readFile(CONFIG_FILE, 'utf8');
return JSON.parse(raw);
} catch {
return { favorites: [], recentOpened: [] };
}
}
// 串行化「读-改-写」:高频 recordRecent 与收藏共享 config.json,必须排队整个 RMW 才不丢更新
let _cfgChain = Promise.resolve();
function updateConfig(mutator) {
const run = _cfgChain.then(async () => {
const cfg = await readConfig();
await mutator(cfg);
await fsp.mkdir(CONFIG_DIR, { recursive: true });
// 原子写:temp + fsync + rename,写一半崩溃不留截断 JSON(否则 readConfig 静默清空收藏/最近)
const tmp = `${CONFIG_FILE}.tmp-${process.pid}-${Date.now()}`;
try {
const fh = await fsp.open(tmp, 'w');
try { await fh.writeFile(JSON.stringify(cfg, null, 2)); await fh.sync(); } finally { await fh.close(); }
await fsp.rename(tmp, CONFIG_FILE);
} catch (e) { await fsp.unlink(tmp).catch(() => {}); throw e; } // 写盘失败要冒泡给调用方,别静默成功
return cfg;
});
_cfgChain = run.catch(() => {}); // 保持队列存活,但 run 本身会 reject 让调用方感知失败
return run;
}
function sendJSON(res, code, obj) {
const body = JSON.stringify(obj);
res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8' });
res.end(body);
}
// ---------- 业务逻辑 ----------
async function listDir(dirPath) {
const dir = resolvePath(dirPath);
const dirents = await fsp.readdir(dir, { withFileTypes: true });
const entries = [];
for (const d of dirents) {
if (d.name === '.DS_Store') continue;
const full = path.join(dir, d.name);
let isDir = d.isDirectory();
let size = 0, mtime = 0;
// 处理符号链接
if (d.isSymbolicLink()) {
try {
const st = await fsp.stat(full);
isDir = st.isDirectory();
} catch { continue; }
}
let btime = 0;
try {
const st = await fsp.lstat(full);
size = st.size;
mtime = st.mtimeMs;
btime = st.birthtimeMs || 0;
} catch { /* ignore */ }
entries.push({
name: d.name,
path: full,
isDir,
kind: kindOf(d.name, isDir),
hidden: d.name.startsWith('.'),
size,
mtime,
btime,
});
}
// 文件夹在前,按名称排序
entries.sort((a, b) => {
if (a.isDir !== b.isDir) return a.isDir ? -1 : 1;
return a.name.localeCompare(b.name, 'zh', { numeric: true });
});
// 识别项目类型(含 package.json / .git / index.html 等)
const names = new Set(entries.map((e) => e.name));
const project = projectOf(names);
// 给每个子目录浅探一次项目类型,文件卡片上标徽章——「一下午起的十个项目」一眼认出是 node/web/py
// 成本受控:只探目录、且总数封顶;大目录(>80 个子目录)跳过,避免拖慢列表
const subDirs = entries.filter((e) => e.isDir && !e.name.startsWith('.'));
if (subDirs.length <= 80) {
await Promise.all(subDirs.map(async (e) => {
try {
const inner = await fsp.readdir(e.path);
e.project = projectOf(new Set(inner));
} catch { /* 无权限等,跳过 */ }
}));
}
const parts = dir.split(path.sep).filter(Boolean);
const breadcrumb = [{ name: PLATFORM === 'win32' ? dir.split(path.sep)[0] : '/', path: PLATFORM === 'win32' ? parts[0] + path.sep : path.sep }];
let acc = PLATFORM === 'win32' ? parts[0] + path.sep : path.sep;
const start = PLATFORM === 'win32' ? 1 : 0;
for (let i = start; i < parts.length; i++) {
acc = path.join(acc, parts[i]);
breadcrumb.push({ name: parts[i], path: acc });
}
return { path: dir, parent: path.dirname(dir), entries, breadcrumb, project };
}
async function readFile(filePath) {
const file = resolvePath(filePath);
const st = await fsp.stat(file);
const kind = kindOf(path.basename(file), false);
const info = {
path: file, name: path.basename(file), size: st.size,
mtime: st.mtimeMs, kind, ext: ext(file),
};
if (kind === 'text') {
if (st.size > 2 * 1024 * 1024) {
info.tooLarge = true;
const fd = await fsp.open(file, 'r');
const buf = Buffer.alloc(256 * 1024);
const { bytesRead } = await fd.read(buf, 0, buf.length, 0);
await fd.close();
// 回退到完整 UTF-8 边界,避免把末尾多字节字符切坏成 �
let end = bytesRead;
while (end > 0 && (buf[end - 1] & 0xC0) === 0x80) end--;
if (end > 0 && (buf[end - 1] & 0xC0) === 0xC0) end--;
info.content = buf.toString('utf8', 0, end) + '\n\n… (文件较大,仅显示前 256KB)';
} else {
info.content = await fsp.readFile(file, 'utf8');
}
}
return info;
}
// 递归遍历,带忽略表、结果上限与时间预算。返回是否因上限/超时而提前中断(截断)
// onDir(可选)让调用方也拿到目录,用于「按文件夹名搜索」——目录不计入 limit。
async function walk(root, { onFile, onDir, limit = 4000, deadline }) {
const queue = [root];
let count = 0;
let truncated = false;
while (queue.length) {
if (Date.now() > deadline || count >= limit) { truncated = true; break; }
const dir = queue.shift();
let dirents;
try {
dirents = await fsp.readdir(dir, { withFileTypes: true });
} catch { continue; }
for (const d of dirents) {
if (d.name === '.DS_Store') continue;
const full = path.join(dir, d.name);
const isDir = d.isDirectory();
if (isDir) {
if (IGNORE_DIRS.has(d.name)) continue;
if (onDir) {
let mtime = 0;
try { mtime = (await fsp.lstat(full)).mtimeMs; } catch { /* */ }
onDir({ name: d.name, path: full, dir, isDir: true, kind: 'dir', mtime, size: 0 });
}
queue.push(full);
} else {
count++;
let mtime = 0, size = 0;
try { const st = await fsp.lstat(full); mtime = st.mtimeMs; size = st.size; } catch { /* */ }
onFile({ name: d.name, path: full, dir, isDir: false, kind: kindOf(d.name, false), mtime, size });
if (count >= limit) { truncated = true; break; }
}
}
}
return { truncated };
}
// 模糊匹配打分:子序列匹配,连续命中、词首命中、靠前命中加分
function fuzzyScore(query, target) {
const q = query.toLowerCase();
const t = target.toLowerCase();
let qi = 0, score = 0, lastIdx = -1, streak = 0;
for (let ti = 0; ti < t.length && qi < q.length; ti++) {
if (t[ti] === q[qi]) {
let pts = 10;
if (ti === lastIdx + 1) { streak++; pts += streak * 8; } else streak = 0;
if (ti === 0 || /[\/_\-. ]/.test(t[ti - 1])) pts += 15; // 词首
pts += Math.max(0, 8 - ti * 0.1); // 靠前
score += pts;
lastIdx = ti;
qi++;
}
}
if (qi < q.length) return -1; // 未能匹配全部字符
score -= (t.length - q.length) * 0.2; // 越短越好
return score;
}
async function searchFiles(query, rootPath, deadlineTs) {
const root = resolvePath(rootPath);
const q = (query || '').trim();
if (!q) return { results: [] };
const matches = [];
const scoreInto = (f, bonus) => {
const s = fuzzyScore(q, f.name);
if (s <= 0) return;
const pathBonus = fuzzyScore(q, f.path) > 0 ? 3 : 0;
// 近期修改加权,让「我刚做的东西」优先浮出
const recencyBonus = Math.max(0, 20 - (Date.now() - f.mtime) / 86400000) * 0.6;
matches.push({ ...f, score: s + pathBonus + recencyBonus + bonus });
};
const { truncated } = await walk(root, {
limit: 60000,
deadline: deadlineTs || Date.now() + 4000, // 多根搜索时传共享截止点,封顶总耗时
onFile: (f) => scoreInto(f, 0),
// 文件夹小幅加权——vibe coding「一下午起十个项目」,最常找的就是项目目录本身
onDir: (f) => scoreInto(f, 6),
});
matches.sort((a, b) => b.score - a.score);
return { results: matches.slice(0, 80), truncated };
}
async function grepFiles(query, rootPath) {
const root = resolvePath(rootPath);
const q = (query || '').trim();
if (!q || q.length < 2) return { results: [] };
const lower = q.toLowerCase();
const files = [];
const { truncated: walkTrunc } = await walk(root, {
limit: 12000,
deadline: Date.now() + 1800,
onFile: (f) => { if (f.kind === 'text' && f.size < 512 * 1024) files.push(f); },
});
// 按修改时间倒序读,让「我最近写过那句话」的文件优先命中
files.sort((a, b) => b.mtime - a.mtime);
const results = [];
let truncated = walkTrunc;
const deadline = Date.now() + 3500;
for (const f of files) {
if (Date.now() > deadline || results.length >= 50) { truncated = true; break; }
let content;
try { content = await fsp.readFile(f.path, 'utf8'); } catch { continue; }
const lines = content.split('\n');
const hits = [];
for (let i = 0; i < lines.length && hits.length < 4; i++) {
if (lines[i].toLowerCase().includes(lower)) {
hits.push({ line: i + 1, text: lines[i].trim().slice(0, 200) });
}
}
if (hits.length) results.push({ ...f, hits });
}
return { results, truncated };
}
// ---------- Spotlight(mdfind)内容搜索:白嫖系统索引 ----------
// 覆盖全文 + PDF/docx + 截图/图片里的 OCR 文字,毫秒级返回;Spotlight 没索引到的(代码目录等)由 grep 兜底
function mdfind(args) {
return new Promise((resolve) => {
execFile('mdfind', args, { timeout: 6000, maxBuffer: 8 * 1024 * 1024 }, (err, stdout) => {
resolve(err ? null : String(stdout).split('\n').filter(Boolean));
});
});
}
async function contentSearch(query, rootPath) {
const root = resolvePath(rootPath);
const q = (query || '').trim();
if (!q || q.length < 2) return { results: [] };
// 属性查询而非自由文本:CJK 子串匹配更稳;[cd] = 忽略大小写/音调
const esc = q.replace(/[\\"*]/g, '');
const paths = await mdfind(['-onlyin', root, `(kMDItemTextContent == "*${esc}*"cd) || (kMDItemDisplayName == "*${esc}*"cd)`]);
if (paths === null || !paths.length) {
const fb = await grepFiles(query, rootPath); // mdfind 不可用或无命中 → 原 grep 兜底
return { ...fb, engine: 'grep' };
}
const results = [];
const deadline = Date.now() + 2500;
for (const p of paths) {
if (results.length >= 60 || Date.now() > deadline) break;
if (/\/(node_modules|\.git|Library\/Caches)\//.test(p)) continue;
let st; try { st = await fsp.stat(p); } catch { continue; }
if (st.isDirectory()) continue;
const name = path.basename(p);
results.push({ name, path: p, isDir: false, kind: kindOf(name, false), hidden: name.startsWith('.'), size: st.size, mtime: st.mtimeMs, btime: st.birthtimeMs || 0 });
}
results.sort((a, b) => b.mtime - a.mtime); // 近改优先,「我刚写的那句话」浮在最上面
// 给文本类命中补行级预览(只读前几个小文件,别拖慢整体)
const lower = q.toLowerCase();
let read = 0;
for (const r of results) {
if (read >= 12) break;
if (r.kind !== 'text' || r.size > 512 * 1024) continue;
read++;
let content; try { content = await fsp.readFile(r.path, 'utf8'); } catch { continue; }
const lines = content.split('\n');
const hits = [];
for (let i = 0; i < lines.length && hits.length < 3; i++) {
if (lines[i].toLowerCase().includes(lower)) hits.push({ line: i + 1, text: lines[i].trim().slice(0, 200) });
}
if (hits.length) r.hits = hits;
}
return { results, truncated: paths.length > results.length, engine: 'spotlight' };
}
async function recentFiles(rootPath) {
const root = resolvePath(rootPath);
const all = [];
const { truncated } = await walk(root, {
limit: 30000,
deadline: Date.now() + 3500,
onFile: (f) => { if (!f.name.startsWith('.')) all.push(f); },
});
all.sort((a, b) => b.mtime - a.mtime);
return { results: all.slice(0, 60), truncated };
}
// ---------- 文件操作(编辑 / 废纸篓 / 重命名 / 新建)----------
// 都带护栏:编辑只认文本类、删除走系统废纸篓可恢复、名称拒绝路径分隔符与空字节。
async function writeTextFile(p, content, expectedMtime) {
const file = resolvePath(p);
if (!TEXT_EXT.has(ext(file))) throw new Error('只支持文本类文件编辑');
if (typeof content !== 'string') throw new Error('内容非法');
// 并发覆盖保护:打开编辑后文件被外部(agent)改过或删除,拒绝盲覆盖
if (expectedMtime) {
let cur = 0, missing = false;
try { cur = (await fsp.stat(file)).mtimeMs; } catch { missing = true; }
if (missing || (cur && Math.abs(cur - expectedMtime) > 1)) {
const e = new Error(missing ? '文件已被外部删除' : '文件已被外部修改'); e.conflict = true; throw e;
}
}
// 原子写:临时文件 + fsync + rename,写到一半崩溃也不会损坏原文件
const tmp = `${file}.botcom-tmp-${process.pid}-${Date.now()}`;
try {
const fh = await fsp.open(tmp, 'w');
try { await fh.writeFile(content, 'utf8'); await fh.sync(); } finally { await fh.close(); }
await fsp.rename(tmp, file);
} catch (e) {
await fsp.unlink(tmp).catch(() => {}); // 失败清理临时文件,不留残骸
throw e;
}
const st = await fsp.stat(file);
return { ok: true, size: st.size, mtime: st.mtimeMs };
}
// 移到系统废纸篓(可恢复),而非永久删除——呼应「不删除只归档」
function trashPath(p) {
return new Promise((resolve) => {
let target;
try { target = resolvePath(p); } catch { return resolve({ ok: false, error: '非法路径' }); }
let isDir = false;
try { isDir = fs.lstatSync(target).isDirectory(); } catch { return resolve({ ok: false, error: '文件不存在' }); }
let cmd;
if (PLATFORM === 'darwin') {
// 路径走 argv,不拼进单引号 AppleScript 字面量——避免含 ' 的文件名删除失败/注入
// POSIX file 必须 as alias 强转,否则 Finder 解析不了报 -1728
cmd = `osascript -e 'on run argv' -e 'tell application "Finder" to delete (POSIX file (item 1 of argv) as alias)' -e 'end run' ${shellQuote(target)}`;
} else if (PLATFORM === 'win32') {
const method = isDir ? 'DeleteDirectory' : 'DeleteFile';
const ps = target.replace(/'/g, "''");
cmd = `powershell -NoProfile -Command "Add-Type -AssemblyName Microsoft.VisualBasic; [Microsoft.VisualBasic.FileIO.FileSystem]::${method}('${ps}','OnlyErrorDialogs','SendToRecycleBin')"`;
} else {
cmd = `gio trash ${shellQuote(target)} || trash-put ${shellQuote(target)} || trash ${shellQuote(target)}`;
}
exec(cmd, (err) => {
if (!err) return resolve({ ok: true });
let msg = err.message;
// Finder 自动化未授权(-1743/-600)给人话
if (PLATFORM === 'darwin' && /-1743|-600|not allowed|authoriz/i.test(msg)) {
msg = '需在「系统设置 → 隐私与安全性 → 自动化」里允许 BotCom Workbench 控制 Finder(首次删除会弹授权)';
}
resolve({ ok: false, error: msg });
});
});
}
function validName(name) {
if (!name || typeof name !== 'string') return false;
const n = name.trim();
return n.length > 0 && n.length <= 255 && !/[\/\\\0]/.test(n) && n !== '.' && n !== '..';
}
async function renamePath(p, newName) {
const src = resolvePath(p);
newName = (newName || '').trim();
if (!validName(newName)) throw new Error('名称不合法');
const dst = path.join(path.dirname(src), newName);
if (fs.existsSync(dst)) throw new Error('已存在同名项');
await fsp.rename(src, dst);
return { ok: true, path: dst };
}
// ---------- AI 整理:备料 + 在内嵌终端拉起交互式 agent(v2,对话式;v1 headless 提案已废弃)----------
// BotCom Workbench不再后台跑 claude -p:把整理偏好、过往整理历史、工作约定写成 brief 文件,
// 前端在内嵌终端启动 claude/codex,方案摊给用户对话确认后由 agent 动手。
// 约定:agent 每批移动追加写 ORGANIZE_LOG_DIR 回滚日志(撤销在对话里完成)、收尾把新学到的偏好沉淀进 prefs 文件。
const ORGANIZE_LOG_DIR = path.join(CONFIG_DIR, 'organize-log');
const ORGANIZE_PREFS_FILE = path.join(CONFIG_DIR, 'organize-prefs.md');
const ORGANIZE_BRIEF_FILE = path.join(CONFIG_DIR, 'organize-brief.md');
const DEFAULT_ORGANIZE_STRATEGY = `- 默认归档:过时/低频的文件移入 _archive/ 下的语义子目录(如 _archive/截图/2026-06/)
- 同一主题的散文件归进语义明确的项目文件夹(项目制:一个项目一个文件夹,按需建议新文件夹)
- 归档之外,单独提一份「建议删除」清单(什么算该删由你判断:明显垃圾、可再生成的产物、过期大文件……),逐条给理由
- 删除须用户逐条点头;确认后移入废纸篓 ~/.Trash/(不直接 rm),并照常记进回滚日志
- 最近 7 天内有动静的文件视为正在进行的工作,不要动
- 文件夹一律不动,只整理松散文件
- 拿不准的单独列出来问,宁可少动不要乱动`;
// codex 各版本旗标常变(0.139 移除了 --full-auto):按 --help 实测有什么用什么,
// 全不认识就裸跑——退化成多几次审批确认,但不会因 unexpected argument 拉不起来
async function codexOrganizeFlags(bin) {
const help = await new Promise((resolve) => {
execFile(bin, ['--help'], { timeout: 8000 }, (err, stdout) => resolve(err ? '' : String(stdout)));
});
if (help.includes('--full-auto')) return ' --full-auto';
let flags = '';
if (help.includes('--sandbox')) flags += ' --sandbox workspace-write';
if (help.includes('--ask-for-approval')) flags += ' -a on-request';
if (help.includes('--add-dir')) flags += ` --add-dir "${CONFIG_DIR}"`;
return flags;
}
async function findAgentBin(name) {
// GUI 启动的 app 没有用户 shell 的 PATH,走登录 shell 找一次绝对路径
return new Promise((resolve) => {
execFile('/bin/zsh', ['-lc', `command -v ${name}`], { timeout: 8000 }, (err, stdout) => {
const out = String(stdout || '').trim().split('\n').pop();
resolve(!err && out && out.startsWith('/') ? out : null);
});
});
}
// ---------- AI 工具 / 模型配置:本地保存、API 脱敏、终端通过 env 脚本加载 ----------
function defaultAiProfiles() {
return [
{
id: 'claude-anthropic',
name: 'Claude Code / Anthropic',
provider: 'anthropic',
tool: 'claude',
model: 'sonnet',
baseUrl: '',
note: 'Claude Code 官方路径。适合代码、文件整理、长任务和本地项目协作。',
},
{
id: 'codex-openai',
name: 'Codex / OpenAI',
provider: 'openai',
tool: 'codex',
model: '',
baseUrl: '',
note: 'Codex CLI 路径。可用 -m/--model 指定模型;也可结合 Codex profile。',
},
{
id: 'deepseek-compatible',
name: 'DeepSeek / OpenAI-compatible',
provider: 'deepseek',
tool: 'shell',
model: 'deepseek-chat',
baseUrl: 'https://api.deepseek.com/v1',
note: 'OpenAI-compatible 环境变量档案。适合支持 OPENAI_API_KEY / OPENAI_BASE_URL 的工具或代理层。',
},
];
}
function normalizeAiProvider(v) {
const s = String(v || '').toLowerCase();
if (['anthropic', 'openai', 'deepseek', 'custom'].includes(s)) return s;
return 'custom';
}
function normalizeAiTool(v) {
const s = String(v || '').toLowerCase();
if (['claude', 'codex', 'shell'].includes(s)) return s;
return 'shell';
}
function mergeDefaultAiProfiles(saved) {
const byId = new Map();
for (const p of defaultAiProfiles()) byId.set(p.id, p);
for (const p of Array.isArray(saved) ? saved : []) {
const id = safeText(p && p.id, 64).replace(/[^a-zA-Z0-9_.:-]/g, '-');
if (!id) continue;
byId.set(id, { ...(byId.get(id) || {}), ...p, id });
}
return [...byId.values()];
}
function sanitizeAiProfile(p) {
const key = p.apiKey || '';
return {
id: safeText(p.id, 64),
name: safeText(p.name || p.id, 90),
provider: normalizeAiProvider(p.provider),
tool: normalizeAiTool(p.tool),
model: safeText(p.model || '', 120),
baseUrl: safeText(p.baseUrl || '', 240),
note: safeText(p.note || '', 180),
apiKeySet: !!key,
apiKeyPreview: key ? `••••${String(key).slice(-4)}` : '',
updated_at: safeText(p.updated_at || '', 80),
};
}
async function readAiProfilesRaw() {
const raw = await readJsonFileSafe(AI_PROFILES_FILE, 256 * 1024);
const saved = raw.ok && raw.data && Array.isArray(raw.data.profiles) ? raw.data.profiles : [];
return mergeDefaultAiProfiles(saved);
}
async function writeAiProfilesRaw(profiles) {
await fsp.mkdir(CONFIG_DIR, { recursive: true });
const tmp = `${AI_PROFILES_FILE}.tmp-${process.pid}-${Date.now()}`;
await fsp.writeFile(tmp, JSON.stringify({ profiles }, null, 2), { mode: 0o600 });
await fsp.rename(tmp, AI_PROFILES_FILE);
await fsp.chmod(AI_PROFILES_FILE, 0o600).catch(() => {});
}
async function aiProfilesStatus() {
const [claude, codex] = await Promise.all([findAgentBin('claude'), findAgentBin('codex')]);
const profiles = await readAiProfilesRaw();
return {
ok: true,
path: AI_PROFILES_FILE,
envDir: AI_ENV_DIR,
tools: {
claude: { installed: !!claude, path: claude || '', supportsModelFlag: true, envKey: 'ANTHROPIC_API_KEY' },
codex: { installed: !!codex, path: codex || '', supportsModelFlag: true, envKey: 'OPENAI_API_KEY' },
shell: { installed: true, path: process.env.SHELL || '/bin/zsh', supportsModelFlag: false, envKey: 'custom' },
},
profiles: profiles.map(sanitizeAiProfile),
};
}
async function saveAiProfile(body) {
const id = safeText(body.id || '', 64).replace(/[^a-zA-Z0-9_.:-]/g, '-').replace(/^-+|-+$/g, '');
if (!id) return { ok: false, error: 'profile id required' };
const profiles = await readAiProfilesRaw();
const current = profiles.find((p) => p.id === id) || {};
const next = {
...current,
id,
name: safeText(body.name || current.name || id, 90),
provider: normalizeAiProvider(body.provider || current.provider),
tool: normalizeAiTool(body.tool || current.tool),
model: safeText(body.model != null ? body.model : current.model || '', 120),
baseUrl: safeText(body.baseUrl != null ? body.baseUrl : current.baseUrl || '', 240),
note: safeText(body.note != null ? body.note : current.note || '', 180),
updated_at: new Date().toISOString(),
};
if (body.clearApiKey) delete next.apiKey;
else if (typeof body.apiKey === 'string' && body.apiKey.trim()) next.apiKey = body.apiKey.trim();
const out = profiles.filter((p) => p.id !== id);
out.push(next);
await writeAiProfilesRaw(out);
return { ok: true, profile: sanitizeAiProfile(next), profiles: out.map(sanitizeAiProfile) };
}
function envForAiProfile(p) {
const env = {};
if (p.provider === 'anthropic') {
if (p.apiKey) env.ANTHROPIC_API_KEY = p.apiKey;
} else if (p.provider === 'deepseek') {
if (p.apiKey) {
env.DEEPSEEK_API_KEY = p.apiKey;
env.OPENAI_API_KEY = p.apiKey;
}
env.OPENAI_BASE_URL = p.baseUrl || 'https://api.deepseek.com/v1';
env.OPENAI_API_BASE = env.OPENAI_BASE_URL;
if (p.model) env.OPENAI_MODEL = p.model;
} else if (p.provider === 'openai') {
if (p.apiKey) env.OPENAI_API_KEY = p.apiKey;
if (p.baseUrl) {
env.OPENAI_BASE_URL = p.baseUrl;
env.OPENAI_API_BASE = p.baseUrl;
}
if (p.model) env.OPENAI_MODEL = p.model;
}
env.BOTCOM_AI_PROFILE = p.id;
return env;
}
async function writeAiEnvScript(p) {
const env = envForAiProfile(p);
const secretKeys = Object.keys(env).filter((k) => /KEY|TOKEN|SECRET/i.test(k));
if (!secretKeys.length && !Object.keys(env).some((k) => k === 'OPENAI_BASE_URL' || k === 'OPENAI_MODEL')) {
return { ok: false, error: '先录入 API Key 或 base URL' };
}
await fsp.mkdir(AI_ENV_DIR, { recursive: true });
const file = path.join(AI_ENV_DIR, `${p.id}.env.sh`);
const lines = [
'# Generated by BotCom Workbench. Local-only; do not commit.',
...Object.entries(env).map(([k, v]) => `export ${k}=${shellQuote(v)}`),
'',
];
await fsp.writeFile(file, lines.join('\n'), { mode: 0o600 });
await fsp.chmod(file, 0o600).catch(() => {});
return { ok: true, file };
}
async function aiLaunchCommand(body) {
const id = safeText(body.profileId || body.id || '', 64);
const profiles = await readAiProfilesRaw();
const p = profiles.find((x) => x.id === id);
if (!p) return { ok: false, error: 'profile not found' };
const envScript = await writeAiEnvScript(p);
if (!envScript.ok) return envScript;
const tool = normalizeAiTool(body.tool || p.tool);
const model = safeText(body.model != null ? body.model : p.model || '', 120);
const src = `. ${shellQuote(envScript.file)}`;
let cmd = src;
if (tool === 'claude') {
const bin = await findAgentBin('claude');
if (!bin) return { ok: false, error: '未找到 claude 命令' };
cmd += ` && claude${model ? ` --model ${shellQuote(model)}` : ''}`;
} else if (tool === 'codex') {
const bin = await findAgentBin('codex');
if (!bin) return { ok: false, error: '未找到 codex 命令' };
cmd += ` && codex${model ? ` -m ${shellQuote(model)}` : ''}`;
} else {
cmd += ` && echo ${shellQuote(`BotCom AI profile "${p.name || p.id}" loaded. You can now run any compatible CLI in this terminal.`)}`;
}
return {
ok: true,
cwd: HOME,
cmd,
label: `${p.name || p.id} 已加载到终端`,
envScript: envScript.file,
profile: sanitizeAiProfile(p),
};
}
async function copyIfMissing(src, dest) {
try {
await fsp.access(dest);
return { path: dest, created: false };
} catch { /* create below */ }
await fsp.mkdir(path.dirname(dest), { recursive: true });
await fsp.copyFile(src, dest);
return { path: dest, created: true };
}
async function writeTextIfMissing(dest, text) {
try {
await fsp.access(dest);
return { path: dest, created: false };
} catch { /* create below */ }
await fsp.mkdir(path.dirname(dest), { recursive: true });
await fsp.writeFile(dest, text, 'utf8');
return { path: dest, created: true };
}
function setupStarterFiles() {
const wb = BOTCOM_ROOTS.workbench;
return [
['00_Start_Here.md', `# Start here
This is your local one-person company workspace.
Suggested first steps:
1. Define your positioning and offer in \`01_Strategy/ICP_and_offer.md\`.
2. Use BotCom OS to review acquisition, content, customer, delivery, revenue, assets, and automation status.
3. Configure model/API profiles from the desktop app before asking agents to work.
4. Keep credentials out of this folder unless they are in a local ignored/private file.
`],
['01_Strategy/ICP_and_offer.md', `# ICP and offer
## Target customer
## Pain / desire
## Promise
## Offer ladder
## Constraints and non-goals
`],
['02_Content/README.md', '# Content\n\nIdeas, scripts, posts, thumbnails, video plans, and publishing notes.\n'],
['03_Customers/README.md', '# Customers\n\nLeads, customer notes, community follow-up, and support summaries.\n'],
['04_Delivery/README.md', '# Delivery\n\nClient/project delivery plans, SOPs, acceptance evidence, and reusable templates.\n'],
['05_Revenue/README.md', '# Revenue\n\nProducts, offers, invoices, affiliate/commerce revenue, costs, and P&L exports.\n'],
['06_Assets/README.md', '# Assets\n\nPrompts, brand assets, examples, datasets, generated media, and knowledge base material.\n'],
['07_Automation/README.md', '# Automation\n\nRecurring workflows, scripts, agent instructions, review policies, and retrospectives.\n'],
].map(([rel, text]) => ({ rel, path: path.join(wb, rel), text }));
}
async function botcomSetupStatus() {
const adapters = await readOperatingAdapters();
const starter = setupStarterFiles();
return {
ok: true,
roots: { home: BOTCOM_HOME, workbench: BOTCOM_ROOTS.workbench, adapters: BOTCOM_ROOTS.adapters, mediaOps: BOTCOM_ROOTS.mediaOps },
exists: {
home: fileStatSafe(BOTCOM_HOME).exists,
workbench: fileStatSafe(BOTCOM_ROOTS.workbench).exists,
adapters: fileStatSafe(BOTCOM_ROOTS.adapters).exists,
mediaOps: fileStatSafe(BOTCOM_ROOTS.mediaOps).exists,
},
adapters: { count: adapters.count, errors: adapters.errors },
starterFiles: starter.map((x) => ({ rel: x.rel, exists: fileStatSafe(x.path).exists })),
};
}
async function botcomSetupAction(body) {
const action = safeText(body && body.action || 'initialize', 40);
if (action !== 'initialize') return { ok: false, error: 'invalid setup action' };
const made = [];
await fsp.mkdir(BOTCOM_HOME, { recursive: true }); made.push({ type: 'dir', path: BOTCOM_HOME });
await fsp.mkdir(BOTCOM_ROOTS.workbench, { recursive: true }); made.push({ type: 'dir', path: BOTCOM_ROOTS.workbench });
await fsp.mkdir(BOTCOM_ROOTS.adapters, { recursive: true }); made.push({ type: 'dir', path: BOTCOM_ROOTS.adapters });
for (const f of setupStarterFiles()) made.push({ type: 'file', ...(await writeTextIfMissing(f.path, f.text)) });
const exampleDir = path.join(__dirname, 'examples', 'adapters');
try {
const files = (await fsp.readdir(exampleDir)).filter((f) => f.endsWith('.json'));
for (const f of files) made.push({ type: 'adapter', ...(await copyIfMissing(path.join(exampleDir, f), path.join(BOTCOM_ROOTS.adapters, f))) });
} catch (err) {
made.push({ type: 'warning', path: exampleDir, error: safeText(err.message, 120) });
}
const status = await botcomSetupStatus();
return { ok: true, made, status };
}
// 最近几次整理日志的一句话摘要,给 agent 当历史参照(日志由 agent 按 brief 约定写入)
async function organizeHistory() {
let files = [];
try { files = (await fsp.readdir(ORGANIZE_LOG_DIR)).filter((f) => f.endsWith('.json')); } catch { return ''; }
files.sort().reverse();
const lines = [];
for (const f of files.slice(0, 3)) {
try {
const log = JSON.parse(await fsp.readFile(path.join(ORGANIZE_LOG_DIR, f), 'utf8'));
const m0 = (log.moves || [])[0];
const sample = m0 ? `(如 ${path.basename(m0.from)} → ${path.relative(log.dir, m0.to)})` : '';
lines.push(`- ${new Date(log.at).toLocaleString('zh-CN')} 整理过 ${log.dir},移动 ${(log.moves || []).length} 项${sample}`);
} catch { /* 坏日志跳过 */ }
}
return lines.join('\n');
}
// 备料并返回终端启动命令:brief 写盘(偏好 + 历史 + 工作约定),前端用 term.runInDir 拉起交互式 agent
async function organizeLaunch(b) {
const dir = resolvePath(b.path);
const cfg = await readConfig();
let engine = cfg.organizeEngine === 'codex' ? 'codex' : 'claude';
let bin = await findAgentBin(engine);
if (!bin) {
const alt = engine === 'claude' ? 'codex' : 'claude';
bin = await findAgentBin(alt);
if (bin) engine = alt;
else return { ok: false, error: '没找到 claude / codex 命令——AI 整理需要装其中一个 CLI' };
}
const prefs = await fsp.readFile(ORGANIZE_PREFS_FILE, 'utf8').catch(() => '');
const history = await organizeHistory();
const brief = `# AI 整理任务(BotCom Workbench 生成,每次启动覆盖本文件)
你在BotCom Workbench的内嵌终端里,帮用户对话式整理这个文件夹:${dir}
## 工作流程
1. 先看现状:列出当前文件夹的松散文件(名字/类型/大小/修改时间)。文件夹和隐藏文件一律不动
2. 结合下面的整理偏好与历史,提出分组整理方案摊给用户——用户明确同意前,一个文件都不要动
3. 用户可能口头调整方案(「截图不动」「这几个归到XX」),以对话为准
4. 动手用 mv 移动,目标目录不存在先 mkdir -p
5. 每完成一批移动,按下面的格式写一份回滚日志,并告诉用户「想撤销随时说」
6. 收尾:把这次对话里新学到的用户偏好(规则/例外/纠正)一条一行追加进偏好文件,别重复已有条目
## 回滚日志(撤销能力全靠它,格式不能错)
每批移动写一个新文件 ${ORGANIZE_LOG_DIR}/<毫秒时间戳>.json,内容:
{"dir":"${dir}","at":<毫秒时间戳>,"moves":[{"from":"<移动前绝对路径>","to":"<移动后绝对路径>"}]}
用户要撤销时:读对应日志,逐条把 to 移回 from(from 位置已被占用的跳过并说明)
## 整理偏好(用户的长期规则,优先级最高)
${DEFAULT_ORGANIZE_STRATEGY}
${prefs.trim() ? `\n### 历次整理沉淀的偏好\n${prefs.trim()}\n` : ''}
## 偏好文件
${ORGANIZE_PREFS_FILE}(markdown 列表,新偏好追加在末尾)
## 最近整理历史
${history || '(还没有历史记录)'}
`;
await fsp.mkdir(ORGANIZE_LOG_DIR, { recursive: true });
await fsp.writeFile(ORGANIZE_BRIEF_FILE, brief, 'utf8');
const kickoff = `先完整读 ${ORGANIZE_BRIEF_FILE},然后按里面的约定,和我对话式整理当前文件夹`;
// claude 跳权限确认(动手前方案已过人);codex 旗标按当前版本实测拼出
const cmd = engine === 'codex'
? `codex${await codexOrganizeFlags(bin)} "${kickoff}"`
: `claude --dangerously-skip-permissions "${kickoff}"`;
return { ok: true, engine, cmd };
}
// ---------- 发版向导:检查项目状态 → 改版本号/CHANGELOG → 命令序列交给内嵌终端跑(每步可见可拦)----------
async function releaseInspect(p) {
const dir = resolvePath(p);
const sh = (cmd, args) => new Promise((resolve) => execFile(cmd, args, { cwd: dir, timeout: 8000 }, (err, stdout) => resolve(err ? null : String(stdout).trim())));
let pkg;
try { pkg = JSON.parse(await fsp.readFile(path.join(dir, 'package.json'), 'utf8')); }
catch { return { ok: false, error: '这里没有 package.json——发版向导目前只认 node 项目' }; }
const out = { ok: true, dir, name: pkg.name || path.basename(dir), version: pkg.version || '0.0.0' };
out.hasDist = !!(pkg.scripts && pkg.scripts.dist);
out.remote = await sh('git', ['remote', 'get-url', 'origin']);
out.branch = await sh('git', ['rev-parse', '--abbrev-ref', 'HEAD']);
const status = await sh('git', ['status', '--porcelain']);
out.isRepo = status !== null;
out.dirty = !!(status && status.length);
out.gh = !!(await sh('/bin/sh', ['-lc', 'command -v gh']));
out.unreleased = ''; out.hasChangelog = false;
try {
const cl = await fsp.readFile(path.join(dir, 'CHANGELOG.md'), 'utf8');
out.hasChangelog = true;
const m = cl.match(/## \[Unreleased\]\s*([\s\S]*?)(?=\n## \[|$)/);
if (m) out.unreleased = m[1].trim();
} catch { /* 没有 CHANGELOG 不挡发版 */ }
return out;
}
async function releasePrepare(b) {
const dir = resolvePath(b.path);
const version = String(b.version || '').trim();
if (!/^\d+\.\d+\.\d+/.test(version)) return { ok: false, error: '版本号格式不对(要 x.y.z)' };
const notes = String(b.notes || '').trim();
// 1) package.json 版本号
const pkgFile = path.join(dir, 'package.json');
let pkgRaw;
try { pkgRaw = await fsp.readFile(pkgFile, 'utf8'); } catch { return { ok: false, error: '读不到 package.json' }; }
if (!/"version"\s*:\s*"[^"]*"/.test(pkgRaw)) return { ok: false, error: 'package.json 里没有 version 字段' };
await fsp.writeFile(pkgFile, pkgRaw.replace(/"version"\s*:\s*"[^"]*"/, `"version": "${version}"`), 'utf8');
// 2) CHANGELOG:Unreleased 段落升格为新版本,开新的空 Unreleased
const clFile = path.join(dir, 'CHANGELOG.md');
try {
const cl = await fsp.readFile(clFile, 'utf8');
if (cl.includes('## [Unreleased]')) {
const date = new Date().toISOString().slice(0, 10);
const next = cl.replace(/## \[Unreleased\][\s\S]*?(?=\n## \[|$)/, `## [Unreleased]\n\n## [${version}] - ${date}\n\n${notes}\n\n`);
await fsp.writeFile(clFile, next, 'utf8');
}
} catch { /* 没有 CHANGELOG 跳过 */ }
// 3) 发布说明落临时文件给 gh 用;命令序列拼好交还前端注入终端
const notesFile = path.join(os.tmpdir(), `botcom-release-notes-${Date.now()}.md`);
await fsp.writeFile(notesFile, notes || `v${version}`, 'utf8');
// 标题优先取第一个要点的内容,「### Added」这类小节头当不了标题
const lines = notes.split('\n').map((l) => l.trim()).filter(Boolean);
const firstBullet = lines.find((l) => /^[-*]\s/.test(l));
const firstPlain = lines.find((l) => !/^#/.test(l));
const title = (firstBullet || firstPlain || '').replace(/^[#\-*\s]+/, '').slice(0, 60);
const steps = [];
if (b.doDist) steps.push('npm run dist');
steps.push('git add -A', `git commit -m ${shellQuote(`v${version}: ${title || '发版'}`)}`);
if (b.doPush) steps.push('git push');
if (b.doRelease) steps.push(`gh release create v${version} --title ${shellQuote(`v${version}${title ? ' · ' + title : ''}`)} --notes-file ${shellQuote(notesFile)}${b.doDist ? ` dist/*${version}*.dmg` : ''}`);
return { ok: true, cmd: steps.join(' && ') };
}
// ---------- 项目记忆:这个文件夹里 AI 干过什么 ----------
// 数据源:~/.claude/projects/<munge(cwd)>/*.jsonl + ~/.codex/sessions/**/rollout-*.jsonl(头部 cwd 匹配)。
// 单会话解析结果按 (size, mtime) 缓存,再次打开只重解析有变化的文件。
const projMemCache = new Map(); // file -> { size, mtimeMs, sess }
const mungeClaudeDir = (cwd) => cwd.replace(/[^A-Za-z0-9]/g, '-');
async function parseClaudeSession(fp, st) {
const hit = projMemCache.get(fp);
if (hit && hit.size === st.size && hit.mtimeMs === st.mtimeMs) return hit.sess;
const sess = { id: path.basename(fp, '.jsonl'), agent: 'claude', title: '', firstT: 0, lastT: st.mtimeMs, userMsgs: 0, files: [], skills: [] };
const filesSet = new Set(), skillsSet = new Set();
// 流式逐行,廉价字符串预判后才 JSON.parse——大会话文件也不整读进内存
const stream = fs.createReadStream(fp, { encoding: 'utf8' });
let rest = '';
const handleLine = (line) => {
if (!sess.firstT) {
const m = line.match(/"timestamp":"([^"]+)"/);
if (m) sess.firstT = Date.parse(m[1]) || 0;
}
if (line.includes('"type":"user"') && !line.includes('"isMeta":true') && !line.includes('"tool_use_id"')) {
sess.userMsgs++;
if (!sess.title) {
try {
const d = JSON.parse(line);
const c = d.message && d.message.content;
let text = typeof c === 'string' ? c : (Array.isArray(c) ? (c.find((x) => x.type === 'text') || {}).text || '' : '');
text = text.trim();
if (text && !text.startsWith('<') && !text.startsWith('Caveat:')) sess.title = text.slice(0, 160);
} catch { /* */ }
}
}
if (line.includes('"file_path"') && /"name":"(Write|Edit|MultiEdit|NotebookEdit)"/.test(line)) {
try {
const d = JSON.parse(line);
const content = d.message && Array.isArray(d.message.content) ? d.message.content : [];
for (const it of content) {
if (it.type === 'tool_use' && it.input && it.input.file_path) filesSet.add(it.input.file_path);
}
} catch { /* */ }
}
if (line.includes('"name":"Skill"')) {
try {
const d = JSON.parse(line);
const content = d.message && Array.isArray(d.message.content) ? d.message.content : [];
for (const it of content) {
if (it.type === 'tool_use' && it.name === 'Skill' && it.input && it.input.skill) skillsSet.add(String(it.input.skill).replace(/^.*:/, ''));