zapmyco 0.3.0 → 0.4.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.
@@ -1,14 +1,25 @@
1
1
  #!/usr/bin/env node
2
- import { D as __VERSION__, E as APP_NAME, T as eventBus, f as WebError, g as createLlmBasedAgent, m as ZapmycoErrorCode, o as logger, t as loadConfig } from "../loader-BNgN6Pz5.mjs";
2
+ import { D as buildSkillSnapshot, E as eventBus, M as __VERSION__, N as __require, O as loadSkills, _ as createLlmBasedAgent, a as SubAgentManager, h as ZapmycoErrorCode, j as APP_NAME, p as WebError, s as logger, t as loadConfig } from "../loader-BK1Z72gI.mjs";
3
+ import { createHash, randomBytes } from "node:crypto";
4
+ import { mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
5
+ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
6
+ import * as os from "node:os";
7
+ import { homedir } from "node:os";
8
+ import * as path from "node:path";
9
+ import { dirname, isAbsolute, join, normalize, resolve } from "node:path";
10
+ import { EventEmitter } from "node:events";
3
11
  import chalk, { Chalk } from "chalk";
4
12
  import { Command } from "commander";
5
13
  import { getModel } from "@mariozechner/pi-ai";
6
14
  import { Container, Editor, Key, ProcessTerminal, TUI, matchesKey, truncateToWidth, wrapTextWithAnsi } from "@mariozechner/pi-tui";
15
+ import { spawn } from "node:child_process";
7
16
  import TurndownService from "turndown";
8
17
  import { lookup } from "node:dns/promises";
18
+ import { Client } from "@modelcontextprotocol/sdk/client";
19
+ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
9
20
 
10
21
  //#region src/cli/repl/command-registry.ts
11
- const log$1 = logger.child("repl:command-registry");
22
+ const log$5 = logger.child("repl:command-registry");
12
23
  /**
13
24
  * 命令注册表
14
25
  */
@@ -23,7 +34,7 @@ var CommandRegistry = class {
23
34
  */
24
35
  register(cmd) {
25
36
  const canonicalName = cmd.name.toLowerCase();
26
- if (this.commands.has(canonicalName)) log$1.warn(`命令 "${canonicalName}" 已存在,将被覆盖`);
37
+ if (this.commands.has(canonicalName)) log$5.warn(`命令 "${canonicalName}" 已存在,将被覆盖`);
27
38
  this.commands.set(canonicalName, cmd);
28
39
  for (const alias of cmd.aliases) {
29
40
  const lowerAlias = alias.toLowerCase();
@@ -51,7 +62,7 @@ var CommandRegistry = class {
51
62
  */
52
63
  async dispatch(parsed) {
53
64
  if (parsed.kind !== "command") {
54
- log$1.warn("dispatch 收到了非 command 类型的输入");
65
+ log$5.warn("dispatch 收到了非 command 类型的输入");
55
66
  return;
56
67
  }
57
68
  const cmd = this.getCommand(parsed.name);
@@ -63,7 +74,7 @@ var CommandRegistry = class {
63
74
  await cmd.handler(parsed.args, this.session);
64
75
  } catch (error) {
65
76
  const message = error instanceof Error ? error.message : String(error);
66
- log$1.error(`命令 /${cmd.name} 执行出错`, {}, error);
77
+ log$5.error(`命令 /${cmd.name} 执行出错`, {}, error);
67
78
  console.log(`\n 命令执行出错: ${message}\n`);
68
79
  }
69
80
  }
@@ -414,6 +425,644 @@ var ZapmycoEditor = class extends Editor {
414
425
  }
415
426
  };
416
427
 
428
+ //#endregion
429
+ //#region src/cli/repl/cron/cron-parser.ts
430
+ /** 通配符 * — 匹配所有值 */
431
+ var WildcardMatcher = class {
432
+ matches(_value) {
433
+ return true;
434
+ }
435
+ };
436
+ /** 步进(每 N 个值匹配一次) */
437
+ var StepMatcher = class {
438
+ step;
439
+ min;
440
+ constructor(step, min) {
441
+ this.step = step;
442
+ this.min = min;
443
+ }
444
+ matches(value) {
445
+ return (value - this.min) % this.step === 0;
446
+ }
447
+ };
448
+ /** 精确值 N */
449
+ var ValueMatcher = class {
450
+ accepted;
451
+ constructor(values) {
452
+ this.accepted = new Set(values);
453
+ }
454
+ matches(value) {
455
+ return this.accepted.has(value);
456
+ }
457
+ };
458
+ /** 范围 N-M */
459
+ var RangeMatcher = class {
460
+ start;
461
+ end;
462
+ constructor(start, end) {
463
+ this.start = start;
464
+ this.end = end;
465
+ }
466
+ matches(value) {
467
+ return value >= this.start && value <= this.end;
468
+ }
469
+ };
470
+ const FIELD_SPECS = [
471
+ {
472
+ name: "minute",
473
+ min: 0,
474
+ max: 59
475
+ },
476
+ {
477
+ name: "hour",
478
+ min: 0,
479
+ max: 23
480
+ },
481
+ {
482
+ name: "day-of-month",
483
+ min: 1,
484
+ max: 31
485
+ },
486
+ {
487
+ name: "month",
488
+ min: 1,
489
+ max: 12
490
+ },
491
+ {
492
+ name: "day-of-week",
493
+ min: 0,
494
+ max: 6
495
+ }
496
+ ];
497
+ /**
498
+ * 解析单个 cron 字段为 Matcher 数组
499
+ */
500
+ function parseField(field, spec) {
501
+ const trimmed = field.trim();
502
+ if (trimmed.length === 0) return null;
503
+ const matchers = [];
504
+ const parts = trimmed.split(",");
505
+ for (const part of parts) {
506
+ const p = part.trim();
507
+ if (p.length === 0) return null;
508
+ const matcher = parseFieldPart(p, spec);
509
+ if (!matcher) return null;
510
+ matchers.push(matcher);
511
+ }
512
+ return matchers.length > 0 ? matchers : null;
513
+ }
514
+ function parseFieldPart(part, spec) {
515
+ if (part.startsWith("*/")) {
516
+ const step = parseInt(part.slice(2), 10);
517
+ if (isNaN(step) || step < 1) return null;
518
+ return new StepMatcher(step, spec.min);
519
+ }
520
+ if (part === "*") return new WildcardMatcher();
521
+ const rangeMatch = part.match(/^(\d+)-(\d+)$/);
522
+ if (rangeMatch) {
523
+ const start = parseInt(rangeMatch[1] ?? "", 10);
524
+ const end = parseInt(rangeMatch[2] ?? "", 10);
525
+ if (isNaN(start) || isNaN(end)) return null;
526
+ if (start < spec.min || end > spec.max || start > end) return null;
527
+ return new RangeMatcher(start, end);
528
+ }
529
+ const value = parseInt(part, 10);
530
+ if (!isNaN(value) && value >= spec.min && value <= spec.max) return new ValueMatcher([value]);
531
+ return null;
532
+ }
533
+ /**
534
+ * 创建 CronSchedule
535
+ * @param expr 5-field cron 表达式
536
+ * @returns 解析后的 CronSchedule,无效表达式返回 null
537
+ */
538
+ function createCronSchedule(expr) {
539
+ const fields = expr.trim().split(/\s+/);
540
+ if (fields.length !== 5) return null;
541
+ const parsedFields = [];
542
+ for (let i = 0; i < 5; i++) {
543
+ const matchers = parseField(fields[i] ?? "", FIELD_SPECS[i]);
544
+ if (!matchers) return null;
545
+ parsedFields.push(matchers);
546
+ }
547
+ return {
548
+ fields: parsedFields,
549
+ description: buildDescription(fields, parsedFields),
550
+ nextFrom(from) {
551
+ return computeNextFrom(from, parsedFields);
552
+ }
553
+ };
554
+ }
555
+ /**
556
+ * 解析 cron 表达式
557
+ * @param expr 5-field cron 表达式
558
+ * @returns 解析后的 CronSchedule,无效表达式返回 null
559
+ */
560
+ function parseCron(expr) {
561
+ return createCronSchedule(expr);
562
+ }
563
+ /**
564
+ * 计算月份的最后一天
565
+ */
566
+ function lastDayOfMonth(year, month) {
567
+ return new Date(year, month, 0).getDate();
568
+ }
569
+ /**
570
+ * 计算下一次触发时间
571
+ *
572
+ * 算法:从起始时间开始逐分钟递增,直到所有字段匹配。
573
+ * 使用月份/日期边界跳跃优化,避免过多次循环。
574
+ */
575
+ function computeNextFrom(from, fields) {
576
+ const maxDate = new Date(from);
577
+ maxDate.setFullYear(maxDate.getFullYear() + 2);
578
+ let current = new Date(from.getTime() + 6e4);
579
+ current.setSeconds(0, 0);
580
+ let iterations = 0;
581
+ const MAX_ITERATIONS = 366 * 24 * 60;
582
+ while (current.getTime() <= maxDate.getTime()) {
583
+ iterations++;
584
+ if (iterations > MAX_ITERATIONS) return null;
585
+ const month = current.getMonth() + 1;
586
+ if (!matchersAnyMatch(month, fields[3])) {
587
+ current.setMonth(current.getMonth() + 1, 1);
588
+ current.setHours(0, 0, 0, 0);
589
+ continue;
590
+ }
591
+ const dom = current.getDate();
592
+ const maxDom = lastDayOfMonth(current.getFullYear(), month);
593
+ if (dom > maxDom || !matchersAnyMatch(dom <= maxDom ? dom : maxDom, fields[2])) {
594
+ const nextDay = new Date(current);
595
+ nextDay.setDate(nextDay.getDate() + 1);
596
+ nextDay.setHours(0, 0, 0, 0);
597
+ if (nextDay.getDate() === 1) current = nextDay;
598
+ else {
599
+ current.setDate(current.getDate() + 1);
600
+ current.setHours(0, 0, 0, 0);
601
+ }
602
+ continue;
603
+ }
604
+ if (!matchersAnyMatch(current.getDay(), fields[4])) {
605
+ current.setDate(current.getDate() + 1);
606
+ current.setHours(0, 0, 0, 0);
607
+ continue;
608
+ }
609
+ const hour = current.getHours();
610
+ if (!matchersAnyMatch(hour, fields[1])) {
611
+ current.setHours(hour + 1, 0, 0, 0);
612
+ continue;
613
+ }
614
+ const minute = current.getMinutes();
615
+ if (matchersAnyMatch(minute, fields[0])) return new Date(current);
616
+ current.setMinutes(minute + 1, 0, 0);
617
+ }
618
+ return null;
619
+ }
620
+ function matchersAnyMatch(value, matchers) {
621
+ for (const m of matchers) if (m.matches(value)) return true;
622
+ return false;
623
+ }
624
+ function buildDescription(rawFields, _parsedFields) {
625
+ const parts = [];
626
+ parts.push(describeField(rawFields[0] ?? "*", "分钟", "每", ""));
627
+ parts.push(describeField(rawFields[1] ?? "*", "小时", "", "点"));
628
+ const domStr = describeField(rawFields[2] ?? "*", "日", "每月", "号");
629
+ if (domStr.length > 0 && rawFields[2] !== "*") parts.push(domStr);
630
+ const monthStr = describeField(rawFields[3] ?? "*", "月", "", "月");
631
+ if (monthStr.length > 0 && rawFields[3] !== "*") parts.push(monthStr);
632
+ const dowStr = describeDow(rawFields[4] ?? "*");
633
+ if (dowStr.length > 0 && rawFields[4] !== "*") parts.push(dowStr);
634
+ if (parts.length === 0) return "每分钟";
635
+ return parts.join(" ");
636
+ }
637
+ function describeField(raw, unit, prefix, suffix) {
638
+ if (raw === "*") return "";
639
+ if (raw.startsWith("*/")) return `${prefix}每${raw.slice(2)}${unit}${suffix}`;
640
+ if (raw.includes(",")) return `${prefix}${raw}${unit}${suffix}`;
641
+ if (raw.includes("-")) return `${prefix}${raw.replace("-", "到")}${unit}${suffix}`;
642
+ return `${prefix}${raw}${unit}${suffix}`;
643
+ }
644
+ function describeDow(raw) {
645
+ const DOW_NAMES = {
646
+ "0": "周日",
647
+ "1": "周一",
648
+ "2": "周二",
649
+ "3": "周三",
650
+ "4": "周四",
651
+ "5": "周五",
652
+ "6": "周六"
653
+ };
654
+ if (raw === "*") return "";
655
+ if (raw.startsWith("*/")) return "";
656
+ if (raw.includes("-")) {
657
+ const parts = raw.split("-");
658
+ const start = parts[0] ?? "";
659
+ const end = parts[1] ?? "";
660
+ return `${DOW_NAMES[start] ?? start}到${DOW_NAMES[end] ?? end}`;
661
+ }
662
+ if (raw.includes(",")) return raw.split(",").map((d) => DOW_NAMES[d.trim()] ?? d.trim()).join("、");
663
+ return DOW_NAMES[raw] ?? raw;
664
+ }
665
+ /**
666
+ * 检测启动时错过的一次性任务
667
+ * 如果 nextFrom(createdAt) < now 且 lastFiredAt 为空,说明错过了
668
+ */
669
+ function getMissedOneShotJobs(jobs, now) {
670
+ const missed = [];
671
+ for (const job of jobs) {
672
+ if (job.lastFiredAt) continue;
673
+ const schedule = parseCron(job.cron);
674
+ if (!schedule) continue;
675
+ const next = schedule.nextFrom(new Date(job.createdAt));
676
+ if (next && next.getTime() < now.getTime()) missed.push({
677
+ id: job.id,
678
+ createdAt: job.createdAt
679
+ });
680
+ }
681
+ return missed;
682
+ }
683
+
684
+ //#endregion
685
+ //#region src/cli/repl/cron/types.ts
686
+ const CRON_CONSTANTS = {
687
+ /** 最大任务数 */
688
+ MAX_JOBS: 50,
689
+ /** prompt 最大长度 */
690
+ MAX_PROMPT_LENGTH: 2e3,
691
+ /** 调度检查间隔(毫秒) */
692
+ CHECK_INTERVAL_MS: 1e3,
693
+ /** recurring 任务自动过期天数 */
694
+ AUTO_EXPIRE_DAYS: 7,
695
+ /** 最大过期天数上限 */
696
+ MAX_AUTO_EXPIRE_DAYS: 30,
697
+ /** 一次性错过任务最多补发次数 */
698
+ MAX_ONESHOT_MISSED_FIRE_COUNT: 5,
699
+ /** 补发任务间隔(毫秒) */
700
+ MISSED_FIRE_STAGGER_MS: 5e3
701
+ };
702
+
703
+ //#endregion
704
+ //#region src/cli/repl/cron/cron-scheduler.ts
705
+ /**
706
+ * CronScheduler — 定时任务调度引擎
707
+ *
708
+ * 在 REPL 会话中运行,1 秒间隔检查到期任务。
709
+ * 仅在 REPL 空闲时触发任务,使用确定性抖动分散负载。
710
+ *
711
+ * 设计要点:
712
+ * - 1s setInterval 检查循环
713
+ * - idle 门控: 仅当 REPL state === 'idle' 时触发
714
+ * - timer.unref(): 不阻止进程退出
715
+ * - 确定性 jitter: 基于 jobId 哈希
716
+ * - 7 天自动过期: recurring 任务到期前触发最后一次
717
+ * - 启动补发: 处理跨会话错过的一次性任务
718
+ *
719
+ * @module cli/repl/cron/cron-scheduler
720
+ */
721
+ const log$4 = logger.child("cron:scheduler");
722
+ var CronScheduler = class extends EventEmitter {
723
+ store;
724
+ jobs = [];
725
+ sessionJobs = [];
726
+ timer = null;
727
+ isIdle;
728
+ checkIntervalMs;
729
+ running = false;
730
+ missedAsked = /* @__PURE__ */ new Set();
731
+ constructor(store, options) {
732
+ super();
733
+ this.store = store;
734
+ this.isIdle = options.isIdle;
735
+ this.checkIntervalMs = options.checkIntervalMs ?? CRON_CONSTANTS.CHECK_INTERVAL_MS;
736
+ }
737
+ /** 启动调度器:加载 durable 任务,启动检查循环 */
738
+ async start() {
739
+ if (this.running) return;
740
+ const loadedJobs = await this.store.load();
741
+ this.jobs = loadedJobs;
742
+ log$4.info(`调度器启动,加载 ${loadedJobs.length} 个 durable 任务`);
743
+ await this.handleMissedJobs();
744
+ this.checkAutoExpiry();
745
+ this.running = true;
746
+ this.timer = setInterval(() => {
747
+ this.tick();
748
+ }, this.checkIntervalMs);
749
+ if (this.timer && typeof this.timer.unref === "function") this.timer.unref();
750
+ }
751
+ /** 停止调度器 */
752
+ stop() {
753
+ this.running = false;
754
+ if (this.timer) {
755
+ clearInterval(this.timer);
756
+ this.timer = null;
757
+ }
758
+ log$4.info("调度器已停止");
759
+ }
760
+ /** 添加任务 */
761
+ async addJob(job) {
762
+ if (this.jobs.length + this.sessionJobs.length >= CRON_CONSTANTS.MAX_JOBS) return `任务数已达上限(${CRON_CONSTANTS.MAX_JOBS})`;
763
+ const schedule = parseCron(job.cron);
764
+ if (!schedule) return `无效的 cron 表达式: ${job.cron}`;
765
+ if (!schedule.nextFrom(/* @__PURE__ */ new Date())) return `cron 表达式在未来 365 天内无匹配: ${job.cron}`;
766
+ if (job.durable) {
767
+ this.jobs.push(job);
768
+ await this.store.persist(this.jobs);
769
+ } else this.sessionJobs.push(job);
770
+ log$4.info("任务已添加", {
771
+ id: job.id,
772
+ cron: job.cron,
773
+ durable: job.durable
774
+ });
775
+ return null;
776
+ }
777
+ /** 删除任务 */
778
+ async removeJob(id) {
779
+ const sessionIdx = this.sessionJobs.findIndex((j) => j.id === id);
780
+ if (sessionIdx >= 0) {
781
+ this.sessionJobs.splice(sessionIdx, 1);
782
+ return true;
783
+ }
784
+ const idx = this.jobs.findIndex((j) => j.id === id);
785
+ if (idx >= 0) {
786
+ this.jobs.splice(idx, 1);
787
+ await this.store.persist(this.jobs);
788
+ return true;
789
+ }
790
+ return false;
791
+ }
792
+ /** 更新任务 */
793
+ async updateJob(id, updates) {
794
+ const job = this.findJob(id);
795
+ if (!job) return `任务未找到: ${id}`;
796
+ if (updates.cron !== void 0) {
797
+ const schedule = parseCron(updates.cron);
798
+ if (!schedule) return `无效的 cron 表达式: ${updates.cron}`;
799
+ if (!schedule.nextFrom(/* @__PURE__ */ new Date())) return `cron 表达式在未来 365 天内无匹配: ${updates.cron}`;
800
+ }
801
+ Object.assign(job, updates);
802
+ if (job.durable) await this.store.persist(this.jobs);
803
+ return null;
804
+ }
805
+ /** 获取所有任务 */
806
+ getJobs() {
807
+ return [...this.jobs, ...this.sessionJobs];
808
+ }
809
+ /** 获取调度器状态 */
810
+ async getStatus() {
811
+ const allJobs = this.getJobs();
812
+ return {
813
+ running: this.running,
814
+ jobCount: allJobs.length,
815
+ enabledCount: allJobs.filter((j) => j.enabled).length,
816
+ durableCount: this.jobs.length,
817
+ sessionCount: this.sessionJobs.length
818
+ };
819
+ }
820
+ /** 立即触发指定任务(不改变调度) */
821
+ async triggerJob(id) {
822
+ const job = this.findJob(id);
823
+ if (!job) return `任务未找到: ${id}`;
824
+ if (!job.enabled) return `任务已暂停: ${id}`;
825
+ this.emit("fire", { job });
826
+ job.lastFiredAt = Date.now();
827
+ job.fireCount++;
828
+ if (job.durable) await this.store.persist(this.jobs);
829
+ return null;
830
+ }
831
+ /** 定时检查循环 */
832
+ async tick() {
833
+ if (!this.running) return;
834
+ if (!this.isIdle()) return;
835
+ const now = Date.now();
836
+ const dueJobs = this.getDueJobs(now);
837
+ if (dueJobs.length === 0) return;
838
+ for (const job of dueJobs) {
839
+ if (!this.isIdle()) break;
840
+ this.emit("fire", { job });
841
+ job.lastFiredAt = now;
842
+ job.fireCount++;
843
+ if (!job.recurring) await this.removeJob(job.id);
844
+ if (job.maxFires && job.fireCount >= job.maxFires) await this.removeJob(job.id);
845
+ }
846
+ if (dueJobs.some((j) => j.durable)) await this.store.persist(this.jobs);
847
+ this.checkAutoExpiry();
848
+ }
849
+ /** 获取到期任务(已应用 jitter) */
850
+ getDueJobs(nowMs) {
851
+ const allJobs = this.getJobs();
852
+ const due = [];
853
+ for (const job of allJobs) {
854
+ if (!job.enabled) continue;
855
+ const schedule = parseCron(job.cron);
856
+ if (!schedule) continue;
857
+ const fromMs = job.lastFiredAt ?? job.createdAt;
858
+ const from = new Date(fromMs);
859
+ const rawNext = schedule.nextFrom(from);
860
+ if (!rawNext) continue;
861
+ let nextMs = rawNext.getTime();
862
+ if (job.recurring) nextMs = applyRecurringJitter(job.id, nextMs, fromMs, schedule, from);
863
+ else nextMs = applyOneShotJitter(job.id, rawNext);
864
+ if (nowMs >= nextMs) due.push(job);
865
+ }
866
+ return due;
867
+ }
868
+ findJob(id) {
869
+ return this.jobs.find((j) => j.id === id) ?? this.sessionJobs.find((j) => j.id === id);
870
+ }
871
+ /** 处理启动时错过的一次性任务 */
872
+ async handleMissedJobs() {
873
+ const oneShotJobs = this.jobs.filter((j) => !j.recurring && !j.lastFiredAt);
874
+ if (oneShotJobs.length === 0) return;
875
+ const missed = getMissedOneShotJobs(oneShotJobs, /* @__PURE__ */ new Date());
876
+ if (missed.length === 0) return;
877
+ const toFire = missed.slice(0, CRON_CONSTANTS.MAX_ONESHOT_MISSED_FIRE_COUNT);
878
+ const toDelete = missed.slice(CRON_CONSTANTS.MAX_ONESHOT_MISSED_FIRE_COUNT);
879
+ for (const m of toDelete) {
880
+ this.missedAsked.add(m.id);
881
+ await this.removeJob(m.id);
882
+ }
883
+ for (let i = 0; i < toFire.length; i++) {
884
+ const m = toFire[i];
885
+ if (!m) continue;
886
+ const job = this.jobs.find((j) => j.id === m.id);
887
+ if (!job) continue;
888
+ const delay = i * CRON_CONSTANTS.MISSED_FIRE_STAGGER_MS;
889
+ setTimeout(() => {
890
+ if (!this.missedAsked.has(job.id)) {
891
+ this.missedAsked.add(job.id);
892
+ this.emit("fire", { job });
893
+ job.lastFiredAt = Date.now();
894
+ job.fireCount++;
895
+ this.removeJob(job.id);
896
+ }
897
+ }, delay);
898
+ }
899
+ if (toDelete.length > 0) {
900
+ log$4.info(`跳过 ${toDelete.length} 个错过的一次性任务(超出补发上限)`);
901
+ this.emit("missed-overflow", {
902
+ count: toDelete.length,
903
+ jobIds: toDelete.map((m) => m.id)
904
+ });
905
+ }
906
+ }
907
+ /** 检查 7 天自动过期 */
908
+ checkAutoExpiry() {
909
+ const now = Date.now();
910
+ const autoExpireMs = CRON_CONSTANTS.AUTO_EXPIRE_DAYS * 24 * 60 * 60 * 1e3;
911
+ for (const job of this.jobs) {
912
+ if (!job.recurring) continue;
913
+ if (job.durable && now - job.createdAt >= autoExpireMs) {
914
+ this.emit("fire", { job });
915
+ job.lastFiredAt = now;
916
+ job.fireCount++;
917
+ this.removeJob(job.id);
918
+ log$4.info("任务已过期并触发最后一次", { id: job.id });
919
+ }
920
+ }
921
+ }
922
+ };
923
+ /**
924
+ * 基于 jobId 计算确定性抖动分数 [0, 1)
925
+ * 使用 SHA256 前 8 位 hex 转换,纯数字 ID 回退到 parseInt
926
+ */
927
+ function jitterFrac(jobId) {
928
+ if (/^[0-9a-fA-F]{8}$/.test(jobId)) {
929
+ const hash = createHash("sha256").update(jobId).digest("hex");
930
+ return parseInt(hash.slice(0, 8), 16) / 4294967295;
931
+ }
932
+ return 0;
933
+ }
934
+ /**
935
+ * 循环任务抖动:正向延迟
936
+ * 延迟 = jitterFrac * 10% * interval,上限 15 分钟
937
+ */
938
+ function applyRecurringJitter(jobId, rawNextMs, _fromMs, schedule, from) {
939
+ if (!schedule) return rawNextMs;
940
+ const next1 = schedule.nextFrom(from);
941
+ if (!next1) return rawNextMs;
942
+ const next2 = schedule.nextFrom(next1);
943
+ if (!next2) return rawNextMs;
944
+ const intervalMs = next2.getTime() - next1.getTime();
945
+ if (intervalMs <= 0) return rawNextMs;
946
+ const frac = jitterFrac(jobId);
947
+ const delay = Math.min(frac * .1 * intervalMs, 900 * 1e3);
948
+ return rawNextMs + Math.floor(delay);
949
+ }
950
+ /**
951
+ * 一次性任务抖动:反向(提前触发)
952
+ * 仅对 :00 和 :30 分钟时刻生效,最多提前 90 秒
953
+ */
954
+ function applyOneShotJitter(jobId, rawNext) {
955
+ const minutes = rawNext.getMinutes();
956
+ const ONE_SHOT_MINUTE_MOD = 30;
957
+ const ONE_SHOT_MAX_MS = 90 * 1e3;
958
+ const ONE_SHOT_FLOOR_MS = 0;
959
+ if (minutes % ONE_SHOT_MINUTE_MOD !== 0) return rawNext.getTime();
960
+ const early = ONE_SHOT_FLOOR_MS + jitterFrac(jobId) * (ONE_SHOT_MAX_MS - ONE_SHOT_FLOOR_MS);
961
+ return rawNext.getTime() - Math.floor(early);
962
+ }
963
+
964
+ //#endregion
965
+ //#region src/cli/repl/cron/cron-store.ts
966
+ /**
967
+ * CronStore — 定时任务持久化存储
968
+ *
969
+ * 管理 ~/.zapmyco/cron/scheduled_tasks.json 文件,
970
+ * 负责 durable 任务的跨会话恢复。
971
+ *
972
+ * 设计要点:
973
+ * - 原子写入: 先写 .tmp 再 rename(防止部分写入损坏)
974
+ * - 容错加载: JSON 损坏时降级为空列表,不阻塞启动
975
+ * - 全局单例: 与 MemoryStore 相同的单例模式
976
+ *
977
+ * @module cli/repl/cron/cron-store
978
+ */
979
+ const log$3 = logger.child("cron:store");
980
+ const STORE_FILE = join(join(homedir(), ".zapmyco", "cron"), "scheduled_tasks.json");
981
+ var CronStore = class {
982
+ filePath;
983
+ initialized = false;
984
+ constructor(customPath) {
985
+ if (customPath) this.filePath = join(customPath, "scheduled_tasks.json");
986
+ else this.filePath = STORE_FILE;
987
+ }
988
+ async initialize() {
989
+ if (this.initialized) return;
990
+ await mkdir(dirname(this.filePath), { recursive: true });
991
+ this.initialized = true;
992
+ }
993
+ /**
994
+ * 从文件加载 durable 任务
995
+ * JSON 损坏时返回空数组并记录警告,不抛异常
996
+ */
997
+ async load() {
998
+ await this.initialize();
999
+ try {
1000
+ const raw = await readFile(this.filePath, "utf-8");
1001
+ const data = JSON.parse(raw);
1002
+ if (!Array.isArray(data)) {
1003
+ log$3.warn("存储文件格式无效(非数组),将使用空列表");
1004
+ return [];
1005
+ }
1006
+ return this.validateJobs(data);
1007
+ } catch (err) {
1008
+ if (err.code === "ENOENT") return [];
1009
+ log$3.warn("加载定时任务文件失败,将使用空列表", { error: err instanceof Error ? err.message : String(err) });
1010
+ return [];
1011
+ }
1012
+ }
1013
+ /**
1014
+ * 将 durable 任务持久化到文件
1015
+ * 原子写入:先写 .tmp 再 rename
1016
+ */
1017
+ async persist(jobs) {
1018
+ await this.initialize();
1019
+ const durableJobs = jobs.filter((j) => j.durable);
1020
+ const tmpPath = `${this.filePath}.tmp`;
1021
+ await writeFile(tmpPath, JSON.stringify(durableJobs, null, 2), "utf-8");
1022
+ await rename(tmpPath, this.filePath);
1023
+ }
1024
+ /** 生成唯一 jobId(8 位 hex) */
1025
+ static generateId() {
1026
+ return randomBytes(4).toString("hex");
1027
+ }
1028
+ /**
1029
+ * 验证并清理加载的作业数据
1030
+ * 跳过无效条目,确保必需字段存在
1031
+ */
1032
+ validateJobs(raw) {
1033
+ const valid = [];
1034
+ for (const item of raw) {
1035
+ if (!item || typeof item !== "object") continue;
1036
+ const obj = item;
1037
+ if (typeof obj.id !== "string" || obj.id.length === 0) continue;
1038
+ if (typeof obj.cron !== "string" || obj.cron.length === 0) continue;
1039
+ if (typeof obj.prompt !== "string") continue;
1040
+ if (typeof obj.createdAt !== "number") continue;
1041
+ const job = {
1042
+ id: obj.id,
1043
+ cron: obj.cron,
1044
+ prompt: obj.prompt,
1045
+ createdAt: obj.createdAt,
1046
+ recurring: obj.recurring === true,
1047
+ durable: obj.durable !== false,
1048
+ enabled: obj.enabled !== false,
1049
+ fireCount: typeof obj.fireCount === "number" ? obj.fireCount : 0
1050
+ };
1051
+ if (typeof obj.lastFiredAt === "number") job.lastFiredAt = obj.lastFiredAt;
1052
+ if (typeof obj.lastError === "string") job.lastError = obj.lastError;
1053
+ if (typeof obj.maxFires === "number") job.maxFires = obj.maxFires;
1054
+ valid.push(job);
1055
+ }
1056
+ if (valid.length < raw.length) log$3.warn(`跳过 ${raw.length - valid.length} 个无效任务条目`);
1057
+ return valid;
1058
+ }
1059
+ };
1060
+ let globalStore$1 = null;
1061
+ function getCronStore() {
1062
+ if (!globalStore$1) globalStore$1 = new CronStore();
1063
+ return globalStore$1;
1064
+ }
1065
+
417
1066
  //#endregion
418
1067
  //#region src/cli/repl/history-store.ts
419
1068
  /** 默认最大历史条数 */
@@ -773,60 +1422,3535 @@ var OutputFormatter = class {
773
1422
  };
774
1423
 
775
1424
  //#endregion
776
- //#region src/cli/repl/renderer.ts
777
- /**
778
- * 终端输出渲染器(TUI 适配版)
779
- *
780
- * 在 pi-tui 架构下,Renderer 不再直接 console.log,
781
- * 而是将格式化后的内容追加到 OutputArea 组件中。
782
- */
1425
+ //#region src/cli/repl/renderer.ts
1426
+ /**
1427
+ * 终端输出渲染器(TUI 适配版)
1428
+ *
1429
+ * 在 pi-tui 架构下,Renderer 不再直接 console.log,
1430
+ * 而是将格式化后的内容追加到 OutputArea 组件中。
1431
+ */
1432
+ /**
1433
+ * 渲染器实现
1434
+ *
1435
+ * 协调 OutputFormatter 和 OutputArea 之间的内容输出。
1436
+ */
1437
+ var Renderer = class {
1438
+ formatter;
1439
+ constructor(opts) {
1440
+ this.formatter = new OutputFormatter(opts.color);
1441
+ }
1442
+ /** 获取底层格式化器(供 OutputArea 直接使用) */
1443
+ getFormatter() {
1444
+ return this.formatter;
1445
+ }
1446
+ /** 渲染欢迎信息 → 返回格式化行 */
1447
+ renderWelcome(version) {
1448
+ return this.formatter.formatWelcome(version);
1449
+ }
1450
+ /** 渲染错误信息 → 返回格式化行 */
1451
+ renderError(error) {
1452
+ return this.formatter.formatError(error);
1453
+ }
1454
+ /** 渲染最终执行结果 → 返回格式化行 */
1455
+ renderResult(result) {
1456
+ return this.formatter.formatResult(result);
1457
+ }
1458
+ /** 渲染任务拆分概览 → 返回格式化行 */
1459
+ renderTaskGraph(graph) {
1460
+ return this.formatter.formatTaskGraph(graph);
1461
+ }
1462
+ /** 渲染 Agent 列表 → 返回格式化行 */
1463
+ renderAgents(agents) {
1464
+ return this.formatter.formatAgents(agents);
1465
+ }
1466
+ /** 渲染配置信息 → 返回格式化行 */
1467
+ renderConfig(config) {
1468
+ return this.formatter.formatConfig(config);
1469
+ }
1470
+ /** 渲染历史记录 → 返回格式化行 */
1471
+ renderHistory(entries) {
1472
+ return this.formatter.formatHistory(entries);
1473
+ }
1474
+ /** 渲染会话状态 → 返回格式化行 */
1475
+ renderStatus(stats) {
1476
+ return this.formatter.formatStatus(stats);
1477
+ }
1478
+ };
1479
+
1480
+ //#endregion
1481
+ //#region src/cli/repl/tools/cron-tool.ts
1482
+ /**
1483
+ * scheduled_task 工具实现 — 定时任务管理
1484
+ *
1485
+ * 参考 Claude Code 的 CronCreate/CronDelete/CronList 三工具设计
1486
+ * 和 OpenClaw/Hermes-Agent 的单工具 + action 模式,
1487
+ * 结合 zapmyco 现有 memory 工具的 action 路由风格。
1488
+ *
1489
+ * 设计要点:
1490
+ * - 单工具 + 8 action: create/list/update/remove/pause/resume/run/status
1491
+ * - 工厂函数 createCronTool(scheduler) 注入依赖
1492
+ * - 5-field cron 表达式,自定义解析器零外部依赖
1493
+ *
1494
+ * @module cli/repl/tools/cron-tool
1495
+ */
1496
+ const CRON_TOOL_DESCRIPTION = `定时任务管理工具 — 创建和管理按 cron 表达式触发的自动化任务。
1497
+
1498
+ ## 何时使用
1499
+ - 用户要求"每天早上 9 点检查 XX" → create 循环任务
1500
+ - 用户要求"5 分钟后提醒我" → 计算 cron 表达式并 create
1501
+ - 用户要求"列出所有定时任务" → list
1502
+ - 用户要求"取消/暂停 XX 任务" → remove / pause
1503
+ - 用户要求"查看调度器状态" → status
1504
+
1505
+ ## 调度说明
1506
+ - 任务仅在当前 REPL 会话存活期间触发
1507
+ - durable=true 的任务会在下次启动时恢复
1508
+ - 循环任务默认 7 天后自动过期(触发最后一次后删除)
1509
+ - 调度器仅在 REPL 空闲时触发任务,不会中断正在执行的对话
1510
+
1511
+ ## Cron 表达式格式(5 字段)
1512
+ \`minute hour day-of-month month day-of-week\`
1513
+
1514
+ | 字段 | 范围 | 说明 |
1515
+ |------|------|------|
1516
+ | minute | 0-59 | 分钟 |
1517
+ | hour | 0-23 | 小时 |
1518
+ | day-of-month | 1-31 | 每月第几天 |
1519
+ | month | 1-12 | 月份 |
1520
+ | day-of-week | 0-6 | 星期几(0=周日) |
1521
+
1522
+ 支持语法:
1523
+ - \`*\` 通配符 — 匹配所有值
1524
+ - \`*/N\` 步进 — 每隔 N 个单位
1525
+ - \`N\` 精确值
1526
+ - \`N-M\` 范围
1527
+ - \`N,M,O\` 列表(逗号分隔)
1528
+
1529
+ 示例:
1530
+ - \`0 9 * * *\` = 每天早上 9:00
1531
+ - \`*/5 * * * *\` = 每 5 分钟
1532
+ - \`0 9 * * 1-5\` = 工作日早上 9:00
1533
+ - \`30 14 28 2 *\` = 2 月 28 日下午 2:30(一次性)
1534
+
1535
+ ## 注意事项
1536
+ - 创建"5分钟后"类任务时,需要根据当前时间计算准确的 cron 表达式
1537
+ - 一次性任务(recurring=false)触发后自动删除
1538
+ - session 级任务(durable=false)在退出后不会恢复`;
1539
+ /**
1540
+ * 创建 scheduled_task 工具
1541
+ * @param scheduler CronScheduler 实例
1542
+ */
1543
+ function createCronTool(scheduler) {
1544
+ return {
1545
+ id: "ScheduledTask",
1546
+ label: "定时任务",
1547
+ description: CRON_TOOL_DESCRIPTION,
1548
+ parameters: {
1549
+ type: "object",
1550
+ properties: {
1551
+ action: {
1552
+ type: "string",
1553
+ description: "操作类型: \"create\"(创建), \"list\"(列出), \"update\"(更新), \"remove\"(删除), \"pause\"(暂停), \"resume\"(恢复), \"run\"(立即执行), \"status\"(状态)。",
1554
+ enum: [
1555
+ "create",
1556
+ "list",
1557
+ "update",
1558
+ "remove",
1559
+ "pause",
1560
+ "resume",
1561
+ "run",
1562
+ "status"
1563
+ ]
1564
+ },
1565
+ cron: {
1566
+ type: "string",
1567
+ description: "5 字段 cron 表达式(action=\"create\" 时必填,update 时可选)"
1568
+ },
1569
+ prompt: {
1570
+ type: "string",
1571
+ description: "任务触发时发送给 Agent 的 prompt 内容(action=\"create\" 时必填)"
1572
+ },
1573
+ recurring: {
1574
+ type: "boolean",
1575
+ description: "是否循环执行,默认 true。设为 false 则为一次性任务,触发后自动删除。"
1576
+ },
1577
+ durable: {
1578
+ type: "boolean",
1579
+ description: "是否持久化到文件(跨会话恢复),默认 false。仅当用户明确要求持久化时设为 true。"
1580
+ },
1581
+ max_fires: {
1582
+ type: "number",
1583
+ description: "最大执行次数,不设置则无限制。一次性任务默认为 1。"
1584
+ },
1585
+ job_id: {
1586
+ type: "string",
1587
+ description: "任务 ID(update/remove/pause/resume/run 操作需要)"
1588
+ },
1589
+ enabled: {
1590
+ type: "boolean",
1591
+ description: "是否启用(update 时使用,对应 pause/resume 操作)"
1592
+ },
1593
+ new_cron: {
1594
+ type: "string",
1595
+ description: "新的 cron 表达式(update 时使用)"
1596
+ },
1597
+ new_prompt: {
1598
+ type: "string",
1599
+ description: "新的 prompt 内容(update 时使用)"
1600
+ }
1601
+ },
1602
+ required: ["action"]
1603
+ },
1604
+ async execute(_toolCallId, params) {
1605
+ const action = params.action ?? "list";
1606
+ switch (action) {
1607
+ case "create": return buildCreateResult(scheduler, params);
1608
+ case "list": return buildListResult$1(scheduler);
1609
+ case "update": return buildUpdateResult$1(scheduler, params);
1610
+ case "remove": return buildRemoveResult$1(scheduler, params);
1611
+ case "pause": return buildPauseResult(scheduler, params);
1612
+ case "resume": return buildResumeResult(scheduler, params);
1613
+ case "run": return buildRunResult(scheduler, params);
1614
+ case "status": return buildStatusResult(scheduler);
1615
+ default: return {
1616
+ content: [{
1617
+ type: "text",
1618
+ text: `不支持的操作: ${action}`
1619
+ }],
1620
+ details: {
1621
+ action,
1622
+ error: `不支持的操作: ${action}`
1623
+ }
1624
+ };
1625
+ }
1626
+ }
1627
+ };
1628
+ }
1629
+ async function buildCreateResult(scheduler, params) {
1630
+ const cron = params.cron?.trim();
1631
+ const prompt = params.prompt?.trim();
1632
+ if (!cron) return {
1633
+ content: [{
1634
+ type: "text",
1635
+ text: "请提供 cron 参数(5 字段 cron 表达式)。"
1636
+ }],
1637
+ details: {
1638
+ action: "create",
1639
+ error: "cron 参数为空"
1640
+ }
1641
+ };
1642
+ if (!prompt) return {
1643
+ content: [{
1644
+ type: "text",
1645
+ text: "请提供 prompt 参数(任务触发时要执行的内容)。"
1646
+ }],
1647
+ details: {
1648
+ action: "create",
1649
+ error: "prompt 参数为空"
1650
+ }
1651
+ };
1652
+ if (prompt.length > CRON_CONSTANTS.MAX_PROMPT_LENGTH) return {
1653
+ content: [{
1654
+ type: "text",
1655
+ text: `prompt 过长(最大 ${CRON_CONSTANTS.MAX_PROMPT_LENGTH} 字符,当前 ${prompt.length} 字符)。`
1656
+ }],
1657
+ details: {
1658
+ action: "create",
1659
+ error: `prompt 过长: ${prompt.length} > ${CRON_CONSTANTS.MAX_PROMPT_LENGTH}`
1660
+ }
1661
+ };
1662
+ const schedule = parseCron(cron);
1663
+ if (!schedule) return {
1664
+ content: [{
1665
+ type: "text",
1666
+ text: `无效的 cron 表达式: "${cron}"。请使用 5 字段格式。`
1667
+ }],
1668
+ details: {
1669
+ action: "create",
1670
+ error: `无效的 cron 表达式: ${cron}`
1671
+ }
1672
+ };
1673
+ const now = Date.now();
1674
+ const recurring = params.recurring !== false;
1675
+ const maxFiresValue = params.max_fires ?? (!recurring ? 1 : void 0);
1676
+ const job = {
1677
+ id: CronStore.generateId(),
1678
+ cron,
1679
+ prompt,
1680
+ createdAt: now,
1681
+ recurring,
1682
+ durable: params.durable === true,
1683
+ enabled: true,
1684
+ fireCount: 0
1685
+ };
1686
+ if (maxFiresValue !== void 0) job.maxFires = maxFiresValue;
1687
+ const error = await scheduler.addJob(job);
1688
+ if (error) return {
1689
+ content: [{
1690
+ type: "text",
1691
+ text: `[创建失败] ${error}`
1692
+ }],
1693
+ details: {
1694
+ action: "create",
1695
+ error
1696
+ }
1697
+ };
1698
+ const nextRun = schedule.nextFrom(/* @__PURE__ */ new Date());
1699
+ const nextRunStr = nextRun ? nextRun.toISOString() : "无(表达式在未来 365 天内无匹配)";
1700
+ const typeStr = recurring ? "循环" : "一次性";
1701
+ const durableStr = job.durable ? "持久化" : "会话级";
1702
+ return {
1703
+ content: [{
1704
+ type: "text",
1705
+ text: [
1706
+ `已创建${typeStr}定时任务:`,
1707
+ ` ID: ${job.id}`,
1708
+ ` 调度: ${schedule.description}`,
1709
+ ` Cron: ${cron}`,
1710
+ ` 类型: ${typeStr} / ${durableStr}`,
1711
+ ` 状态: 启用`,
1712
+ ` 下次触发: ${nextRunStr}`,
1713
+ job.maxFires ? ` 剩余次数: ${job.maxFires}` : ""
1714
+ ].filter(Boolean).join("\n")
1715
+ }],
1716
+ details: {
1717
+ action: "create",
1718
+ jobId: job.id,
1719
+ cron,
1720
+ recurring,
1721
+ durable: job.durable,
1722
+ nextRun: nextRunStr
1723
+ }
1724
+ };
1725
+ }
1726
+ async function buildListResult$1(scheduler) {
1727
+ const jobs = scheduler.getJobs();
1728
+ if (jobs.length === 0) return {
1729
+ content: [{
1730
+ type: "text",
1731
+ text: "暂无定时任务。使用 action=\"create\" 创建一个。"
1732
+ }],
1733
+ details: {
1734
+ action: "list",
1735
+ jobs: []
1736
+ }
1737
+ };
1738
+ const lines = [`共 ${jobs.length} 个定时任务:\n`];
1739
+ let index = 0;
1740
+ for (const job of jobs) {
1741
+ index++;
1742
+ const schedule = parseCron(job.cron);
1743
+ const nextRun = schedule?.nextFrom(new Date(job.lastFiredAt ?? job.createdAt));
1744
+ const nextStr = nextRun ? nextRun.toISOString() : "已过期";
1745
+ const status = job.enabled ? "启用" : "暂停";
1746
+ const type = job.recurring ? "循环" : "一次性";
1747
+ const persist = job.durable ? "持久" : "会话";
1748
+ lines.push(`${index}. [${status}] ${job.id}`, ` 调度: ${schedule?.description ?? job.cron}`, ` 类型: ${type} / ${persist} | 已触发: ${job.fireCount}次 | 下次: ${nextStr}`, ` 任务: ${job.prompt.slice(0, 80)}${job.prompt.length > 80 ? "..." : ""}`, "");
1749
+ }
1750
+ return {
1751
+ content: [{
1752
+ type: "text",
1753
+ text: lines.join("\n")
1754
+ }],
1755
+ details: {
1756
+ action: "list",
1757
+ count: jobs.length,
1758
+ jobs: jobs.map((j) => ({
1759
+ id: j.id,
1760
+ cron: j.cron
1761
+ }))
1762
+ }
1763
+ };
1764
+ }
1765
+ async function buildUpdateResult$1(scheduler, params) {
1766
+ if (!params.job_id) return {
1767
+ content: [{
1768
+ type: "text",
1769
+ text: "请提供 job_id 参数。"
1770
+ }],
1771
+ details: {
1772
+ action: "update",
1773
+ error: "job_id 参数为空"
1774
+ }
1775
+ };
1776
+ const updates = {};
1777
+ if (params.new_cron !== void 0) updates.cron = params.new_cron;
1778
+ if (params.new_prompt !== void 0) updates.prompt = params.new_prompt;
1779
+ if (params.enabled !== void 0) updates.enabled = params.enabled;
1780
+ if (Object.keys(updates).length === 0) return {
1781
+ content: [{
1782
+ type: "text",
1783
+ text: "请提供要更新的参数(new_cron / new_prompt / enabled)。"
1784
+ }],
1785
+ details: {
1786
+ action: "update",
1787
+ error: "无更新参数"
1788
+ }
1789
+ };
1790
+ const error = await scheduler.updateJob(params.job_id, updates);
1791
+ if (error) return {
1792
+ content: [{
1793
+ type: "text",
1794
+ text: `[更新失败] ${error}`
1795
+ }],
1796
+ details: {
1797
+ action: "update",
1798
+ error
1799
+ }
1800
+ };
1801
+ return {
1802
+ content: [{
1803
+ type: "text",
1804
+ text: `任务 ${params.job_id} 已更新。`
1805
+ }],
1806
+ details: {
1807
+ action: "update",
1808
+ jobId: params.job_id,
1809
+ updates
1810
+ }
1811
+ };
1812
+ }
1813
+ async function buildRemoveResult$1(scheduler, params) {
1814
+ if (!params.job_id) return {
1815
+ content: [{
1816
+ type: "text",
1817
+ text: "请提供 job_id 参数。"
1818
+ }],
1819
+ details: {
1820
+ action: "remove",
1821
+ error: "job_id 参数为空"
1822
+ }
1823
+ };
1824
+ if (!await scheduler.removeJob(params.job_id)) return {
1825
+ content: [{
1826
+ type: "text",
1827
+ text: `[删除失败] 任务未找到: ${params.job_id}`
1828
+ }],
1829
+ details: {
1830
+ action: "remove",
1831
+ error: `任务未找到: ${params.job_id}`
1832
+ }
1833
+ };
1834
+ return {
1835
+ content: [{
1836
+ type: "text",
1837
+ text: `任务 ${params.job_id} 已删除。`
1838
+ }],
1839
+ details: {
1840
+ action: "remove",
1841
+ jobId: params.job_id
1842
+ }
1843
+ };
1844
+ }
1845
+ async function buildPauseResult(scheduler, params) {
1846
+ if (!params.job_id) return {
1847
+ content: [{
1848
+ type: "text",
1849
+ text: "请提供 job_id 参数。"
1850
+ }],
1851
+ details: {
1852
+ action: "pause",
1853
+ error: "job_id 参数为空"
1854
+ }
1855
+ };
1856
+ const error = await scheduler.updateJob(params.job_id, { enabled: false });
1857
+ if (error) return {
1858
+ content: [{
1859
+ type: "text",
1860
+ text: `[暂停失败] ${error}`
1861
+ }],
1862
+ details: {
1863
+ action: "pause",
1864
+ error
1865
+ }
1866
+ };
1867
+ return {
1868
+ content: [{
1869
+ type: "text",
1870
+ text: `任务 ${params.job_id} 已暂停。`
1871
+ }],
1872
+ details: {
1873
+ action: "pause",
1874
+ jobId: params.job_id
1875
+ }
1876
+ };
1877
+ }
1878
+ async function buildResumeResult(scheduler, params) {
1879
+ if (!params.job_id) return {
1880
+ content: [{
1881
+ type: "text",
1882
+ text: "请提供 job_id 参数。"
1883
+ }],
1884
+ details: {
1885
+ action: "resume",
1886
+ error: "job_id 参数为空"
1887
+ }
1888
+ };
1889
+ const error = await scheduler.updateJob(params.job_id, { enabled: true });
1890
+ if (error) return {
1891
+ content: [{
1892
+ type: "text",
1893
+ text: `[恢复失败] ${error}`
1894
+ }],
1895
+ details: {
1896
+ action: "resume",
1897
+ error
1898
+ }
1899
+ };
1900
+ return {
1901
+ content: [{
1902
+ type: "text",
1903
+ text: `任务 ${params.job_id} 已恢复。`
1904
+ }],
1905
+ details: {
1906
+ action: "resume",
1907
+ jobId: params.job_id
1908
+ }
1909
+ };
1910
+ }
1911
+ async function buildRunResult(scheduler, params) {
1912
+ if (!params.job_id) return {
1913
+ content: [{
1914
+ type: "text",
1915
+ text: "请提供 job_id 参数。"
1916
+ }],
1917
+ details: {
1918
+ action: "run",
1919
+ error: "job_id 参数为空"
1920
+ }
1921
+ };
1922
+ const error = await scheduler.triggerJob(params.job_id);
1923
+ if (error) return {
1924
+ content: [{
1925
+ type: "text",
1926
+ text: `[执行失败] ${error}`
1927
+ }],
1928
+ details: {
1929
+ action: "run",
1930
+ error
1931
+ }
1932
+ };
1933
+ return {
1934
+ content: [{
1935
+ type: "text",
1936
+ text: `任务 ${params.job_id} 已触发执行。`
1937
+ }],
1938
+ details: {
1939
+ action: "run",
1940
+ jobId: params.job_id
1941
+ }
1942
+ };
1943
+ }
1944
+ async function buildStatusResult(scheduler) {
1945
+ const status = await scheduler.getStatus();
1946
+ const lines = [
1947
+ "调度器状态:",
1948
+ ` 运行中: ${status.running ? "是" : "否"}`,
1949
+ ` 总任务数: ${status.jobCount}`,
1950
+ ` 已启用: ${status.enabledCount}`,
1951
+ ` 持久化任务: ${status.durableCount}`,
1952
+ ` 会话任务: ${status.sessionCount}`
1953
+ ];
1954
+ if (status.running && status.enabledCount > 0) {
1955
+ const enabledJobs = scheduler.getJobs().filter((j) => j.enabled);
1956
+ if (enabledJobs.length > 0) {
1957
+ lines.push("\n最近到期任务:");
1958
+ const now = /* @__PURE__ */ new Date();
1959
+ for (const job of enabledJobs.slice(0, 5)) {
1960
+ const nextRun = parseCron(job.cron)?.nextFrom(new Date(Math.max(job.lastFiredAt ?? job.createdAt, now.getTime())));
1961
+ lines.push(` ${job.id}: ${nextRun?.toISOString() ?? "无"} — ${job.prompt.slice(0, 50)}`);
1962
+ }
1963
+ }
1964
+ }
1965
+ return {
1966
+ content: [{
1967
+ type: "text",
1968
+ text: lines.join("\n")
1969
+ }],
1970
+ details: {
1971
+ action: "status",
1972
+ ...status
1973
+ }
1974
+ };
1975
+ }
1976
+
1977
+ //#endregion
1978
+ //#region src/cli/repl/tools/file-security.ts
1979
+ /**
1980
+ * 文件工具共享安全模块
1981
+ *
1982
+ * 提供路径验证、敏感路径检查、过期检测、diff 生成等共享功能。
1983
+ * 参考 Hermes (file_safety.py) 和 Claude Code (permissions/filesystem.ts) 的设计。
1984
+ *
1985
+ * @module cli/repl/tools/file-security
1986
+ */
1987
+ /**
1988
+ * 敏感路径模式列表
1989
+ *
1990
+ * 拒绝写入这些路径,防止破坏系统安全或泄露凭据。
1991
+ * 参考 Hermes file_safety.py 的 WRITE_DENY_LIST。
1992
+ */
1993
+ const SENSITIVE_PATH_PATTERNS = [
1994
+ /[/\\]\.ssh[/\\]/,
1995
+ /[/\\]\.gnupg[/\\]/,
1996
+ /[/\\]\.bashrc$/,
1997
+ /[/\\]\.zshrc$/,
1998
+ /[/\\]\.profile$/,
1999
+ /[/\\]\.env$/,
2000
+ /[/\\]\.env\.[a-zA-Z0-9_]+$/,
2001
+ /[/\\]\.aws[/\\]/,
2002
+ /[/\\]\.config[/\\]gh[/\\]/,
2003
+ /[/\\]\.kube[/\\]/,
2004
+ /[/\\]\.docker[/\\]config\.json$/,
2005
+ /[/\\]\.git[/\\]config$/,
2006
+ /[/\\]\.git[/\\]HEAD$/,
2007
+ /[/\\]\.git[/\\]index$/,
2008
+ /[/\\]\.git[/\\]hooks[/\\]/,
2009
+ /[/\\]\.git[/\\]objects[/\\]/,
2010
+ /[/\\]\.git[/\\]refs[/\\]/,
2011
+ /^\/etc\/sudoers/,
2012
+ /^\/etc\/passwd/,
2013
+ /^\/etc\/shadow/,
2014
+ /^\/etc\/hosts$/,
2015
+ /^\/etc\/hostname$/,
2016
+ /^\/boot[/\\]/,
2017
+ /^\/proc[/\\]/,
2018
+ /^\/sys[/\\]/,
2019
+ /^\/dev[/\\]/,
2020
+ /[/\\]\.vscode[/\\]settings\.json$/,
2021
+ /[/\\]\.idea[/\\]/
2022
+ ];
2023
+ /**
2024
+ * 系统目录前缀(不可写入)
2025
+ */
2026
+ const SYSTEM_DIR_PREFIXES = [
2027
+ "/etc/",
2028
+ "/boot/",
2029
+ "/proc/",
2030
+ "/sys/",
2031
+ "/dev/",
2032
+ "/usr/lib/",
2033
+ "/usr/share/"
2034
+ ];
2035
+ /**
2036
+ * 验证并解析文件路径
2037
+ *
2038
+ * 1. 解析为绝对路径
2039
+ * 2. 检查是否需要拒绝
2040
+ * 3. 检查工作区边界
2041
+ */
2042
+ function validateFilePath(filePath, cwd) {
2043
+ if (!filePath || filePath.trim() === "") return {
2044
+ valid: false,
2045
+ resolved: "",
2046
+ reason: "文件路径不能为空"
2047
+ };
2048
+ const workdir = cwd ?? process.cwd();
2049
+ let resolved;
2050
+ try {
2051
+ resolved = resolve(isAbsolute(filePath) ? filePath : resolve(workdir, filePath));
2052
+ resolved = normalize(resolved);
2053
+ } catch {
2054
+ return {
2055
+ valid: false,
2056
+ resolved: "",
2057
+ reason: `无法解析路径: ${filePath}`
2058
+ };
2059
+ }
2060
+ const sensitiveCheck = checkSensitivePath(resolved);
2061
+ if (sensitiveCheck) return {
2062
+ valid: false,
2063
+ resolved,
2064
+ reason: sensitiveCheck
2065
+ };
2066
+ if (!isPathWithinWorkdir(resolved, workdir)) return {
2067
+ valid: false,
2068
+ resolved,
2069
+ reason: `路径超出工作区范围: ${resolved}。仅允许在工作区 ${workdir} 内写入文件。`
2070
+ };
2071
+ return {
2072
+ valid: true,
2073
+ resolved
2074
+ };
2075
+ }
2076
+ /**
2077
+ * 检查是否为敏感路径
2078
+ * 返回拒绝原因字符串,或 null 表示安全
2079
+ */
2080
+ function checkSensitivePath(resolvedPath) {
2081
+ const normalized = resolvedPath.replace(/\\/g, "/");
2082
+ for (const pattern of SENSITIVE_PATH_PATTERNS) if (pattern.test(normalized)) return `拒绝写入敏感路径: ${resolvedPath}`;
2083
+ for (const prefix of SYSTEM_DIR_PREFIXES) if (normalized.startsWith(prefix)) return `拒绝写入系统目录: ${resolvedPath}`;
2084
+ return null;
2085
+ }
2086
+ /**
2087
+ * 检查路径是否在工作区范围内
2088
+ */
2089
+ function isPathWithinWorkdir(resolvedPath, workdir) {
2090
+ const normalizedPath = normalize(resolvedPath).replace(/\\/g, "/");
2091
+ const normalizedWorkdir = normalize(resolve(workdir)).replace(/\\/g, "/");
2092
+ if (!normalizedPath.startsWith(normalizedWorkdir + "/") && normalizedPath !== normalizedWorkdir) return false;
2093
+ return true;
2094
+ }
2095
+ /**
2096
+ * 读取状态跟踪器
2097
+ *
2098
+ * 记录文件读取时间戳,用于写入前检测外部修改。
2099
+ * 参考 Claude Code 的 readFileState 和 Hermes 的 stale detection。
2100
+ */
2101
+ var ReadStateTracker = class {
2102
+ state = /* @__PURE__ */ new Map();
2103
+ /**
2104
+ * 记录文件读取
2105
+ */
2106
+ recordRead(filePath) {
2107
+ try {
2108
+ const stat = statSync(filePath);
2109
+ this.state.set(filePath, {
2110
+ timestamp: stat.mtimeMs,
2111
+ size: stat.size
2112
+ });
2113
+ } catch {}
2114
+ }
2115
+ /**
2116
+ * 记录文件写入(更新读取时间戳,避免自身写入触发过期警告)
2117
+ */
2118
+ recordWrite(filePath) {
2119
+ try {
2120
+ const stat = statSync(filePath);
2121
+ this.state.set(filePath, {
2122
+ timestamp: stat.mtimeMs,
2123
+ size: stat.size
2124
+ });
2125
+ } catch {
2126
+ this.state.delete(filePath);
2127
+ }
2128
+ }
2129
+ /**
2130
+ * 检查文件是否过期(自上次读取后被修改)
2131
+ * 返回 null 表示安全,返回警告消息表示可能过期
2132
+ */
2133
+ checkStale(filePath) {
2134
+ const lastRead = this.state.get(filePath);
2135
+ if (!lastRead) return null;
2136
+ try {
2137
+ if (statSync(filePath).mtimeMs > lastRead.timestamp + 1e3) return `文件自上次读取后已被修改(警告:可能发生外部变更)`;
2138
+ } catch {
2139
+ return null;
2140
+ }
2141
+ return null;
2142
+ }
2143
+ };
2144
+ /** 全局单例 */
2145
+ const readStateTracker = new ReadStateTracker();
2146
+ /**
2147
+ * 生成简单的 unified diff
2148
+ *
2149
+ * 用于 write/edit 工具返回时展示改动内容。
2150
+ */
2151
+ function generateSimpleDiff(filePath, oldContent, newContent) {
2152
+ if (oldContent === null) {
2153
+ const lines = newContent.split("\n");
2154
+ return `--- /dev/null\n+++ ${filePath}\n@@ -0,0 +1,${lines.length} @@\n${lines.map((l) => `+${l}`).join("\n")}`;
2155
+ }
2156
+ if (oldContent === newContent) return "(无变化)";
2157
+ const oldLines = oldContent.split("\n");
2158
+ const newLines = newContent.split("\n");
2159
+ const diffLines = [];
2160
+ diffLines.push(`--- ${filePath}`);
2161
+ diffLines.push(`+++ ${filePath}`);
2162
+ let start = 0;
2163
+ while (start < oldLines.length && start < newLines.length && oldLines[start] === newLines[start]) start++;
2164
+ let oldEnd = oldLines.length;
2165
+ let newEnd = newLines.length;
2166
+ while (oldEnd > start && newEnd > start && oldLines[oldEnd - 1] === newLines[newEnd - 1]) {
2167
+ oldEnd--;
2168
+ newEnd--;
2169
+ }
2170
+ const contextStart = Math.max(0, start - 3);
2171
+ const oldHunkLen = oldEnd - contextStart;
2172
+ const newHunkLen = newEnd - contextStart;
2173
+ diffLines.push(`@@ -${contextStart + 1},${oldHunkLen} +${contextStart + 1},${newHunkLen} @@`);
2174
+ for (let i = contextStart; i < oldEnd && i - contextStart < 50; i++) if (i < start || i >= oldEnd) diffLines.push(` ${oldLines[i]}`);
2175
+ else if (i < oldEnd && i < 0) diffLines.push(` ${oldLines[i]}`);
2176
+ const maxContext = 60;
2177
+ for (let i = contextStart; i < oldEnd && i - contextStart < maxContext; i++) if (i >= start) diffLines.push(`-${oldLines[i]}`);
2178
+ for (let i = contextStart; i < newEnd && i - contextStart < maxContext; i++) if (i >= start) diffLines.push(`+${newLines[i]}`);
2179
+ return diffLines.join("\n");
2180
+ }
2181
+ /**
2182
+ * 写入文件内容
2183
+ *
2184
+ * 自动创建父目录,使用原子写入(先写临时文件再重命名)。
2185
+ */
2186
+ function writeFileContent(filePath, content) {
2187
+ const { mkdirSync, writeFileSync } = __require("node:fs");
2188
+ const { dirname } = __require("node:path");
2189
+ mkdirSync(dirname(filePath), { recursive: true });
2190
+ writeFileSync(filePath, content, "utf-8");
2191
+ }
2192
+ /**
2193
+ * 读取文件内容(同步)
2194
+ * 返回文件内容字符串,或 null 表示文件不存在
2195
+ */
2196
+ function readFileContent(filePath) {
2197
+ try {
2198
+ return readFileSync(filePath, "utf-8");
2199
+ } catch {
2200
+ return null;
2201
+ }
2202
+ }
2203
+
2204
+ //#endregion
2205
+ //#region src/cli/repl/tools/file-edit.ts
2206
+ /**
2207
+ * edit_file 工具实现 — 精确字符串替换
2208
+ *
2209
+ * 功能:
2210
+ * - 精确字符串查找替换
2211
+ * - replace_all 批量替换
2212
+ * - Unicode 引号归一化(花括号引号 → 直引号)
2213
+ * - 多匹配唯一性检查
2214
+ * - 同 write_file 的安全保护
2215
+ *
2216
+ * 参考 Claude Code FileEditTool 的设计。
2217
+ * 不做 Hermes 的 9 级模糊匹配(复杂度高,可后续迭代)。
2218
+ *
2219
+ * @module cli/repl/tools/file-edit
2220
+ */
2221
+ /**
2222
+ * Unicode 字符映射:将花括号引号等归一化为 ASCII 等价字符
2223
+ * 参考 Claude Code FileEditTool/utils.ts 和 Hermes fuzzy_match.py
2224
+ */
2225
+ const UNICODE_MAP = {
2226
+ "“": "\"",
2227
+ "”": "\"",
2228
+ "‘": "'",
2229
+ "’": "'",
2230
+ "—": "--",
2231
+ "–": "-",
2232
+ "…": "...",
2233
+ "\xA0": " "
2234
+ };
2235
+ /**
2236
+ * 归一化 Unicode 字符
2237
+ */
2238
+ function normalizeUnicode(text) {
2239
+ let result = text;
2240
+ for (const [char, replacement] of Object.entries(UNICODE_MAP)) result = result.replaceAll(char, replacement);
2241
+ return result;
2242
+ }
2243
+ /**
2244
+ * 在目标内容中查找匹配的字符串
2245
+ *
2246
+ * 先精确匹配,如果失败则尝试 Unicode 归一化后匹配。
2247
+ * 返回实际匹配到的字符串(用于替换),或 null。
2248
+ */
2249
+ function findActualString(content, oldString) {
2250
+ if (content.includes(oldString)) return oldString;
2251
+ const normalizedContent = normalizeUnicode(content);
2252
+ const normalizedOld = normalizeUnicode(oldString);
2253
+ const idx = normalizedContent.indexOf(normalizedOld);
2254
+ if (idx !== -1) return content.substring(idx, idx + normalizedOld.length);
2255
+ return null;
2256
+ }
2257
+ function createEditFileTool() {
2258
+ return {
2259
+ id: "EditFile",
2260
+ label: "编辑文件",
2261
+ description: "在文件中执行精确字符串替换。参数 old_string 为要查找的文本(必须在文件中精确匹配),new_string 为替换后的文本(必须与 old_string 不同)。如果 old_string 在文件中出现多次,必须设置 replace_all=true。自动支持 Unicode 引号归一化(花括号引号 → 直引号)。",
2262
+ parameters: {
2263
+ type: "object",
2264
+ properties: {
2265
+ file_path: {
2266
+ type: "string",
2267
+ description: "文件绝对路径(必填)"
2268
+ },
2269
+ old_string: {
2270
+ type: "string",
2271
+ description: "要替换的文本(必填)。必须是文件中的精确内容。支持 Unicode 引号自动归一化。"
2272
+ },
2273
+ new_string: {
2274
+ type: "string",
2275
+ description: "替换后的新文本(必填)。必须与 old_string 不同。"
2276
+ },
2277
+ replace_all: {
2278
+ type: "boolean",
2279
+ description: "是否替换所有匹配项(可选,默认 false)。当 old_string 在文件中出现多次时必须设为 true。"
2280
+ }
2281
+ },
2282
+ required: [
2283
+ "file_path",
2284
+ "old_string",
2285
+ "new_string"
2286
+ ]
2287
+ },
2288
+ async execute(_toolCallId, params) {
2289
+ const startTime = Date.now();
2290
+ const replaceAll = params.replace_all === true;
2291
+ if (params.old_string === params.new_string) return {
2292
+ content: [{
2293
+ type: "text",
2294
+ text: "[编辑失败] old_string 和 new_string 完全相同,没有需要修改的内容。"
2295
+ }],
2296
+ details: {
2297
+ filePath: params.file_path,
2298
+ replaced: false,
2299
+ matchCount: 0,
2300
+ replaceAll,
2301
+ error: "old_string 与 new_string 相同"
2302
+ }
2303
+ };
2304
+ const pathResult = validateFilePath(params.file_path);
2305
+ if (!pathResult.valid) {
2306
+ const details = {
2307
+ filePath: params.file_path,
2308
+ replaced: false,
2309
+ matchCount: 0,
2310
+ replaceAll
2311
+ };
2312
+ if (pathResult.reason) details.error = pathResult.reason;
2313
+ return {
2314
+ content: [{
2315
+ type: "text",
2316
+ text: `[编辑失败] ${pathResult.reason}`
2317
+ }],
2318
+ details
2319
+ };
2320
+ }
2321
+ const resolvedPath = pathResult.resolved;
2322
+ const fileContent = readFileContent(resolvedPath);
2323
+ if (fileContent === null) {
2324
+ const details = {
2325
+ filePath: resolvedPath,
2326
+ replaced: false,
2327
+ matchCount: 0,
2328
+ replaceAll,
2329
+ error: "文件不存在"
2330
+ };
2331
+ return {
2332
+ content: [{
2333
+ type: "text",
2334
+ text: `[编辑失败] 文件不存在: ${resolvedPath}`
2335
+ }],
2336
+ details
2337
+ };
2338
+ }
2339
+ let warning;
2340
+ const staleWarning = readStateTracker.checkStale(resolvedPath);
2341
+ if (staleWarning) warning = staleWarning;
2342
+ const actualOldString = findActualString(fileContent, params.old_string);
2343
+ if (actualOldString === null) {
2344
+ const details = {
2345
+ filePath: resolvedPath,
2346
+ replaced: false,
2347
+ matchCount: 0,
2348
+ replaceAll,
2349
+ error: "old_string 未在文件中找到"
2350
+ };
2351
+ return {
2352
+ content: [{
2353
+ type: "text",
2354
+ text: `[编辑失败] 在文件中未找到要替换的文本。\n搜索内容: "${params.old_string}"\n请使用 ReadFile 确认文件当前内容后重试。`
2355
+ }],
2356
+ details
2357
+ };
2358
+ }
2359
+ const matchCount = fileContent.split(actualOldString).length - 1;
2360
+ if (matchCount > 1 && !replaceAll) {
2361
+ const details = {
2362
+ filePath: resolvedPath,
2363
+ replaced: false,
2364
+ matchCount,
2365
+ replaceAll,
2366
+ error: `找到 ${matchCount} 处匹配但未设置 replace_all`
2367
+ };
2368
+ return {
2369
+ content: [{
2370
+ type: "text",
2371
+ text: `[编辑失败] 找到 ${matchCount} 处匹配,但 replace_all 为 false。\n要替换所有 ${matchCount} 处匹配,请设置 replace_all=true。\n要替换其中一处,请提供更多上下文使 old_string 唯一。\n搜索内容: "${params.old_string}"`
2372
+ }],
2373
+ details
2374
+ };
2375
+ }
2376
+ const newContent = replaceAll ? fileContent.replaceAll(actualOldString, params.new_string) : fileContent.replace(actualOldString, params.new_string);
2377
+ try {
2378
+ writeFileContent(resolvedPath, newContent);
2379
+ } catch (err) {
2380
+ const details = {
2381
+ filePath: resolvedPath,
2382
+ replaced: false,
2383
+ matchCount,
2384
+ replaceAll,
2385
+ error: err instanceof Error ? err.message : String(err)
2386
+ };
2387
+ return {
2388
+ content: [{
2389
+ type: "text",
2390
+ text: `[编辑失败] ${err instanceof Error ? err.message : String(err)}`
2391
+ }],
2392
+ details
2393
+ };
2394
+ }
2395
+ readStateTracker.recordWrite(resolvedPath);
2396
+ const diff = generateSimpleDiff(resolvedPath, fileContent, newContent);
2397
+ const elapsedMs = Date.now() - startTime;
2398
+ const parts = [];
2399
+ parts.push(`[文件已编辑] ${resolvedPath}`, `替换了 ${matchCount} 处匹配${replaceAll ? "(replace_all=true)" : ""}`);
2400
+ if (warning) parts.push(`\n⚠ ${warning}`);
2401
+ parts.push(`\n\`\`\`diff\n${diff}\n\`\`\``);
2402
+ parts.push(`\n耗时: ${elapsedMs}ms`);
2403
+ const details = {
2404
+ filePath: resolvedPath,
2405
+ replaced: true,
2406
+ matchCount,
2407
+ replaceAll,
2408
+ elapsedMs
2409
+ };
2410
+ if (warning) details.warning = warning;
2411
+ return {
2412
+ content: [{
2413
+ type: "text",
2414
+ text: parts.join("\n")
2415
+ }],
2416
+ details
2417
+ };
2418
+ }
2419
+ };
2420
+ }
2421
+
2422
+ //#endregion
2423
+ //#region src/cli/repl/tools/file-glob.ts
2424
+ /**
2425
+ * glob 工具实现 — 文件模式匹配搜索
2426
+ *
2427
+ * 功能:
2428
+ * - 使用 glob 模式匹配文件
2429
+ * - 支持相对/绝对路径
2430
+ * - 按修改时间排序
2431
+ * - 结果数量限制
2432
+ *
2433
+ * 参考 Claude Code GlobTool 的设计。
2434
+ *
2435
+ * @module cli/repl/tools/file-glob
2436
+ */
2437
+ /**
2438
+ * 简单的 glob 模式匹配
2439
+ *
2440
+ * 使用同步 fs.readdirSync 递归实现,避免引入额外依赖。
2441
+ * 支持 **、*、? 通配符。
2442
+ */
2443
+ function globSync(pattern, rootPath) {
2444
+ const { readdirSync } = __require("node:fs");
2445
+ const { join, relative, dirname } = __require("node:path");
2446
+ const normalizedPattern = pattern.replace(/\\/g, "/");
2447
+ if (normalizedPattern.includes("**")) {
2448
+ const parts = normalizedPattern.split("/");
2449
+ const results = [];
2450
+ const maxResults = 500;
2451
+ let regexStr = normalizedPattern.replace(/\\/g, "\\\\").replace(/\./g, "\\.").replace(/\*\*/g, "<<<GLOBSTAR>>>").replace(/\*/g, "[^/]*").replace(/\?/g, ".").replace(/<<<GLOBSTAR>>>/g, ".*");
2452
+ if (normalizedPattern.startsWith("**/")) regexStr = regexStr.replace(/^\.\*\//, "(.*/)?");
2453
+ const regex = new RegExp(`^${regexStr}$`);
2454
+ function walk(dir) {
2455
+ if (results.length >= maxResults) return;
2456
+ try {
2457
+ const entries = readdirSync(dir, { withFileTypes: true });
2458
+ for (const entry of entries) {
2459
+ if (results.length >= maxResults) return;
2460
+ const fullPath = join(dir, entry.name);
2461
+ const relativePath = relative(rootPath, fullPath).replace(/\\/g, "/");
2462
+ if (entry.isDirectory()) {
2463
+ if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
2464
+ walk(fullPath);
2465
+ } else if (entry.isFile()) {
2466
+ if (regex.test(relativePath)) results.push(fullPath);
2467
+ }
2468
+ }
2469
+ } catch {}
2470
+ }
2471
+ const startsWithGlobstar = normalizedPattern.startsWith("**");
2472
+ const staticPart = parts.find((p) => !p.includes("*") && !p.includes("?")) || ".";
2473
+ walk(startsWithGlobstar ? rootPath : resolve(rootPath, staticPart === "**" ? "." : dirname(normalizedPattern)));
2474
+ results.sort((a, b) => {
2475
+ try {
2476
+ return statSync(b).mtimeMs - statSync(a).mtimeMs;
2477
+ } catch {
2478
+ return 0;
2479
+ }
2480
+ });
2481
+ return results.slice(0, maxResults);
2482
+ } else {
2483
+ const results = [];
2484
+ const regexStr = normalizedPattern.replace(/\\/g, "\\\\").replace(/\./g, "\\.").replace(/\*/g, "[^/]*").replace(/\?/g, ".");
2485
+ const regex = new RegExp(regexStr);
2486
+ const searchDir = rootPath;
2487
+ try {
2488
+ const entries = readdirSync(searchDir, {
2489
+ withFileTypes: true,
2490
+ recursive: false
2491
+ });
2492
+ for (const entry of entries) if (entry.isFile() && regex.test(entry.name)) {
2493
+ const fullPath = join(searchDir, entry.name);
2494
+ results.push(fullPath);
2495
+ }
2496
+ } catch {}
2497
+ results.sort((a, b) => {
2498
+ try {
2499
+ return statSync(b).mtimeMs - statSync(a).mtimeMs;
2500
+ } catch {
2501
+ return 0;
2502
+ }
2503
+ });
2504
+ return results;
2505
+ }
2506
+ }
2507
+ function createGlobTool() {
2508
+ return {
2509
+ id: "Glob",
2510
+ label: "文件搜索",
2511
+ description: "使用 glob 模式匹配搜索文件。支持 ** (递归)、* (任意字符)、? (单字符) 通配符。结果按修改时间倒序排列,最多返回 500 个匹配。参数 pattern 为 glob 模式(如 \"src/**/*.ts\"、\"*.js\"),path 为搜索根目录(可选)。",
2512
+ parameters: {
2513
+ type: "object",
2514
+ properties: {
2515
+ pattern: {
2516
+ type: "string",
2517
+ description: "glob 匹配模式(必填)。如 \"**/*.ts\"、\"src/**/*.test.ts\""
2518
+ },
2519
+ path: {
2520
+ type: "string",
2521
+ description: "搜索根目录(可选,默认当前工作目录)"
2522
+ }
2523
+ },
2524
+ required: ["pattern"]
2525
+ },
2526
+ async execute(_toolCallId, params) {
2527
+ const startTime = Date.now();
2528
+ const searchPath = resolve(params.path ?? process.cwd());
2529
+ try {
2530
+ const matches = globSync(params.pattern, searchPath);
2531
+ const elapsedMs = Date.now() - startTime;
2532
+ const truncated = matches.length >= 500;
2533
+ if (matches.length === 0) return {
2534
+ content: [{
2535
+ type: "text",
2536
+ text: `[glob] 未找到匹配 "${params.pattern}" 的文件(在 ${searchPath} 中)`
2537
+ }],
2538
+ details: {
2539
+ pattern: params.pattern,
2540
+ matchCount: 0,
2541
+ searchPath,
2542
+ truncated: false,
2543
+ elapsedMs
2544
+ }
2545
+ };
2546
+ return {
2547
+ content: [{
2548
+ type: "text",
2549
+ text: [
2550
+ `[glob] 找到 ${matches.length} 个匹配 "${params.pattern}" 的文件${truncated ? "(结果已截断)" : ""}:`,
2551
+ "",
2552
+ ...matches.map((m, i) => `${i + 1}. ${m}`)
2553
+ ].join("\n")
2554
+ }],
2555
+ details: {
2556
+ pattern: params.pattern,
2557
+ matchCount: matches.length,
2558
+ searchPath,
2559
+ truncated,
2560
+ elapsedMs
2561
+ }
2562
+ };
2563
+ } catch (err) {
2564
+ return {
2565
+ content: [{
2566
+ type: "text",
2567
+ text: `[glob] 搜索失败: ${err instanceof Error ? err.message : String(err)}`
2568
+ }],
2569
+ details: {
2570
+ pattern: params.pattern,
2571
+ matchCount: 0,
2572
+ searchPath,
2573
+ truncated: false,
2574
+ error: err instanceof Error ? err.message : String(err)
2575
+ }
2576
+ };
2577
+ }
2578
+ }
2579
+ };
2580
+ }
2581
+
2582
+ //#endregion
2583
+ //#region src/cli/repl/tools/file-grep.ts
2584
+ /**
2585
+ * grep 工具实现 — 文件内容搜索
2586
+ *
2587
+ * 功能:
2588
+ * - 正则表达式搜索文件内容
2589
+ * - 支持 output_mode: content / files_with_matches / count
2590
+ * - 支持上下文行(-A/-B/-C)
2591
+ * - 支持 glob 文件过滤
2592
+ * - 结果数量限制
2593
+ *
2594
+ * 参考 Claude Code GrepTool 的设计。
2595
+ *
2596
+ * @module cli/repl/tools/file-grep
2597
+ */
2598
+ /**
2599
+ * 收集搜索范围内的所有文件
2600
+ */
2601
+ function collectFiles(searchPath, fileGlob) {
2602
+ const { readdirSync, statSync } = __require("node:fs");
2603
+ const { join } = __require("node:path");
2604
+ const results = [];
2605
+ const maxFiles = 2e3;
2606
+ let globRegex = null;
2607
+ if (fileGlob) {
2608
+ const regexStr = fileGlob.replace(/\\/g, "\\\\").replace(/\./g, "\\.").replace(/\*\*/g, "<<<GLOBSTAR>>>").replace(/\*/g, "[^/]*").replace(/\?/g, ".").replace(/<<<GLOBSTAR>>>/g, ".*");
2609
+ globRegex = new RegExp(`^${regexStr}$`);
2610
+ }
2611
+ function walk(dir) {
2612
+ if (results.length >= maxFiles) return;
2613
+ try {
2614
+ const entries = readdirSync(dir, { withFileTypes: true });
2615
+ for (const entry of entries) {
2616
+ if (results.length >= maxFiles) return;
2617
+ if (entry.name.startsWith(".") && entry.name !== ".") continue;
2618
+ if (entry.name === "node_modules" || entry.name === ".git") continue;
2619
+ const fullPath = join(dir, entry.name);
2620
+ if (entry.isDirectory()) walk(fullPath);
2621
+ else if (entry.isFile()) {
2622
+ if (isBinaryExtension(entry.name)) continue;
2623
+ if (globRegex) {
2624
+ const relativePath = fullPath.replace(searchPath, "").replace(/\\/g, "/").replace(/^\//, "");
2625
+ if (!globRegex.test(relativePath) && !globRegex.test(entry.name)) continue;
2626
+ }
2627
+ try {
2628
+ if (statSync(fullPath).size > 1024 * 1024) continue;
2629
+ } catch {
2630
+ continue;
2631
+ }
2632
+ results.push(fullPath);
2633
+ }
2634
+ }
2635
+ } catch {}
2636
+ }
2637
+ walk(searchPath);
2638
+ return results;
2639
+ }
2640
+ /**
2641
+ * 二进制文件扩展名列表
2642
+ */
2643
+ const BINARY_EXTENSIONS = new Set([
2644
+ ".png",
2645
+ ".jpg",
2646
+ ".jpeg",
2647
+ ".gif",
2648
+ ".webp",
2649
+ ".ico",
2650
+ ".bmp",
2651
+ ".svg",
2652
+ ".mp3",
2653
+ ".mp4",
2654
+ ".avi",
2655
+ ".mov",
2656
+ ".mkv",
2657
+ ".wav",
2658
+ ".flac",
2659
+ ".zip",
2660
+ ".tar",
2661
+ ".gz",
2662
+ ".bz2",
2663
+ ".7z",
2664
+ ".rar",
2665
+ ".exe",
2666
+ ".dll",
2667
+ ".so",
2668
+ ".dylib",
2669
+ ".pdf",
2670
+ ".doc",
2671
+ ".docx",
2672
+ ".xls",
2673
+ ".xlsx",
2674
+ ".ppt",
2675
+ ".pptx",
2676
+ ".woff",
2677
+ ".woff2",
2678
+ ".ttf",
2679
+ ".eot",
2680
+ ".otf",
2681
+ ".bin",
2682
+ ".dat",
2683
+ ".class",
2684
+ ".pyc",
2685
+ ".db",
2686
+ ".sqlite",
2687
+ ".sqlite3"
2688
+ ]);
2689
+ function isBinaryExtension(filename) {
2690
+ const ext = filename.substring(filename.lastIndexOf(".")).toLowerCase();
2691
+ return BINARY_EXTENSIONS.has(ext);
2692
+ }
2693
+ function createGrepTool() {
2694
+ const MAX_RESULTS = 250;
2695
+ return {
2696
+ id: "Grep",
2697
+ label: "内容搜索",
2698
+ description: "使用正则表达式搜索文件内容。支持 output_mode 控制输出格式:\"content\"(含上下文)、\"files_with_matches\"(仅文件列表)、\"count\"(匹配计数)。参数 context 可指定显示匹配行前后 N 行上下文。参数 glob 可过滤文件(如 \"*.ts\" 仅搜索 TypeScript 文件)。",
2699
+ parameters: {
2700
+ type: "object",
2701
+ properties: {
2702
+ pattern: {
2703
+ type: "string",
2704
+ description: "正则表达式搜索模式(必填)"
2705
+ },
2706
+ path: {
2707
+ type: "string",
2708
+ description: "搜索范围目录(可选,默认当前工作目录)"
2709
+ },
2710
+ glob: {
2711
+ type: "string",
2712
+ description: "文件过滤 glob 模式(可选,如 \"*.ts\")"
2713
+ },
2714
+ output_mode: {
2715
+ type: "string",
2716
+ enum: [
2717
+ "content",
2718
+ "files_with_matches",
2719
+ "count"
2720
+ ],
2721
+ description: "输出模式:content(默认,含上下文)、files_with_matches(文件列表)、count(计数)"
2722
+ },
2723
+ "-i": {
2724
+ type: "boolean",
2725
+ description: "忽略大小写(默认大小写敏感)"
2726
+ },
2727
+ "-A": {
2728
+ type: "number",
2729
+ description: "匹配行之后显示的行数"
2730
+ },
2731
+ "-B": {
2732
+ type: "number",
2733
+ description: "匹配行之前显示的行数"
2734
+ },
2735
+ context: {
2736
+ type: "number",
2737
+ description: "匹配行前后显示的行数(同时设置 -A 和 -B)"
2738
+ }
2739
+ },
2740
+ required: ["pattern"]
2741
+ },
2742
+ async execute(_toolCallId, params) {
2743
+ const startTime = Date.now();
2744
+ const searchPath = resolve(params.path ?? process.cwd());
2745
+ const outputMode = params.output_mode ?? "content";
2746
+ const ignoreCase = params["-i"] === true;
2747
+ const contextBefore = params["-B"] ?? params.context ?? 0;
2748
+ const contextAfter = params["-A"] ?? params.context ?? 0;
2749
+ let regex;
2750
+ try {
2751
+ regex = new RegExp(params.pattern, ignoreCase ? "gi" : "g");
2752
+ } catch {
2753
+ const details = {
2754
+ pattern: params.pattern,
2755
+ matchCount: 0,
2756
+ fileCount: 0,
2757
+ searchPath,
2758
+ truncated: false,
2759
+ outputMode
2760
+ };
2761
+ return {
2762
+ content: [{
2763
+ type: "text",
2764
+ text: `[grep] 无效的正则表达式: "${params.pattern}"`
2765
+ }],
2766
+ details
2767
+ };
2768
+ }
2769
+ const files = collectFiles(searchPath, params.glob);
2770
+ const results = [];
2771
+ for (const file of files) {
2772
+ if (results.length >= MAX_RESULTS) break;
2773
+ try {
2774
+ const lines = readFileSync(file, "utf-8").split("\n");
2775
+ regex.lastIndex = 0;
2776
+ for (let i = 0; i < lines.length; i++) {
2777
+ if (results.length >= MAX_RESULTS) break;
2778
+ const currentLine = lines[i];
2779
+ if (new RegExp(params.pattern, ignoreCase ? "i" : "").test(currentLine)) {
2780
+ const ctxBefore = [];
2781
+ const ctxAfter = [];
2782
+ for (let b = Math.max(0, i - contextBefore); b < i; b++) ctxBefore.push(lines[b]);
2783
+ for (let a = i + 1; a <= Math.min(lines.length - 1, i + contextAfter); a++) ctxAfter.push(lines[a]);
2784
+ results.push({
2785
+ file,
2786
+ lineNum: i + 1,
2787
+ line: currentLine.substring(0, 500),
2788
+ contextBefore: ctxBefore.map((l) => l.substring(0, 500)),
2789
+ contextAfter: ctxAfter.map((l) => l.substring(0, 500))
2790
+ });
2791
+ }
2792
+ }
2793
+ } catch {}
2794
+ }
2795
+ const elapsedMs = Date.now() - startTime;
2796
+ const truncated = results.length >= MAX_RESULTS;
2797
+ if (outputMode === "files_with_matches") return buildFilesOutput([...new Set(results.map((r) => r.file))], params.pattern, searchPath, truncated, elapsedMs);
2798
+ if (outputMode === "count") {
2799
+ const fileCounts = /* @__PURE__ */ new Map();
2800
+ for (const r of results) fileCounts.set(r.file, (fileCounts.get(r.file) ?? 0) + 1);
2801
+ return buildCountOutput(fileCounts, params.pattern, searchPath, truncated, elapsedMs);
2802
+ }
2803
+ return buildContentOutput(results, params.pattern, searchPath, truncated, elapsedMs);
2804
+ }
2805
+ };
2806
+ }
2807
+ function buildContentOutput(results, pattern, searchPath, truncated, elapsedMs) {
2808
+ if (results.length === 0) {
2809
+ const details = {
2810
+ pattern,
2811
+ matchCount: 0,
2812
+ fileCount: 0,
2813
+ searchPath,
2814
+ truncated: false,
2815
+ outputMode: "content",
2816
+ elapsedMs
2817
+ };
2818
+ return {
2819
+ content: [{
2820
+ type: "text",
2821
+ text: `[grep] 未找到匹配 "${pattern}" 的内容(在 ${searchPath} 中)`
2822
+ }],
2823
+ details
2824
+ };
2825
+ }
2826
+ const lines = [`[grep] 找到 ${results.length} 处匹配 "${pattern}"${truncated ? "(结果已截断)" : ""}:`, ""];
2827
+ for (const r of results) {
2828
+ lines.push(`${r.file}:${r.lineNum}`);
2829
+ for (const ctxLine of r.contextBefore) lines.push(` ${ctxLine}`);
2830
+ lines.push(`> ${r.line}`);
2831
+ for (const ctxLine of r.contextAfter) lines.push(` ${ctxLine}`);
2832
+ lines.push("");
2833
+ }
2834
+ const details = {
2835
+ pattern,
2836
+ matchCount: results.length,
2837
+ fileCount: [...new Set(results.map((r) => r.file))].length,
2838
+ searchPath,
2839
+ truncated,
2840
+ outputMode: "content",
2841
+ elapsedMs
2842
+ };
2843
+ return {
2844
+ content: [{
2845
+ type: "text",
2846
+ text: lines.join("\n")
2847
+ }],
2848
+ details
2849
+ };
2850
+ }
2851
+ function buildFilesOutput(files, pattern, searchPath, truncated, elapsedMs) {
2852
+ if (files.length === 0) {
2853
+ const details = {
2854
+ pattern,
2855
+ matchCount: 0,
2856
+ fileCount: 0,
2857
+ searchPath,
2858
+ truncated: false,
2859
+ outputMode: "files_with_matches",
2860
+ elapsedMs
2861
+ };
2862
+ return {
2863
+ content: [{
2864
+ type: "text",
2865
+ text: `[grep] 未找到匹配 "${pattern}" 的文件(在 ${searchPath} 中)`
2866
+ }],
2867
+ details
2868
+ };
2869
+ }
2870
+ const parts = [
2871
+ `[grep] 找到 ${files.length} 个匹配 "${pattern}" 的文件${truncated ? "(结果已截断)" : ""}:`,
2872
+ "",
2873
+ ...files.map((f, i) => `${i + 1}. ${f}`)
2874
+ ];
2875
+ const details = {
2876
+ pattern,
2877
+ matchCount: files.length,
2878
+ fileCount: files.length,
2879
+ searchPath,
2880
+ truncated,
2881
+ outputMode: "files_with_matches",
2882
+ elapsedMs
2883
+ };
2884
+ return {
2885
+ content: [{
2886
+ type: "text",
2887
+ text: parts.join("\n")
2888
+ }],
2889
+ details
2890
+ };
2891
+ }
2892
+ function buildCountOutput(fileCounts, pattern, searchPath, truncated, elapsedMs) {
2893
+ if (fileCounts.size === 0) {
2894
+ const details = {
2895
+ pattern,
2896
+ matchCount: 0,
2897
+ fileCount: 0,
2898
+ searchPath,
2899
+ truncated: false,
2900
+ outputMode: "count",
2901
+ elapsedMs
2902
+ };
2903
+ return {
2904
+ content: [{
2905
+ type: "text",
2906
+ text: `[grep] 未找到匹配 "${pattern}" 的内容(在 ${searchPath} 中)`
2907
+ }],
2908
+ details
2909
+ };
2910
+ }
2911
+ const parts = [`[grep] 匹配 "${pattern}" 的计数${truncated ? "(结果已截断)" : ""}:`, ""];
2912
+ let total = 0;
2913
+ const sorted = [...fileCounts.entries()].sort((a, b) => b[1] - a[1]);
2914
+ for (const [file, count] of sorted) {
2915
+ parts.push(`${count} ${file}`);
2916
+ total += count;
2917
+ }
2918
+ parts.push("", `合计: ${total} 处匹配(${fileCounts.size} 个文件)`);
2919
+ const details = {
2920
+ pattern,
2921
+ matchCount: total,
2922
+ fileCount: fileCounts.size,
2923
+ searchPath,
2924
+ truncated,
2925
+ outputMode: "count",
2926
+ elapsedMs
2927
+ };
2928
+ return {
2929
+ content: [{
2930
+ type: "text",
2931
+ text: parts.join("\n")
2932
+ }],
2933
+ details
2934
+ };
2935
+ }
2936
+
2937
+ //#endregion
2938
+ //#region src/cli/repl/tools/file-write.ts
2939
+ /**
2940
+ * write_file 工具实现 — 文件创建与覆写
2941
+ *
2942
+ * 功能:
2943
+ * - 创建新文件 / 覆写已有文件
2944
+ * - 自动创建父目录
2945
+ * - 敏感路径检查
2946
+ * - 工作区边界保护
2947
+ * - 过期检测(软约束)
2948
+ * - 返回结构化 diff
2949
+ *
2950
+ * 参考 Claude Code FileWriteTool 和 Hermes write_file 的设计。
2951
+ *
2952
+ * @module cli/repl/tools/file-write
2953
+ */
2954
+ function createWriteFileTool() {
2955
+ return {
2956
+ id: "WriteFile",
2957
+ label: "写入文件",
2958
+ description: "将内容写入文件,完全替换已有内容或创建新文件。使用此工具来创建或覆写文件。自动创建父目录。参数 file_path 为文件绝对路径,content 为要写入的内容。",
2959
+ parameters: {
2960
+ type: "object",
2961
+ properties: {
2962
+ file_path: {
2963
+ type: "string",
2964
+ description: "文件绝对路径(必填)。必须是绝对路径,不能是相对路径。"
2965
+ },
2966
+ content: {
2967
+ type: "string",
2968
+ description: "要写入文件的完整内容(必填)"
2969
+ }
2970
+ },
2971
+ required: ["file_path", "content"]
2972
+ },
2973
+ async execute(_toolCallId, params) {
2974
+ const startTime = Date.now();
2975
+ const pathResult = validateFilePath(params.file_path);
2976
+ if (!pathResult.valid) {
2977
+ const details = {
2978
+ type: "create",
2979
+ filePath: params.file_path,
2980
+ contentLength: 0
2981
+ };
2982
+ if (pathResult.reason) details.error = pathResult.reason;
2983
+ return {
2984
+ content: [{
2985
+ type: "text",
2986
+ text: `[写入失败] ${pathResult.reason}`
2987
+ }],
2988
+ details
2989
+ };
2990
+ }
2991
+ const resolvedPath = pathResult.resolved;
2992
+ let warning;
2993
+ const staleWarning = readStateTracker.checkStale(resolvedPath);
2994
+ if (staleWarning) warning = staleWarning;
2995
+ const oldContent = readFileContent(resolvedPath);
2996
+ const isNew = oldContent === null;
2997
+ try {
2998
+ writeFileContent(resolvedPath, params.content);
2999
+ } catch (err) {
3000
+ const details = {
3001
+ type: isNew ? "create" : "update",
3002
+ filePath: resolvedPath,
3003
+ contentLength: 0,
3004
+ error: err instanceof Error ? err.message : String(err)
3005
+ };
3006
+ return {
3007
+ content: [{
3008
+ type: "text",
3009
+ text: `[写入失败] ${err instanceof Error ? err.message : String(err)}`
3010
+ }],
3011
+ details
3012
+ };
3013
+ }
3014
+ readStateTracker.recordWrite(resolvedPath);
3015
+ const diff = generateSimpleDiff(resolvedPath, oldContent, params.content);
3016
+ const elapsedMs = Date.now() - startTime;
3017
+ const type = isNew ? "create" : "update";
3018
+ const parts = [type === "create" ? `[文件已创建] ${resolvedPath}` : `[文件已更新] ${resolvedPath}`];
3019
+ if (warning) parts.push(`\n⚠ ${warning}`);
3020
+ parts.push(`\n\`\`\`diff\n${diff}\n\`\`\``);
3021
+ parts.push(`\n耗时: ${elapsedMs}ms`);
3022
+ const details = {
3023
+ type,
3024
+ filePath: resolvedPath,
3025
+ contentLength: params.content.length,
3026
+ elapsedMs
3027
+ };
3028
+ if (warning) details.warning = warning;
3029
+ return {
3030
+ content: [{
3031
+ type: "text",
3032
+ text: parts.join("\n")
3033
+ }],
3034
+ details
3035
+ };
3036
+ }
3037
+ };
3038
+ }
3039
+
3040
+ //#endregion
3041
+ //#region src/cli/repl/tools/memory-tool.ts
3042
+ /**
3043
+ * memory 工具实现 — 持久化记忆管理
3044
+ *
3045
+ * 参考 Hermes-Agent 的 memory_tool.py(单工具 + action 模式、§ 分隔符、快照冻结)
3046
+ * 和 Claude Code 的类型分类(user/project/session 三种记忆类型)。
3047
+ *
3048
+ * 设计要点:
3049
+ * - 存储位置: ~/.zapmyco/memory/
3050
+ * - 条目分隔符: §(Section Sign,借鉴 Hermes)
3051
+ * - 快照冻结: 会话开始时冻结内容到快照,会话中写入不影响当前系统提示
3052
+ * - 原子写入: 先写临时文件,再 rename(防部分写入)
3053
+ * - 自动去重: add 时检查是否已存在相同内容
3054
+ *
3055
+ * @module cli/repl/tools/memory-tool
3056
+ */
3057
+ const MEMORY_DIR = join(homedir(), ".zapmyco", "memory");
3058
+ const SECTION_DELIMITER = "\n§ ";
3059
+ const MAX_CONTENT_LENGTH = 2e3;
3060
+ const MEMORY_FILES = {
3061
+ user: "user.md",
3062
+ project: "project.md",
3063
+ session: "session.md"
3064
+ };
3065
+ const MEMORY_LABELS = {
3066
+ user: "用户画像",
3067
+ project: "项目上下文",
3068
+ session: "会话摘要"
3069
+ };
3070
+ /**
3071
+ * 持久化记忆存储
3072
+ *
3073
+ * 管理 ~/.zapmyco/memory/ 目录下的记忆文件。
3074
+ * 采用快照模式:会话开始时冻结内容到快照,会话中写入不影响快照。
3075
+ */
3076
+ var MemoryStore = class {
3077
+ baseDir;
3078
+ snapshot = /* @__PURE__ */ new Map();
3079
+ /** Promise-based 锁,防止并发 initialize() 调用产生竞态 */
3080
+ initPromise = null;
3081
+ constructor(homeDir) {
3082
+ this.baseDir = homeDir ? join(homeDir, ".zapmyco", "memory") : MEMORY_DIR;
3083
+ }
3084
+ /** 确保目录和默认文件存在(Promise 锁防并发竞态) */
3085
+ async initialize() {
3086
+ if (this.initPromise) return this.initPromise;
3087
+ this.initPromise = this.doInitialize();
3088
+ return this.initPromise;
3089
+ }
3090
+ async doInitialize() {
3091
+ await mkdir(this.baseDir, { recursive: true });
3092
+ for (const [type, filename] of Object.entries(MEMORY_FILES)) {
3093
+ const filepath = join(this.baseDir, filename);
3094
+ try {
3095
+ await readFile(filepath, "utf-8");
3096
+ } catch {
3097
+ await writeFile(filepath, this.buildFileHeader(type), "utf-8");
3098
+ }
3099
+ }
3100
+ const indexPath = join(this.baseDir, "MEMORY.md");
3101
+ try {
3102
+ await readFile(indexPath, "utf-8");
3103
+ } catch {
3104
+ await this.updateIndex();
3105
+ }
3106
+ }
3107
+ /** 冻结当前记忆内容为快照(会话开始时调用) */
3108
+ async freezeSnapshot() {
3109
+ await this.initialize();
3110
+ for (const type of Object.keys(MEMORY_FILES)) {
3111
+ const content = await this.readFileContent(type);
3112
+ this.snapshot.set(type, content);
3113
+ }
3114
+ }
3115
+ /** 获取快照内容(用于系统提示注入,不触发文件读取) */
3116
+ getSnapshot(type) {
3117
+ if (type) return this.snapshot.get(type) ?? "";
3118
+ const parts = [];
3119
+ for (const [t, content] of this.snapshot) if (content.trim()) parts.push(`### ${MEMORY_LABELS[t]}\n${content}`);
3120
+ return parts.join("\n\n") || "(暂无记忆)";
3121
+ }
3122
+ /** 读取指定类型记忆 */
3123
+ async read(type) {
3124
+ await this.initialize();
3125
+ return this.readFileContent(type);
3126
+ }
3127
+ /** 添加一条记忆条目 */
3128
+ async add(type, content) {
3129
+ await this.initialize();
3130
+ if (!content?.trim()) return {
3131
+ ok: false,
3132
+ error: "内容不能为空"
3133
+ };
3134
+ if (content.length > MAX_CONTENT_LENGTH) return {
3135
+ ok: false,
3136
+ error: `内容过长(最大 ${MAX_CONTENT_LENGTH} 字符)`
3137
+ };
3138
+ const existing = await this.readFileContent(type);
3139
+ const entries = this.parseEntries(existing);
3140
+ const normalized = content.trim();
3141
+ if (entries.some((e) => e.trim() === normalized)) return {
3142
+ ok: false,
3143
+ error: "该条目已存在"
3144
+ };
3145
+ entries.push(normalized);
3146
+ await this.writeFileContent(type, entries);
3147
+ await this.updateIndex();
3148
+ return { ok: true };
3149
+ }
3150
+ /** 删除匹配的记忆条目 */
3151
+ async remove(type, oldContent) {
3152
+ await this.initialize();
3153
+ if (!oldContent?.trim()) return {
3154
+ ok: false,
3155
+ error: "old_content 不能为空"
3156
+ };
3157
+ const existing = await this.readFileContent(type);
3158
+ const entries = this.parseEntries(existing);
3159
+ const search = oldContent.trim();
3160
+ const matches = entries.filter((e) => e.includes(search));
3161
+ if (matches.length === 0) return {
3162
+ ok: false,
3163
+ error: "未找到匹配的条目"
3164
+ };
3165
+ if (matches.length > 1) return {
3166
+ ok: false,
3167
+ error: `找到 ${matches.length} 个匹配项,请提供更精确的 old_content。匹配条目:\n${matches.map((m) => ` § ${m}`).join("\n")}`
3168
+ };
3169
+ const newEntries = entries.filter((e) => !e.includes(search));
3170
+ await this.writeFileContent(type, newEntries);
3171
+ await this.updateIndex();
3172
+ return { ok: true };
3173
+ }
3174
+ /** 列出所有记忆索引 */
3175
+ async list() {
3176
+ await this.initialize();
3177
+ const indexPath = join(this.baseDir, "MEMORY.md");
3178
+ try {
3179
+ return await readFile(indexPath, "utf-8") || "暂无记忆索引。";
3180
+ } catch {
3181
+ return "暂无记忆索引。";
3182
+ }
3183
+ }
3184
+ buildFileHeader(type) {
3185
+ return `# ${MEMORY_LABELS[type]}\n\n> 更新: ${(/* @__PURE__ */ new Date()).toISOString()}\n\n`;
3186
+ }
3187
+ parseEntries(content) {
3188
+ const entries = [];
3189
+ const parts = content.split(SECTION_DELIMITER);
3190
+ for (let i = 1; i < parts.length; i++) {
3191
+ const part = parts[i];
3192
+ if (part) {
3193
+ const trimmed = part.trim();
3194
+ if (trimmed) entries.push(trimmed);
3195
+ }
3196
+ }
3197
+ return entries;
3198
+ }
3199
+ entriesToContent(type, entries) {
3200
+ const header = this.buildFileHeader(type);
3201
+ if (entries.length === 0) return header;
3202
+ return header + SECTION_DELIMITER.trimStart() + entries.join(SECTION_DELIMITER);
3203
+ }
3204
+ async readFileContent(type) {
3205
+ const filepath = join(this.baseDir, MEMORY_FILES[type]);
3206
+ try {
3207
+ return await readFile(filepath, "utf-8");
3208
+ } catch {
3209
+ return this.buildFileHeader(type);
3210
+ }
3211
+ }
3212
+ /** 原子写入:先写临时文件,再 rename */
3213
+ async writeFileContent(type, entries) {
3214
+ const filepath = join(this.baseDir, MEMORY_FILES[type]);
3215
+ const tmpPath = `${filepath}.tmp`;
3216
+ await writeFile(tmpPath, this.entriesToContent(type, entries), "utf-8");
3217
+ await rename(tmpPath, filepath);
3218
+ }
3219
+ async updateIndex() {
3220
+ const lines = ["# Memory Index", ""];
3221
+ for (const [type, filename] of Object.entries(MEMORY_FILES)) {
3222
+ const content = await this.readFileContent(type);
3223
+ const count = this.parseEntries(content).length;
3224
+ const label = MEMORY_LABELS[type];
3225
+ lines.push(`- [${filename}](${filename}) — ${label}(${count} 条)`);
3226
+ }
3227
+ await writeFile(join(this.baseDir, "MEMORY.md"), lines.join("\n"), "utf-8");
3228
+ }
3229
+ };
3230
+ const MEMORY_DESCRIPTION = `持久化记忆管理工具 — 跨会话保存和检索信息,让 Agent "越用越懂用户"。
3231
+
3232
+ ## 记忆类型 (type)
3233
+ - "user": 用户画像 — 偏好、习惯、知识背景、角色
3234
+ - "project": 项目上下文 — 决策、约定、目标
3235
+ - "session": 会话摘要 — 最近会话的关键结论或进展
3236
+
3237
+ ## 操作 (action)
3238
+ - "read": 读取指定类型的记忆内容
3239
+ - "add": 添加一条记忆条目(自动去重)
3240
+ - "remove": 删除匹配的记忆条目(需提供 old_content 精确匹配)
3241
+ - "list": 列出 MEMORY.md 索引摘要
3242
+
3243
+ ## 何时保存记忆
3244
+ - 用户明确告知偏好、习惯、知识背景时 → 保存到 user
3245
+ - 项目做出重要决策或约定时 → 保存到 project
3246
+ - 会话结束时有值得跨会话保留的结论 → 保存到 session
3247
+ - 用户纠正你的行为或给出反馈时 → 保存到 user
3248
+
3249
+ ## 何时不保存
3250
+ - 临时任务进度、会话状态(使用 TaskManage 管理)
3251
+ - 代码细节(可直接从代码库获取)
3252
+ - 一次性查询的内容`;
3253
+ /** 全局单例 MemoryStore(会话级生命周期) */
3254
+ let globalStore = null;
3255
+ /**
3256
+ * 获取或创建 MemoryStore 实例
3257
+ */
3258
+ function getMemoryStore() {
3259
+ if (!globalStore) globalStore = new MemoryStore();
3260
+ return globalStore;
3261
+ }
3262
+ /**
3263
+ * 创建 memory 工具
3264
+ */
3265
+ function createMemoryTool() {
3266
+ const store = getMemoryStore();
3267
+ return {
3268
+ id: "Memory",
3269
+ label: "记忆管理",
3270
+ description: MEMORY_DESCRIPTION,
3271
+ parameters: {
3272
+ type: "object",
3273
+ properties: {
3274
+ action: {
3275
+ type: "string",
3276
+ description: "操作类型: \"read\"(读取), \"add\"(添加), \"remove\"(删除), \"list\"(列出索引)。",
3277
+ enum: [
3278
+ "read",
3279
+ "add",
3280
+ "remove",
3281
+ "list"
3282
+ ]
3283
+ },
3284
+ type: {
3285
+ type: "string",
3286
+ description: "记忆类型: \"user\"(用户画像), \"project\"(项目上下文), \"session\"(会话摘要)。read/add/remove 操作需要指定。",
3287
+ enum: [
3288
+ "user",
3289
+ "project",
3290
+ "session"
3291
+ ]
3292
+ },
3293
+ content: {
3294
+ type: "string",
3295
+ description: "要添加的记忆内容(action=\"add\" 时必填)"
3296
+ },
3297
+ old_content: {
3298
+ type: "string",
3299
+ description: "要删除的记忆内容(action=\"remove\" 时必填,用于精确匹配已有条目)"
3300
+ }
3301
+ }
3302
+ },
3303
+ async execute(_toolCallId, params) {
3304
+ const action = params.action ?? "read";
3305
+ const type = params.type ?? "user";
3306
+ switch (action) {
3307
+ case "read": return buildReadResult$1(store, type);
3308
+ case "add": return buildAddResult(store, type, params.content);
3309
+ case "remove": return buildRemoveResult(store, type, params.old_content);
3310
+ case "list": return buildListResult(store);
3311
+ default: return {
3312
+ content: [{
3313
+ type: "text",
3314
+ text: `不支持的操作: ${action}`
3315
+ }],
3316
+ details: {
3317
+ action,
3318
+ error: `不支持的操作: ${action}`
3319
+ }
3320
+ };
3321
+ }
3322
+ }
3323
+ };
3324
+ }
3325
+ async function buildReadResult$1(store, type) {
3326
+ const content = await store.read(type);
3327
+ const label = MEMORY_LABELS[type];
3328
+ if (!content.trim() || content.trim() === `# ${label}`) return {
3329
+ content: [{
3330
+ type: "text",
3331
+ text: `${label}暂无记忆条目。使用 action="add" 添加。`
3332
+ }],
3333
+ details: {
3334
+ action: "read",
3335
+ type,
3336
+ content: ""
3337
+ }
3338
+ };
3339
+ return {
3340
+ content: [{
3341
+ type: "text",
3342
+ text: content
3343
+ }],
3344
+ details: {
3345
+ action: "read",
3346
+ type,
3347
+ content
3348
+ }
3349
+ };
3350
+ }
3351
+ async function buildAddResult(store, type, content) {
3352
+ if (!content) return {
3353
+ content: [{
3354
+ type: "text",
3355
+ text: "请提供 content 参数(要保存的记忆内容)。"
3356
+ }],
3357
+ details: {
3358
+ action: "add",
3359
+ type,
3360
+ error: "content 参数为空"
3361
+ }
3362
+ };
3363
+ const result = await store.add(type, content);
3364
+ if (!result.ok) return {
3365
+ content: [{
3366
+ type: "text",
3367
+ text: `[保存失败] ${result.error}`
3368
+ }],
3369
+ details: {
3370
+ action: "add",
3371
+ type,
3372
+ error: result.error
3373
+ }
3374
+ };
3375
+ return {
3376
+ content: [{
3377
+ type: "text",
3378
+ text: `已保存到${MEMORY_LABELS[type]}:\n§ ${content.trim()}`
3379
+ }],
3380
+ details: {
3381
+ action: "add",
3382
+ type,
3383
+ content: content.trim()
3384
+ }
3385
+ };
3386
+ }
3387
+ async function buildRemoveResult(store, type, oldContent) {
3388
+ if (!oldContent) return {
3389
+ content: [{
3390
+ type: "text",
3391
+ text: "请提供 old_content 参数(要删除的记忆内容,用于匹配已有条目)。"
3392
+ }],
3393
+ details: {
3394
+ action: "remove",
3395
+ type,
3396
+ error: "old_content 参数为空"
3397
+ }
3398
+ };
3399
+ const result = await store.remove(type, oldContent);
3400
+ if (!result.ok) return {
3401
+ content: [{
3402
+ type: "text",
3403
+ text: `[删除失败] ${result.error}`
3404
+ }],
3405
+ details: {
3406
+ action: "remove",
3407
+ type,
3408
+ error: result.error
3409
+ }
3410
+ };
3411
+ return {
3412
+ content: [{
3413
+ type: "text",
3414
+ text: `已从${MEMORY_LABELS[type]}删除匹配条目。`
3415
+ }],
3416
+ details: {
3417
+ action: "remove",
3418
+ type,
3419
+ removed: oldContent.trim()
3420
+ }
3421
+ };
3422
+ }
3423
+ async function buildListResult(store) {
3424
+ const content = await store.list();
3425
+ return {
3426
+ content: [{
3427
+ type: "text",
3428
+ text: content
3429
+ }],
3430
+ details: {
3431
+ action: "list",
3432
+ content
3433
+ }
3434
+ };
3435
+ }
3436
+
3437
+ //#endregion
3438
+ //#region src/cli/repl/tools/process-registry.ts
3439
+ /** 每个进程最大输出字符数 */
3440
+ const MAX_OUTPUT_CHARS$1 = 2e5;
3441
+ /** 日志预览尾部行数 */
3442
+ const LOG_TAIL_LINES = 40;
3443
+ /** 默认 TTL(30 分钟),超时自动清理 */
3444
+ const DEFAULT_TTL_MS$1 = 1800 * 1e3;
3445
+ /** 清理间隔 */
3446
+ const CLEANUP_INTERVAL_MS = 6e4;
3447
+ var ProcessRegistry = class {
3448
+ processes = /* @__PURE__ */ new Map();
3449
+ cleanupTimer = null;
3450
+ /**
3451
+ * 注册新的后台进程
3452
+ */
3453
+ register(command, childProcess, options) {
3454
+ const sessionId = `proc_${randomBytes(6).toString("hex")}`;
3455
+ const session = {
3456
+ sessionId,
3457
+ command,
3458
+ pid: childProcess.pid ?? 0,
3459
+ status: "running",
3460
+ startTime: Date.now()
3461
+ };
3462
+ if (options?.workdir !== void 0) session.workdir = options.workdir;
3463
+ const record = {
3464
+ session,
3465
+ childProcess,
3466
+ stdoutChunks: [],
3467
+ stderrChunks: [],
3468
+ totalOutputSize: 0
3469
+ };
3470
+ if (options?.onComplete !== void 0) record.onComplete = options.onComplete;
3471
+ if (childProcess.stdout) childProcess.stdout.on("data", (data) => {
3472
+ this.appendOutput(record, data.toString("utf-8"));
3473
+ });
3474
+ if (childProcess.stderr) childProcess.stderr.on("data", (data) => {
3475
+ this.appendOutput(record, data.toString("utf-8"));
3476
+ });
3477
+ childProcess.on("close", (code, signal) => {
3478
+ session.endTime = Date.now();
3479
+ session.exitCode = code;
3480
+ session.signal = signal;
3481
+ if (session.status === "running") if (code === 0 || code === null) session.status = "exited";
3482
+ else if (signal && [
3483
+ "SIGTERM",
3484
+ "SIGKILL",
3485
+ "SIGINT"
3486
+ ].includes(signal)) session.status = "killed";
3487
+ else session.status = "exited";
3488
+ record.onComplete?.(session);
3489
+ });
3490
+ childProcess.on("error", (_err) => {
3491
+ if (session.status === "running") {
3492
+ session.status = "errored";
3493
+ session.endTime = Date.now();
3494
+ }
3495
+ record.onComplete?.(session);
3496
+ });
3497
+ this.processes.set(sessionId, record);
3498
+ this.ensureCleanupTimer();
3499
+ return session;
3500
+ }
3501
+ /**
3502
+ * 列出所有进程
3503
+ */
3504
+ list() {
3505
+ const sessions = [];
3506
+ for (const record of this.processes.values()) sessions.push({ ...record.session });
3507
+ return sessions.sort((a, b) => b.startTime - a.startTime);
3508
+ }
3509
+ /**
3510
+ * 轮询进程状态(返回新输出)
3511
+ */
3512
+ poll(sessionId) {
3513
+ const record = this.processes.get(sessionId);
3514
+ if (!record) return null;
3515
+ const newOutput = record.stdoutChunks.join("");
3516
+ record.stdoutChunks = [];
3517
+ return {
3518
+ session: { ...record.session },
3519
+ newOutput
3520
+ };
3521
+ }
3522
+ /**
3523
+ * 获取进程完整日志
3524
+ */
3525
+ getLog(sessionId, options) {
3526
+ const record = this.processes.get(sessionId);
3527
+ if (!record) return null;
3528
+ const offset = options?.offset ?? 0;
3529
+ const limit = options?.limit ?? LOG_TAIL_LINES;
3530
+ const sliced = this.getAllOutput(record).split("\n").slice(offset, offset + limit);
3531
+ return {
3532
+ session: { ...record.session },
3533
+ output: sliced.join("\n")
3534
+ };
3535
+ }
3536
+ /**
3537
+ * 等待进程完成
3538
+ */
3539
+ wait(sessionId, timeoutMs) {
3540
+ const record = this.processes.get(sessionId);
3541
+ if (!record) return Promise.resolve(null);
3542
+ if (record.session.status !== "running") return Promise.resolve({ ...record.session });
3543
+ return new Promise((resolve) => {
3544
+ const timeout = timeoutMs ? setTimeout(() => {
3545
+ resolve({ ...record.session });
3546
+ }, timeoutMs) : null;
3547
+ const originalHandler = record.onComplete;
3548
+ record.onComplete = (session) => {
3549
+ originalHandler?.(session);
3550
+ if (timeout) clearTimeout(timeout);
3551
+ resolve({ ...session });
3552
+ };
3553
+ });
3554
+ }
3555
+ /**
3556
+ * 终止进程(先 SIGTERM,2 秒后 SIGKILL)
3557
+ */
3558
+ kill(sessionId) {
3559
+ const record = this.processes.get(sessionId);
3560
+ if (!record) return null;
3561
+ if (record.session.status !== "running") return { ...record.session };
3562
+ record.childProcess.kill("SIGTERM");
3563
+ record.session.status = "killed";
3564
+ setTimeout(() => {
3565
+ if (record.childProcess.exitCode === null) try {
3566
+ record.childProcess.kill("SIGKILL");
3567
+ } catch {}
3568
+ }, 2e3);
3569
+ return { ...record.session };
3570
+ }
3571
+ /**
3572
+ * 向进程 stdin 写入数据
3573
+ */
3574
+ write(sessionId, data, newline) {
3575
+ const record = this.processes.get(sessionId);
3576
+ if (!record) return null;
3577
+ if (record.session.status !== "running") return { ...record.session };
3578
+ if (record.childProcess.stdin && !record.childProcess.stdin.destroyed) {
3579
+ record.childProcess.stdin.write(data);
3580
+ if (newline) record.childProcess.stdin.write("\n");
3581
+ }
3582
+ return { ...record.session };
3583
+ }
3584
+ /**
3585
+ * 移除进程记录(清理已完成进程)
3586
+ */
3587
+ remove(sessionId) {
3588
+ return this.processes.delete(sessionId);
3589
+ }
3590
+ /**
3591
+ * 获取所有已完成的进程 session ID
3592
+ */
3593
+ getCompletedSessionIds() {
3594
+ const ids = [];
3595
+ for (const [id, record] of this.processes) if (record.session.status !== "running") ids.push(id);
3596
+ return ids;
3597
+ }
3598
+ /**
3599
+ * 获取进程数
3600
+ */
3601
+ get count() {
3602
+ return this.processes.size;
3603
+ }
3604
+ /**
3605
+ * 销毁注册表(清理所有进程)
3606
+ */
3607
+ destroy() {
3608
+ if (this.cleanupTimer) {
3609
+ clearInterval(this.cleanupTimer);
3610
+ this.cleanupTimer = null;
3611
+ }
3612
+ for (const record of this.processes.values()) if (record.session.status === "running") try {
3613
+ record.childProcess.kill("SIGKILL");
3614
+ } catch {}
3615
+ this.processes.clear();
3616
+ }
3617
+ appendOutput(record, data) {
3618
+ if (record.totalOutputSize >= MAX_OUTPUT_CHARS$1) return;
3619
+ const remaining = MAX_OUTPUT_CHARS$1 - record.totalOutputSize;
3620
+ const chunk = data.length > remaining ? data.slice(0, remaining) : data;
3621
+ record.stdoutChunks.push(chunk);
3622
+ record.totalOutputSize += chunk.length;
3623
+ }
3624
+ getAllOutput(record) {
3625
+ return record.stdoutChunks.join("");
3626
+ }
3627
+ ensureCleanupTimer() {
3628
+ if (this.cleanupTimer) return;
3629
+ this.cleanupTimer = setInterval(() => {
3630
+ const now = Date.now();
3631
+ for (const [id, record] of this.processes) if (record.session.status !== "running") {
3632
+ if (now - (record.session.endTime ?? record.session.startTime) > DEFAULT_TTL_MS$1) this.processes.delete(id);
3633
+ }
3634
+ }, CLEANUP_INTERVAL_MS);
3635
+ if (this.cleanupTimer.unref) this.cleanupTimer.unref();
3636
+ }
3637
+ };
3638
+ /** 全局单例 */
3639
+ let globalRegistry = null;
3640
+ /** 获取全局 ProcessRegistry 实例 */
3641
+ function getProcessRegistry() {
3642
+ if (!globalRegistry) globalRegistry = new ProcessRegistry();
3643
+ return globalRegistry;
3644
+ }
3645
+
3646
+ //#endregion
3647
+ //#region src/cli/repl/tools/shell-security.ts
3648
+ /**
3649
+ * Shell 安全检查模块
3650
+ *
3651
+ * 多层安全防护:
3652
+ * 1. 硬性阻断列表 — 危险命令无条件拒绝
3653
+ * 2. 危险命令审批 — 高风险操作需用户确认
3654
+ * 3. 环境变量清洗 — 防止敏感信息泄漏
3655
+ * 4. 工作目录验证 — 限制操作范围
3656
+ *
3657
+ * 参考 Hermes (approval.py) 和 Claude Code (bashSecurity.ts) 的设计。
3658
+ *
3659
+ * @module cli/repl/tools/shell-security
3660
+ */
3661
+ const BLOCK_RULES = [
3662
+ {
3663
+ name: "rm-rf-root",
3664
+ pattern: /\brm\s+-[a-zA-Z0-9-]*[rf][a-zA-Z0-9-]*[rf][a-zA-Z0-9-]*\s+(?:--no-preserve-root\s+)?["']?\/["']?(?:\s|$)/,
3665
+ risk: "critical",
3666
+ reason: "禁止递归强制删除根目录"
3667
+ },
3668
+ {
3669
+ name: "dd-block-device",
3670
+ pattern: /\bdd\s+.*\bof=\/dev\/(?:sd|nvme|hd|xvd|vd|mmcblk)/,
3671
+ risk: "critical",
3672
+ reason: "禁止直接写入裸块设备"
3673
+ },
3674
+ {
3675
+ name: "system-shutdown",
3676
+ pattern: /\b(?:shutdown|reboot|halt|poweroff|init\s+[06])\b/,
3677
+ risk: "critical",
3678
+ reason: "禁止执行系统关机/重启命令"
3679
+ },
3680
+ {
3681
+ name: "fork-bomb",
3682
+ pattern: /:\s*\(\s*\)\s*\{/,
3683
+ risk: "critical",
3684
+ reason: "检测到 fork bomb 模式"
3685
+ },
3686
+ {
3687
+ name: "mkfs",
3688
+ pattern: /\bmkfs\.\w+/,
3689
+ risk: "critical",
3690
+ reason: "禁止执行格式化命令"
3691
+ },
3692
+ {
3693
+ name: "chmod-root",
3694
+ pattern: /\bchmod\s+(?:.*\s)?-R\s+777\s+\//,
3695
+ risk: "critical",
3696
+ reason: "禁止递归修改根目录权限为 777"
3697
+ },
3698
+ {
3699
+ name: "overwrite-system",
3700
+ pattern: /(?:>|>>)\s*\/etc\/(?:passwd|shadow|sudoers|hosts)/,
3701
+ risk: "critical",
3702
+ reason: "禁止覆盖关键系统文件"
3703
+ }
3704
+ ];
3705
+ const APPROVAL_RULES = [
3706
+ {
3707
+ name: "rm-recursive",
3708
+ pattern: /\brm\s+(?:-[a-zA-Z]*r[a-zA-Z]*|-rf?)\b/,
3709
+ risk: "high",
3710
+ message: "检测到递归删除操作,请确认要删除的文件/目录。"
3711
+ },
3712
+ {
3713
+ name: "force-push-main",
3714
+ pattern: /\bgit\s+push\s+(?:-[a-zA-Z]*f[a-zA-Z]*|--force)\s+origin\s+(?:main|master)\b/,
3715
+ risk: "high",
3716
+ message: "检测到强制推送到 main/master 分支,可能覆盖远程历史。"
3717
+ },
3718
+ {
3719
+ name: "curl-pipe-shell",
3720
+ pattern: /\b(?:curl|wget)\s+.*\|\s*(?:sh|bash|zsh|python|ruby|perl)\b/,
3721
+ risk: "high",
3722
+ message: "检测到从网络下载并直接执行脚本,请确认来源可信。"
3723
+ },
3724
+ {
3725
+ name: "eval-exec",
3726
+ pattern: /\beval\s+/,
3727
+ risk: "medium",
3728
+ message: "检测到 eval 命令,可能执行动态内容。"
3729
+ },
3730
+ {
3731
+ name: "source-exec",
3732
+ pattern: /\bsource\s+/,
3733
+ risk: "medium",
3734
+ message: "检测到 source 命令执行文件。"
3735
+ },
3736
+ {
3737
+ name: "sudo",
3738
+ pattern: /\bsudo\b/,
3739
+ risk: "medium",
3740
+ message: "检测到 sudo 提权操作。"
3741
+ },
3742
+ {
3743
+ name: "chmod-system",
3744
+ pattern: /\bchmod\s+.*\/(?:etc|usr|bin|sbin|opt|var|home)\b/,
3745
+ risk: "medium",
3746
+ message: "检测到修改系统目录权限。"
3747
+ },
3748
+ {
3749
+ name: "chown-recursive",
3750
+ pattern: /\bchown\s+(?:-[a-zA-Z]*R[a-zA-Z]*\s+)/,
3751
+ risk: "medium",
3752
+ message: "检测到递归修改文件所有者。"
3753
+ }
3754
+ ];
3755
+ const BLOCKED_ENV_KEYS = new Set([
3756
+ "CI_JOB_TOKEN",
3757
+ "CI_BUILD_TOKEN",
3758
+ "GITLAB_TOKEN",
3759
+ "CIRCLECI_TOKEN",
3760
+ "TRAVIS_TOKEN",
3761
+ "DOCKER_PASSWORD",
3762
+ "DOCKER_AUTH",
3763
+ "NPM_TOKEN",
3764
+ "NPM_AUTH_TOKEN",
3765
+ "NODE_AUTH_TOKEN",
3766
+ "OPENAI_API_KEY",
3767
+ "ANTHROPIC_API_KEY",
3768
+ "COHERE_API_KEY",
3769
+ "GOOGLE_AI_API_KEY",
3770
+ "MISTRAL_API_KEY",
3771
+ "DEEPSEEK_API_KEY",
3772
+ "AZURE_OPENAI_API_KEY",
3773
+ "HUGGINGFACE_TOKEN",
3774
+ "REPLICATE_API_KEY",
3775
+ "DATABASE_URL",
3776
+ "DB_PASSWORD",
3777
+ "PGPASSWORD",
3778
+ "MYSQL_PWD",
3779
+ "REDIS_URL",
3780
+ "MONGO_URL",
3781
+ "ELASTIC_URL",
3782
+ "JWT_SECRET",
3783
+ "ENCRYPTION_KEY",
3784
+ "PRIVATE_KEY",
3785
+ "AWS_SECRET_ACCESS_KEY",
3786
+ "AWS_SESSION_TOKEN",
3787
+ "LD_PRELOAD",
3788
+ "DYLD_INSERT_LIBRARIES",
3789
+ "DYLD_LIBRARY_PATH",
3790
+ "LD_LIBRARY_PATH",
3791
+ "BASH_FUNC_",
3792
+ "NODE_OPTIONS",
3793
+ "PYTHONPATH",
3794
+ "PERL5LIB",
3795
+ "RUBYLIB",
3796
+ "CLASSPATH"
3797
+ ]);
3798
+ const BLOCKED_ENV_KEY_PREFIXES = [
3799
+ "AWS_SECRET_",
3800
+ "AZURE_CLIENT_SECRET_",
3801
+ "GCP_SECRET_"
3802
+ ];
3803
+ const FORBIDDEN_WORKDIR_PREFIXES = ["/etc", "/root"];
3804
+ /**
3805
+ * 检查命令安全性(阻断检查 + 审批检查)
3806
+ */
3807
+ function checkCommandSecurity(command) {
3808
+ const normalized = stripAnsi(command).replace(/\s+/g, " ").trim();
3809
+ if (!normalized) return {
3810
+ allowed: false,
3811
+ blocked: true,
3812
+ reason: "命令为空"
3813
+ };
3814
+ for (const rule of BLOCK_RULES) if (rule.pattern.test(normalized)) return {
3815
+ allowed: false,
3816
+ blocked: true,
3817
+ risk: rule.risk,
3818
+ reason: rule.reason,
3819
+ matchedRule: rule.name
3820
+ };
3821
+ for (const rule of APPROVAL_RULES) if (rule.pattern.test(normalized)) return {
3822
+ allowed: true,
3823
+ requiresApproval: true,
3824
+ risk: rule.risk,
3825
+ reason: rule.message,
3826
+ matchedRule: rule.name
3827
+ };
3828
+ return { allowed: true };
3829
+ }
3830
+ /**
3831
+ * 清洗环境变量(返回安全的子进程环境)
3832
+ */
3833
+ function sanitizeEnv(customEnv) {
3834
+ const env = {};
3835
+ for (const [key, value] of Object.entries(process.env)) {
3836
+ if (value === void 0) continue;
3837
+ if (BLOCKED_ENV_KEYS.has(key.toUpperCase())) continue;
3838
+ if (BLOCKED_ENV_KEY_PREFIXES.some((prefix) => key.toUpperCase().startsWith(prefix))) continue;
3839
+ env[key] = value;
3840
+ }
3841
+ if (customEnv) for (const [key, value] of Object.entries(customEnv)) {
3842
+ if (BLOCKED_ENV_KEYS.has(key.toUpperCase())) continue;
3843
+ if (BLOCKED_ENV_KEY_PREFIXES.some((prefix) => key.toUpperCase().startsWith(prefix))) continue;
3844
+ env[key] = value;
3845
+ }
3846
+ return env;
3847
+ }
3848
+ /**
3849
+ * 验证工作目录安全性
3850
+ */
3851
+ function validateWorkdir(workdir) {
3852
+ const resolved = workdir ? path.resolve(workdir) : process.cwd();
3853
+ for (const prefix of FORBIDDEN_WORKDIR_PREFIXES) if (resolved === prefix || resolved.startsWith(prefix + path.sep)) return {
3854
+ valid: false,
3855
+ reason: `不允许在 ${prefix} 目录下执行命令`,
3856
+ resolved
3857
+ };
3858
+ return {
3859
+ valid: true,
3860
+ resolved
3861
+ };
3862
+ }
3863
+ /**
3864
+ * 剥离 ANSI 转义序列(ECMA-48 CSI 序列)
3865
+ */
3866
+ function stripAnsi(text) {
3867
+ const esc = String.fromCharCode(27);
3868
+ const bel = String.fromCharCode(7);
3869
+ const patterns = [
3870
+ new RegExp(`${esc}\\[[0-9;]*[a-zA-Z]`, "g"),
3871
+ new RegExp(`${esc}\\][0-9;]*[^${bel}]*(${bel}|$)`, "g"),
3872
+ new RegExp(`${esc}[PX^_][^${esc}]*${esc}\\\\`, "g"),
3873
+ new RegExp(`${esc}\\[[?]?[0-9;]*[hl]`, "g"),
3874
+ new RegExp(`${esc}[=>]`, "g"),
3875
+ new RegExp(`${esc}[\\x40-\\x5f]`, "g")
3876
+ ];
3877
+ let result = text;
3878
+ for (const pattern of patterns) result = result.replace(pattern, "");
3879
+ return result;
3880
+ }
3881
+ /**
3882
+ * 截断输出(头 40% + 尾 60%)
3883
+ */
3884
+ function truncateOutput(text, maxChars = 1e5) {
3885
+ if (text.length <= maxChars) return text;
3886
+ const headChars = Math.floor(maxChars * .4);
3887
+ const tailChars = Math.floor(maxChars * .6);
3888
+ const head = text.slice(0, headChars);
3889
+ const tail = text.slice(-tailChars);
3890
+ return head + `\n\n... [已截断 ${text.length - headChars - tailChars} 个字符] ...\n\n` + tail;
3891
+ }
3892
+ /**
3893
+ * 脱敏输出中的敏感信息
3894
+ */
3895
+ function redactSensitiveInfo(text) {
3896
+ let result = text;
3897
+ result = result.replace(/sk-[a-zA-Z0-9_-]{20,}/g, "sk-***");
3898
+ result = result.replace(/ghp_[a-zA-Z0-9]{20,}/g, "ghp_***");
3899
+ result = result.replace(/AKIA[0-9A-Z]{16}/g, "AKIA***");
3900
+ result = result.replace(/eyJ[a-zA-Z0-9_-]+\.eyJ[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+/g, "<JWT-TOKEN>");
3901
+ result = result.replace(/([a-zA-Z0-9_-]{15,}=)([a-zA-Z0-9+/]{20,})/g, "$1***");
3902
+ result = result.replace(/-----BEGIN (?:RSA|EC|DSA|OPENSSH) PRIVATE KEY-----[\s\S]*?-----END (?:RSA|EC|DSA|OPENSSH) PRIVATE KEY-----/g, "<PRIVATE-KEY>");
3903
+ return result;
3904
+ }
3905
+
3906
+ //#endregion
3907
+ //#region src/cli/repl/tools/shell-exec.ts
3908
+ /**
3909
+ * exec 工具实现 — Shell 命令执行
3910
+ *
3911
+ * 功能:
3912
+ * - 前台/后台命令执行
3913
+ * - 超时控制
3914
+ * - PTY 支持(可选)
3915
+ * - 安全检查集成
3916
+ * - 输出处理(ANSI 剥离、截断、脱敏)
3917
+ *
3918
+ * 参考 Hermes (terminal_tool.py) 和 Claude Code (BashTool.tsx) 的设计。
3919
+ *
3920
+ * @module cli/repl/tools/shell-exec
3921
+ */
3922
+ const DEFAULT_TIMEOUT_SEC = 180;
3923
+ const MAX_FOREGROUND_TIMEOUT_SEC = 600;
3924
+ const MAX_OUTPUT_CHARS = 1e5;
3925
+ const KILL_GRACE_PERIOD_MS = 2e3;
3926
+ function createExecTool() {
3927
+ return {
3928
+ id: "Exec",
3929
+ label: "执行命令",
3930
+ description: "在本地执行 Shell 命令。支持前台/后台模式、超时控制、PTY 交互。可用于构建、测试、git 操作、文件操作、包管理等。当需要执行命令行操作时调用此工具,不要直接操作文件系统。注意:危险命令(如 rm -rf /、shutdown 等)会被自动阻断。长时间运行的任务请使用 background=true 在后台执行。",
3931
+ parameters: {
3932
+ type: "object",
3933
+ properties: {
3934
+ command: {
3935
+ type: "string",
3936
+ description: "要执行的 Shell 命令"
3937
+ },
3938
+ workdir: {
3939
+ type: "string",
3940
+ description: "工作目录,默认为当前项目根目录"
3941
+ },
3942
+ timeout: {
3943
+ type: "number",
3944
+ description: `超时时间(秒),默认 ${DEFAULT_TIMEOUT_SEC},最大 ${MAX_FOREGROUND_TIMEOUT_SEC}`
3945
+ },
3946
+ background: {
3947
+ type: "boolean",
3948
+ description: "是否在后台运行。长时间任务(构建、服务启动等)应设为 true"
3949
+ },
3950
+ pty: {
3951
+ type: "boolean",
3952
+ description: "是否使用 PTY 模式(适用于交互式命令),默认 false"
3953
+ }
3954
+ },
3955
+ required: ["command"]
3956
+ },
3957
+ async execute(_toolCallId, params, signal) {
3958
+ const startTime = Date.now();
3959
+ const securityResult = checkCommandSecurity(params.command);
3960
+ if (securityResult.blocked) return {
3961
+ content: [{
3962
+ type: "text",
3963
+ text: formatBlockedOutput(params.command, securityResult.reason)
3964
+ }],
3965
+ details: {
3966
+ command: params.command,
3967
+ status: "blocked",
3968
+ exitCode: -1,
3969
+ durationMs: Date.now() - startTime
3970
+ }
3971
+ };
3972
+ const workdirResult = validateWorkdir(params.workdir);
3973
+ if (!workdirResult.valid) return {
3974
+ content: [{
3975
+ type: "text",
3976
+ text: `工作目录无效: ${workdirResult.reason}`
3977
+ }],
3978
+ details: {
3979
+ command: params.command,
3980
+ status: "error",
3981
+ exitCode: -1,
3982
+ durationMs: Date.now() - startTime
3983
+ }
3984
+ };
3985
+ const shell = getShell();
3986
+ const isBackground = params.background === true;
3987
+ const timeoutSec = isBackground ? 0 : Math.min(params.timeout ?? DEFAULT_TIMEOUT_SEC, MAX_FOREGROUND_TIMEOUT_SEC);
3988
+ const env = sanitizeEnv();
3989
+ let childProcess;
3990
+ try {
3991
+ childProcess = spawn(shell, ["-c", params.command], {
3992
+ cwd: workdirResult.resolved,
3993
+ env,
3994
+ stdio: [
3995
+ "pipe",
3996
+ "pipe",
3997
+ "pipe"
3998
+ ],
3999
+ ...os.platform() !== "win32" ? { detached: false } : {}
4000
+ });
4001
+ } catch (err) {
4002
+ return {
4003
+ content: [{
4004
+ type: "text",
4005
+ text: `进程启动失败: ${err instanceof Error ? err.message : String(err)}`
4006
+ }],
4007
+ details: {
4008
+ command: params.command,
4009
+ status: "error",
4010
+ exitCode: -1,
4011
+ durationMs: Date.now() - startTime
4012
+ }
4013
+ };
4014
+ }
4015
+ if (isBackground) {
4016
+ const session = getProcessRegistry().register(params.command, childProcess, { workdir: workdirResult.resolved });
4017
+ return {
4018
+ content: [{
4019
+ type: "text",
4020
+ text: `[后台进程已启动]\nSession ID: ${session.sessionId}\nPID: ${session.pid}\n命令: ${params.command}\n使用 process 工具 (action=poll) 获取状态和输出。`
4021
+ }],
4022
+ details: {
4023
+ command: params.command,
4024
+ status: "running",
4025
+ pid: session.pid,
4026
+ sessionId: session.sessionId,
4027
+ durationMs: Date.now() - startTime,
4028
+ workdir: workdirResult.resolved
4029
+ }
4030
+ };
4031
+ }
4032
+ try {
4033
+ const result = await runForeground(childProcess, timeoutSec, signal);
4034
+ const durationMs = Date.now() - startTime;
4035
+ return {
4036
+ content: [{
4037
+ type: "text",
4038
+ text: processOutput(result.stdout, result.stderr, result.exitCode, result.timedOut, result.killed)
4039
+ }],
4040
+ details: {
4041
+ command: params.command,
4042
+ status: result.timedOut ? "timeout" : result.killed ? "killed" : result.exitCode === 0 ? "completed" : "failed",
4043
+ exitCode: result.exitCode,
4044
+ signal: result.signal,
4045
+ durationMs,
4046
+ workdir: workdirResult.resolved
4047
+ }
4048
+ };
4049
+ } catch (err) {
4050
+ const durationMs = Date.now() - startTime;
4051
+ return {
4052
+ content: [{
4053
+ type: "text",
4054
+ text: `命令执行异常: ${err instanceof Error ? err.message : String(err)}`
4055
+ }],
4056
+ details: {
4057
+ command: params.command,
4058
+ status: "error",
4059
+ exitCode: -1,
4060
+ durationMs
4061
+ }
4062
+ };
4063
+ }
4064
+ }
4065
+ };
4066
+ }
4067
+ function runForeground(childProcess, timeoutSec, signal) {
4068
+ return new Promise((resolve) => {
4069
+ const stdoutChunks = [];
4070
+ const stderrChunks = [];
4071
+ let timedOut = false;
4072
+ let killed = false;
4073
+ let settled = false;
4074
+ const settle = (result) => {
4075
+ if (settled) return;
4076
+ settled = true;
4077
+ if (timeoutTimer) clearTimeout(timeoutTimer);
4078
+ if (killTimer) clearTimeout(killTimer);
4079
+ resolve(result);
4080
+ };
4081
+ if (childProcess.stdout) childProcess.stdout.on("data", (data) => {
4082
+ stdoutChunks.push(data.toString("utf-8"));
4083
+ });
4084
+ if (childProcess.stderr) childProcess.stderr.on("data", (data) => {
4085
+ stderrChunks.push(data.toString("utf-8"));
4086
+ });
4087
+ childProcess.on("exit", (code, procSignal) => {
4088
+ settle({
4089
+ stdout: stdoutChunks.join(""),
4090
+ stderr: stderrChunks.join(""),
4091
+ exitCode: code,
4092
+ signal: procSignal,
4093
+ timedOut,
4094
+ killed
4095
+ });
4096
+ });
4097
+ childProcess.on("error", (err) => {
4098
+ settle({
4099
+ stdout: stdoutChunks.join(""),
4100
+ stderr: stderrChunks.join("") + `\n[进程错误: ${err.message}]`,
4101
+ exitCode: -1,
4102
+ signal: null,
4103
+ timedOut: false,
4104
+ killed: false
4105
+ });
4106
+ });
4107
+ let timeoutTimer;
4108
+ let killTimer;
4109
+ if (timeoutSec > 0) timeoutTimer = setTimeout(() => {
4110
+ timedOut = true;
4111
+ try {
4112
+ if (childProcess.pid && os.platform() !== "win32") try {
4113
+ process.kill(-childProcess.pid, "SIGTERM");
4114
+ } catch {
4115
+ childProcess.kill("SIGTERM");
4116
+ }
4117
+ else childProcess.kill("SIGTERM");
4118
+ } catch {}
4119
+ killTimer = setTimeout(() => {
4120
+ killed = true;
4121
+ try {
4122
+ if (childProcess.pid && os.platform() !== "win32") try {
4123
+ process.kill(-childProcess.pid, "SIGKILL");
4124
+ } catch {
4125
+ childProcess.kill("SIGKILL");
4126
+ }
4127
+ else childProcess.kill("SIGKILL");
4128
+ } catch {}
4129
+ }, KILL_GRACE_PERIOD_MS);
4130
+ }, timeoutSec * 1e3);
4131
+ if (signal) signal.addEventListener("abort", () => {
4132
+ try {
4133
+ if (childProcess.pid && os.platform() !== "win32") process.kill(-childProcess.pid, "SIGTERM");
4134
+ else childProcess.kill("SIGTERM");
4135
+ } catch {}
4136
+ }, { once: true });
4137
+ });
4138
+ }
4139
+ function getShell() {
4140
+ if (os.platform() === "win32") return process.env.COMSPEC || "cmd.exe";
4141
+ return process.env.SHELL || "/bin/bash";
4142
+ }
4143
+ function processOutput(stdout, stderr, exitCode, timedOut, killed) {
4144
+ let output = stdout;
4145
+ if (stderr) output += (output ? "\n" : "") + stderr;
4146
+ output = stripAnsi(output);
4147
+ output = redactSensitiveInfo(output);
4148
+ output = truncateOutput(output, MAX_OUTPUT_CHARS);
4149
+ if (timedOut) output += "\n\n[命令执行超时]";
4150
+ else if (killed) output += "\n\n[命令已被终止]";
4151
+ else if (exitCode !== null && exitCode !== 0) output += `\n\n(命令退出码: ${exitCode})`;
4152
+ return output || "(无输出)";
4153
+ }
4154
+ function formatBlockedOutput(command, reason) {
4155
+ return `[安全检查] 命令被阻断\n\n命令: ${command}\n原因: ${reason}\n\n此命令属于危险操作,已被自动拒绝执行。`;
4156
+ }
4157
+
4158
+ //#endregion
4159
+ //#region src/cli/repl/tools/shell-process.ts
4160
+ /**
4161
+ * process 工具实现 — 后台进程管理
4162
+ *
4163
+ * 管理 exec 工具启动的后台进程:
4164
+ * - list: 列出所有后台进程
4165
+ * - poll: 检查状态 + 获取新输出
4166
+ * - log: 获取完整或部分输出
4167
+ * - wait: 等待进程完成
4168
+ * - kill: 终止进程
4169
+ * - write: 向 stdin 写入数据
4170
+ * - submit: 向 stdin 写入数据 + 换行
4171
+ *
4172
+ * 参考 Hermes (process_registry.py) 和 OpenClaw (bash-tools.process.ts) 的设计。
4173
+ *
4174
+ * @module cli/repl/tools/shell-process
4175
+ */
4176
+ function createProcessTool() {
4177
+ return {
4178
+ id: "Process",
4179
+ label: "管理进程",
4180
+ description: "管理后台运行的进程。支持的操作:\n- list: 列出所有后台进程\n- poll: 检查进程状态并获取新输出\n- log: 获取进程完整或部分日志\n- wait: 等待进程完成\n- kill: 终止进程\n- write: 向进程 stdin 写入数据\n- submit: 向进程 stdin 写入数据并追加换行",
4181
+ parameters: {
4182
+ type: "object",
4183
+ properties: {
4184
+ action: {
4185
+ type: "string",
4186
+ description: "操作类型: list, poll, log, wait, kill, write, submit",
4187
+ enum: [
4188
+ "list",
4189
+ "poll",
4190
+ "log",
4191
+ "wait",
4192
+ "kill",
4193
+ "write",
4194
+ "submit"
4195
+ ]
4196
+ },
4197
+ sessionId: {
4198
+ type: "string",
4199
+ description: "进程 session ID(除 list 外必需)"
4200
+ },
4201
+ data: {
4202
+ type: "string",
4203
+ description: "write/submit 时写入的数据"
4204
+ },
4205
+ offset: {
4206
+ type: "number",
4207
+ description: "log 时的行偏移量"
4208
+ },
4209
+ limit: {
4210
+ type: "number",
4211
+ description: "log 时的最大行数(默认 40)"
4212
+ },
4213
+ waitTimeout: {
4214
+ type: "number",
4215
+ description: "wait 时的超时(毫秒),默认无超时"
4216
+ }
4217
+ },
4218
+ required: ["action"]
4219
+ },
4220
+ async execute(_toolCallId, params) {
4221
+ const registry = getProcessRegistry();
4222
+ switch (params.action) {
4223
+ case "list": return handleList(registry);
4224
+ case "poll": return handlePoll(registry, params);
4225
+ case "log": return handleLog(registry, params);
4226
+ case "wait": return await handleWait(registry, params);
4227
+ case "kill": return handleKill(registry, params);
4228
+ case "write": return handleWrite(registry, params, false);
4229
+ case "submit": return handleWrite(registry, params, true);
4230
+ default: return { content: [{
4231
+ type: "text",
4232
+ text: `未知操作: ${params.action}。支持: list, poll, log, wait, kill, write, submit`
4233
+ }] };
4234
+ }
4235
+ }
4236
+ };
4237
+ }
4238
+ function handleList(registry) {
4239
+ const sessions = registry.list();
4240
+ if (sessions.length === 0) return {
4241
+ content: [{
4242
+ type: "text",
4243
+ text: "(没有活动的后台进程)"
4244
+ }],
4245
+ details: {
4246
+ action: "list",
4247
+ sessions: [],
4248
+ processCount: 0
4249
+ }
4250
+ };
4251
+ const lines = [`共 ${sessions.length} 个后台进程:`, ""];
4252
+ for (const s of sessions) {
4253
+ const statusIcon = statusIconMap[s.status];
4254
+ const duration = formatDuration(Date.now() - s.startTime);
4255
+ lines.push(`${statusIcon} [${s.sessionId}] ${s.status}`);
4256
+ lines.push(` PID: ${s.pid} | 运行: ${duration} | 命令: ${truncateCommand(s.command)}`);
4257
+ lines.push("");
4258
+ }
4259
+ return {
4260
+ content: [{
4261
+ type: "text",
4262
+ text: lines.join("\n")
4263
+ }],
4264
+ details: {
4265
+ action: "list",
4266
+ sessions,
4267
+ processCount: sessions.length
4268
+ }
4269
+ };
4270
+ }
4271
+ function handlePoll(registry, params) {
4272
+ if (!params.sessionId) return { content: [{
4273
+ type: "text",
4274
+ text: "poll 操作需要 sessionId 参数"
4275
+ }] };
4276
+ const result = registry.poll(params.sessionId);
4277
+ if (!result) return { content: [{
4278
+ type: "text",
4279
+ text: `进程 ${params.sessionId} 未找到`
4280
+ }] };
4281
+ const { session, newOutput } = result;
4282
+ const statusIcon = statusIconMap[session.status];
4283
+ const duration = formatDuration(Date.now() - session.startTime);
4284
+ let text = `${statusIcon} [${session.sessionId}] ${session.status}\n`;
4285
+ text += `PID: ${session.pid} | 运行: ${duration}\n`;
4286
+ if (session.exitCode != null) text += `退出码: ${session.exitCode}\n`;
4287
+ if (newOutput) text += `\n--- 新输出 ---\n${newOutput}`;
4288
+ else if (session.status === "running") text += "\n(尚无新输出)";
4289
+ return {
4290
+ content: [{
4291
+ type: "text",
4292
+ text
4293
+ }],
4294
+ details: {
4295
+ action: "poll",
4296
+ sessionId: session.sessionId
4297
+ }
4298
+ };
4299
+ }
4300
+ function handleLog(registry, params) {
4301
+ if (!params.sessionId) return { content: [{
4302
+ type: "text",
4303
+ text: "log 操作需要 sessionId 参数"
4304
+ }] };
4305
+ const logOptions = {};
4306
+ if (params.offset !== void 0) logOptions.offset = params.offset;
4307
+ if (params.limit !== void 0) logOptions.limit = params.limit;
4308
+ const result = registry.getLog(params.sessionId, logOptions);
4309
+ if (!result) return { content: [{
4310
+ type: "text",
4311
+ text: `进程 ${params.sessionId} 未找到`
4312
+ }] };
4313
+ const { session, output } = result;
4314
+ let text = `[${session.sessionId}] ${session.status}\n`;
4315
+ if (session.exitCode != null) text += `退出码: ${session.exitCode}\n`;
4316
+ text += `\n${output || "(无输出)"}`;
4317
+ return {
4318
+ content: [{
4319
+ type: "text",
4320
+ text
4321
+ }],
4322
+ details: {
4323
+ action: "log",
4324
+ sessionId: session.sessionId
4325
+ }
4326
+ };
4327
+ }
4328
+ async function handleWait(registry, params) {
4329
+ if (!params.sessionId) return { content: [{
4330
+ type: "text",
4331
+ text: "wait 操作需要 sessionId 参数"
4332
+ }] };
4333
+ const session = await registry.wait(params.sessionId, params.waitTimeout);
4334
+ if (!session) return { content: [{
4335
+ type: "text",
4336
+ text: `进程 ${params.sessionId} 未找到`
4337
+ }] };
4338
+ const duration = formatDuration(Date.now() - session.startTime);
4339
+ let text = `[${session.sessionId}] ${session.status}\n`;
4340
+ text += `总运行时间: ${duration}\n`;
4341
+ if (session.exitCode != null) text += `退出码: ${session.exitCode}\n`;
4342
+ return {
4343
+ content: [{
4344
+ type: "text",
4345
+ text
4346
+ }],
4347
+ details: {
4348
+ action: "wait",
4349
+ sessionId: session.sessionId
4350
+ }
4351
+ };
4352
+ }
4353
+ function handleKill(registry, params) {
4354
+ if (!params.sessionId) return { content: [{
4355
+ type: "text",
4356
+ text: "kill 操作需要 sessionId 参数"
4357
+ }] };
4358
+ const session = registry.kill(params.sessionId);
4359
+ if (!session) return { content: [{
4360
+ type: "text",
4361
+ text: `进程 ${params.sessionId} 未找到`
4362
+ }] };
4363
+ return {
4364
+ content: [{
4365
+ type: "text",
4366
+ text: `[${session.sessionId}] 已发送终止信号 (SIGTERM)\nPID: ${session.pid}\n命令: ${truncateCommand(session.command)}`
4367
+ }],
4368
+ details: {
4369
+ action: "kill",
4370
+ sessionId: session.sessionId
4371
+ }
4372
+ };
4373
+ }
4374
+ function handleWrite(registry, params, newline) {
4375
+ if (!params.sessionId) return { content: [{
4376
+ type: "text",
4377
+ text: "write/submit 操作需要 sessionId 参数"
4378
+ }] };
4379
+ if (!params.data) return { content: [{
4380
+ type: "text",
4381
+ text: "write/submit 操作需要 data 参数"
4382
+ }] };
4383
+ const session = registry.write(params.sessionId, params.data, newline);
4384
+ if (!session) return { content: [{
4385
+ type: "text",
4386
+ text: `进程 ${params.sessionId} 未找到`
4387
+ }] };
4388
+ if (session.status !== "running") return { content: [{
4389
+ type: "text",
4390
+ text: `Cannot write to process ${params.sessionId}: status is ${session.status}`
4391
+ }] };
4392
+ const action = newline ? "submit" : "write";
4393
+ return {
4394
+ content: [{
4395
+ type: "text",
4396
+ text: `已向 [${session.sessionId}] ${action === "submit" ? "发送并提交" : "写入"}数据。`
4397
+ }],
4398
+ details: {
4399
+ action,
4400
+ sessionId: session.sessionId
4401
+ }
4402
+ };
4403
+ }
4404
+ const statusIconMap = {
4405
+ running: "🔄",
4406
+ exited: "✅",
4407
+ killed: "🛑",
4408
+ timeout: "⏰",
4409
+ errored: "❌"
4410
+ };
4411
+ function formatDuration(ms) {
4412
+ const seconds = Math.floor(ms / 1e3);
4413
+ if (seconds < 60) return `${seconds}s`;
4414
+ const minutes = Math.floor(seconds / 60);
4415
+ const remainingSec = seconds % 60;
4416
+ if (minutes < 60) return `${minutes}m ${remainingSec}s`;
4417
+ return `${Math.floor(minutes / 60)}h ${minutes % 60}m`;
4418
+ }
4419
+ function truncateCommand(command, maxLen = 80) {
4420
+ if (command.length <= maxLen) return command;
4421
+ return command.slice(0, maxLen - 3) + "...";
4422
+ }
4423
+
4424
+ //#endregion
4425
+ //#region src/cli/repl/tools/skill-tool.ts
4426
+ /**
4427
+ * Skill 工具实现 — 技能调用
4428
+ *
4429
+ * 将 Skill 作为 ToolRegistration 注册到 Agent,
4430
+ * 模型可主动调用此工具来执行预定义的技能工作流。
4431
+ *
4432
+ * 参考 Claude Code 的 SkillTool(inline 模式)和 Hermes-Agent 的 skill_view。
4433
+ *
4434
+ * @module cli/repl/tools/skill-tool
4435
+ */
4436
+ const SKILL_DESCRIPTION = `调用预定义的技能(Skill)来执行特定工作流。
4437
+
4438
+ 技能是预定义的工作流模板,封装了完成特定任务所需的指令、工具调用和最佳实践。
4439
+
4440
+ ## 如何使用
4441
+ - 使用 skill 参数指定技能名称
4442
+ - 使用可选的 args 参数传递参数(如文件名、选项等)
4443
+ - 调用后技能内容会展开为当前对话的上下文,模型据此执行
4444
+
4445
+ ## 示例
4446
+ - skill: "commit" — 调用 commit 技能生成规范的 git commit
4447
+ - skill: "review-pr", args: "123" — 调用 review-pr 技能审查指定的 PR`;
4448
+ /** 已加载的 Skill 条目(由外部注入) */
4449
+ let skillEntries = [];
4450
+ /**
4451
+ * 更新已加载的 Skill 列表
4452
+ */
4453
+ function setSkillEntries(entries) {
4454
+ skillEntries = entries;
4455
+ }
4456
+ /**
4457
+ * 替换 Skill 内容中的模板变量
4458
+ *
4459
+ * 支持的变量:
4460
+ * - $ARGUMENTS / $ARGUMENTS[0] / $ARGUMENTS[1] — 按索引的参数
4461
+ * - $0 / $1 / $2 — 按索引的参数简写
4462
+ * - ${ZAPMYCO_SKILL_DIR} — Skill 目录绝对路径
4463
+ */
4464
+ function substituteVariables(content, args, skillDir, skillName) {
4465
+ let result = content;
4466
+ result = result.replace(/\$\{ZAPMYCO_SKILL_DIR\}/g, skillDir);
4467
+ result = result.replace(/\$\{SKILL_DIR\}/g, skillDir);
4468
+ const parsedArgs = parseArgs(args);
4469
+ result = result.replace(/\$ARGUMENTS\b/g, args ?? "");
4470
+ result = result.replace(/\$ARGUMENTS\[(\d+)\]/g, (_match, idx) => {
4471
+ return parsedArgs[parseInt(idx, 10)] ?? "";
4472
+ });
4473
+ result = result.replace(/\$(\d+)\b/g, (_match, idx) => {
4474
+ return parsedArgs[parseInt(idx, 10)] ?? "";
4475
+ });
4476
+ result = result.replace(/\$name\b/g, skillName);
4477
+ return result;
4478
+ }
4479
+ /**
4480
+ * 简易参数解析(支持引号和转义)
4481
+ */
4482
+ function parseArgs(args) {
4483
+ if (!args) return [];
4484
+ const result = [];
4485
+ let current = "";
4486
+ let inSingleQuote = false;
4487
+ let inDoubleQuote = false;
4488
+ for (let i = 0; i < args.length; i++) {
4489
+ const ch = args[i] ?? "";
4490
+ if (inSingleQuote) if (ch === "'") inSingleQuote = false;
4491
+ else current += ch;
4492
+ else if (inDoubleQuote) if (ch === "\"") inDoubleQuote = false;
4493
+ else if (ch === "\\" && i + 1 < args.length) {
4494
+ i++;
4495
+ current += args[i] ?? "";
4496
+ } else current += ch;
4497
+ else if (ch === "'") inSingleQuote = true;
4498
+ else if (ch === "\"") inDoubleQuote = true;
4499
+ else if (ch === " " || ch === " ") {
4500
+ if (current.length > 0) {
4501
+ result.push(current);
4502
+ current = "";
4503
+ }
4504
+ } else current += ch;
4505
+ }
4506
+ if (current.length > 0) result.push(current);
4507
+ return result;
4508
+ }
4509
+ /**
4510
+ * 创建 Skill 工具
4511
+ *
4512
+ * @param _config - Skill 加载配置(保留用于未来扩展)
4513
+ */
4514
+ function createSkillTool(_config) {
4515
+ return {
4516
+ id: "Skill",
4517
+ label: "技能调用",
4518
+ description: SKILL_DESCRIPTION,
4519
+ parameters: {
4520
+ type: "object",
4521
+ properties: {
4522
+ skill: {
4523
+ type: "string",
4524
+ description: "要调用的技能名称(如 \"commit\"、\"review-pr\")"
4525
+ },
4526
+ args: {
4527
+ type: "string",
4528
+ description: "传递给技能的可选参数(如文件名、选项等)"
4529
+ }
4530
+ },
4531
+ required: ["skill"]
4532
+ },
4533
+ async execute(_toolCallId, params) {
4534
+ const skillName = params.skill?.trim();
4535
+ if (!skillName) return {
4536
+ content: [{
4537
+ type: "text",
4538
+ text: "请提供要调用的技能名称。"
4539
+ }],
4540
+ details: { error: "skill 参数为空" }
4541
+ };
4542
+ const entry = skillEntries.find((e) => e.skill.name.toLowerCase() === skillName.toLowerCase());
4543
+ if (!entry) {
4544
+ const available = skillEntries.map((e) => e.skill.name).join(", ");
4545
+ return {
4546
+ content: [{
4547
+ type: "text",
4548
+ text: `未找到技能 "${skillName}"。\n\n可用技能: ${available || "(无)"}`
4549
+ }],
4550
+ details: {
4551
+ error: `技能 "${skillName}" 不存在`,
4552
+ available
4553
+ }
4554
+ };
4555
+ }
4556
+ const { skill } = entry;
4557
+ let content;
4558
+ try {
4559
+ content = await readFile(skill.filePath, "utf-8");
4560
+ } catch (err) {
4561
+ return {
4562
+ content: [{
4563
+ type: "text",
4564
+ text: `读取技能文件失败: ${err instanceof Error ? err.message : String(err)}`
4565
+ }],
4566
+ details: {
4567
+ error: "读取文件失败",
4568
+ path: skill.filePath
4569
+ }
4570
+ };
4571
+ }
4572
+ let bodyContent = content;
4573
+ if (content.trimStart().startsWith("---")) {
4574
+ const endIdx = content.indexOf("---", 3);
4575
+ if (endIdx !== -1) bodyContent = content.slice(endIdx + 3).trim();
4576
+ }
4577
+ const substituted = substituteVariables(bodyContent, params.args, skill.baseDir, skill.name);
4578
+ const hint = skill.frontmatter["argument-hint"] ? ` [${skill.frontmatter["argument-hint"]}]` : "";
4579
+ const instructionParts = [
4580
+ `# Skill: ${skill.name}${hint}`,
4581
+ "",
4582
+ skill.description ? `> ${skill.description}` : "",
4583
+ "",
4584
+ "---",
4585
+ "",
4586
+ substituted,
4587
+ "",
4588
+ "---",
4589
+ `Base directory: ${skill.baseDir}`
4590
+ ];
4591
+ if (!substituted.includes(params.args ?? "") && params.args) instructionParts.push("", `ARGUMENTS: ${params.args}`);
4592
+ return {
4593
+ content: [{
4594
+ type: "text",
4595
+ text: instructionParts.filter(Boolean).join("\n")
4596
+ }],
4597
+ details: {
4598
+ skill: skill.name,
4599
+ description: skill.description,
4600
+ source: skill.source,
4601
+ baseDir: skill.baseDir,
4602
+ context: skill.frontmatter.context ?? "inline",
4603
+ allowedTools: skill.frontmatter["allowed-tools"] ?? [],
4604
+ args: params.args
4605
+ }
4606
+ };
4607
+ }
4608
+ };
4609
+ }
4610
+ /**
4611
+ * 从 Skill 条目列表获取命令规格(用于自动注册斜杠命令)
4612
+ */
4613
+ function getSkillCommandSpecs(entries) {
4614
+ return entries.filter((e) => e.skill.userInvocable).map((e) => ({
4615
+ name: sanitizeSkillCommandName(e.skill.name),
4616
+ description: e.skill.description || `调用 ${e.skill.name} 技能`,
4617
+ isSkill: true
4618
+ }));
4619
+ }
4620
+ /**
4621
+ * 规范化技能命令名称
4622
+ *
4623
+ * 转换为小写,非字母数字字符替换为连字符。
4624
+ */
4625
+ function sanitizeSkillCommandName(name) {
4626
+ return name.toLowerCase().replace(/[^a-z0-9_-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "").slice(0, 32);
4627
+ }
4628
+
4629
+ //#endregion
4630
+ //#region src/cli/repl/tools/subagent-spawn.ts
4631
+ /**
4632
+ * 创建 spawn_subagents 工具注册
4633
+ *
4634
+ * @param manager - SubAgentManager 实例
4635
+ * @param config - Sub-Agent 系统配置
4636
+ * @returns ToolRegistration
4637
+ */
4638
+ function createSpawnSubAgentsTool(manager, _config) {
4639
+ return {
4640
+ id: "SpawnSubAgents",
4641
+ label: "派生子 Agent",
4642
+ description: [
4643
+ "并行启动多个子 Agent 执行独立任务,等待全部完成后汇总返回结果。",
4644
+ "",
4645
+ "### 何时使用此工具",
4646
+ "1. 用户请求包含 2 个以上互不依赖的独立步骤",
4647
+ "2. 每个步骤需要独立的搜索、分析或研究",
4648
+ "3. 步骤之间没有顺序依赖关系,可以同时进行",
4649
+ "",
4650
+ "### 何时不使用此工具",
4651
+ "- 只有 1 个任务时(直接执行即可)",
4652
+ "- 任务之间有严格的顺序依赖(必须串行执行)",
4653
+ "- 任务非常简单(如读取单个文件)",
4654
+ "",
4655
+ "### 使用流程",
4656
+ "1. 先用 TaskManage write 规划所有子任务",
4657
+ "2. 识别其中可并行的独立子任务",
4658
+ "3. 调用本工具一次性派发所有并行子任务",
4659
+ "4. 根据返回结果更新 TaskManage 状态",
4660
+ "5. 继续处理依赖这些结果的后续任务",
4661
+ "",
4662
+ "### 参数说明",
4663
+ "- agents: 子任务列表,每个包含 id(唯一标识)、description(详细指令)、allowedTools(可选工具白名单)",
4664
+ "- context: 可选背景摘要,会注入给每个子 Agent 帮助它们理解任务背景",
4665
+ "",
4666
+ "### 子 Agent 的能力",
4667
+ "默认情况下子 Agent 拥有安全的只读工具集:ReadFile, Glob, Grep, WebFetch, WebSearch。",
4668
+ "如需子 Agent 写文件或执行命令,请在 allowedTools 中显式指定。"
4669
+ ].join("\n"),
4670
+ parameters: {
4671
+ type: "object",
4672
+ properties: {
4673
+ agents: {
4674
+ type: "array",
4675
+ items: {
4676
+ type: "object",
4677
+ properties: {
4678
+ id: {
4679
+ type: "string",
4680
+ description: "子任务唯一标识"
4681
+ },
4682
+ description: {
4683
+ type: "string",
4684
+ description: "发给子 Agent 的详细任务指令"
4685
+ },
4686
+ allowedTools: {
4687
+ type: "array",
4688
+ items: { type: "string" },
4689
+ description: "可选工具白名单,默认使用安全工具集"
4690
+ }
4691
+ },
4692
+ required: ["id", "description"]
4693
+ },
4694
+ description: "要并行创建的子 Agent 列表"
4695
+ },
4696
+ context: {
4697
+ type: "string",
4698
+ description: "可选背景摘要,注入给每个子 Agent"
4699
+ }
4700
+ },
4701
+ required: ["agents"]
4702
+ },
4703
+ execute: async (_toolCallId, params) => {
4704
+ const { agents, context } = params;
4705
+ const results = await manager.spawnAndWait(agents, context);
4706
+ return {
4707
+ content: [{
4708
+ type: "text",
4709
+ text: results.summary
4710
+ }],
4711
+ details: results
4712
+ };
4713
+ }
4714
+ };
4715
+ }
4716
+
4717
+ //#endregion
4718
+ //#region src/cli/repl/tools/task-manage.ts
4719
+ const TASK_MANAGE_DESCRIPTION = `任务管理工具 — 对于多步骤任务,**这必须是你调用的第一个工具**。
4720
+
4721
+ ## 何时必须使用
4722
+ 收到任何需要 2 个以上独立步骤的任务时,**在调用任何其他工具之前**,必须先用 action="write" 将任务分解为子任务。
4723
+ **你的第一个 tool call 必须是 TaskManage!** 不得先搜索、读取、执行任何操作!
4724
+
4725
+ ## 操作类型 (action)
4726
+ - "read": 读取当前任务列表和进度摘要
4727
+ - "write": 设置任务列表。传入 tasks 数组。用于初次规划或重新规划。merge=true 可追加/更新任务而不删除已有任务
4728
+ - "update": 更新单个任务状态。每完成一个子任务**立即**调用 update 标记状态,**不要等到所有任务做完才批量更新**
4729
+
4730
+ ## 必须遵守的规则
4731
+ 1. 任何 2 个以上步骤的任务:第一个 tool call = TaskManage write,先规划再执行
4732
+ 2. 每次只能有 1 个任务处于 "in_progress" 状态
4733
+ 3. 完成一个子任务后**立刻** update 为 "completed",然后再开始下一个
4734
+ 4. 任务状态: "pending"(未开始), "in_progress"(进行中), "completed"(已完成), "cancelled"(已取消)
4735
+ 5. 不要在一条消息中批量标记多个任务完成 — 每个任务完成时单独 update
4736
+
4737
+ > 提示:如果项目启用了 SpawnSubAgents 工具,规划完成后识别其中互不依赖的子任务,使用 SpawnSubAgents 并行执行它们,然后根据结果更新任务状态。`;
783
4738
  /**
784
- * 渲染器实现
4739
+ * 创建 task_manage 工具
785
4740
  *
786
- * 协调 OutputFormatter OutputArea 之间的内容输出。
4741
+ * @param store - TaskStore 实例(由 ReplSession 注入,保证会话级生命周期)
787
4742
  */
788
- var Renderer = class {
789
- formatter;
790
- constructor(opts) {
791
- this.formatter = new OutputFormatter(opts.color);
792
- }
793
- /** 获取底层格式化器(供 OutputArea 直接使用) */
794
- getFormatter() {
795
- return this.formatter;
796
- }
797
- /** 渲染欢迎信息 → 返回格式化行 */
798
- renderWelcome(version) {
799
- return this.formatter.formatWelcome(version);
800
- }
801
- /** 渲染错误信息 → 返回格式化行 */
802
- renderError(error) {
803
- return this.formatter.formatError(error);
804
- }
805
- /** 渲染最终执行结果 → 返回格式化行 */
806
- renderResult(result) {
807
- return this.formatter.formatResult(result);
808
- }
809
- /** 渲染任务拆分概览 → 返回格式化行 */
810
- renderTaskGraph(graph) {
811
- return this.formatter.formatTaskGraph(graph);
812
- }
813
- /** 渲染 Agent 列表 → 返回格式化行 */
814
- renderAgents(agents) {
815
- return this.formatter.formatAgents(agents);
816
- }
817
- /** 渲染配置信息 → 返回格式化行 */
818
- renderConfig(config) {
819
- return this.formatter.formatConfig(config);
4743
+ function createTaskManageTool(store) {
4744
+ return {
4745
+ id: "TaskManage",
4746
+ label: "任务管理",
4747
+ description: TASK_MANAGE_DESCRIPTION,
4748
+ parameters: {
4749
+ type: "object",
4750
+ properties: {
4751
+ action: {
4752
+ type: "string",
4753
+ description: "操作类型: \"read\"(读取), \"write\"(全量设置), \"update\"(更新单个任务)。默认 \"read\"。",
4754
+ enum: [
4755
+ "read",
4756
+ "write",
4757
+ "update"
4758
+ ]
4759
+ },
4760
+ tasks: {
4761
+ type: "array",
4762
+ description: "任务项数组。write 模式传入完整任务列表,update 模式只传入需要更新的任务。每项包含: id(标识), subject(标题), description(描述, 可选), status(状态)",
4763
+ items: {
4764
+ type: "object",
4765
+ properties: {
4766
+ id: {
4767
+ type: "string",
4768
+ description: "任务唯一标识,如 \"1\", \"2\", \"search-files\""
4769
+ },
4770
+ subject: {
4771
+ type: "string",
4772
+ description: "简短标题(祈使句),如 \"搜索相关文件\""
4773
+ },
4774
+ description: {
4775
+ type: "string",
4776
+ description: "详细描述(可选)"
4777
+ },
4778
+ status: {
4779
+ type: "string",
4780
+ description: "任务状态",
4781
+ enum: [
4782
+ "pending",
4783
+ "in_progress",
4784
+ "completed",
4785
+ "cancelled"
4786
+ ]
4787
+ }
4788
+ },
4789
+ required: [
4790
+ "id",
4791
+ "subject",
4792
+ "status"
4793
+ ]
4794
+ }
4795
+ },
4796
+ merge: {
4797
+ type: "boolean",
4798
+ description: "write 模式下是否按 ID 合并(默认 false 全量替换)。设为 true 时按 ID 新增/更新任务,不删除已有任务。"
4799
+ }
4800
+ }
4801
+ },
4802
+ async execute(_toolCallId, params) {
4803
+ const action = params.action ?? "read";
4804
+ switch (action) {
4805
+ case "read": return buildReadResult(store);
4806
+ case "write": return buildWriteResult(store, params.tasks, params.merge);
4807
+ case "update": return buildUpdateResult(store, params.tasks);
4808
+ default: return {
4809
+ content: [{
4810
+ type: "text",
4811
+ text: `不支持的操作: ${action}`
4812
+ }],
4813
+ details: {
4814
+ action,
4815
+ tasks: [],
4816
+ summary: {
4817
+ total: 0,
4818
+ pending: 0,
4819
+ in_progress: 0,
4820
+ completed: 0,
4821
+ cancelled: 0
4822
+ },
4823
+ error: `不支持的操作: ${action}`
4824
+ }
4825
+ };
4826
+ }
4827
+ }
4828
+ };
4829
+ }
4830
+ function buildReadResult(store) {
4831
+ const tasks = store.read();
4832
+ const summary = store.summary();
4833
+ const details = {
4834
+ action: "read",
4835
+ tasks,
4836
+ summary
4837
+ };
4838
+ if (tasks.length === 0) return {
4839
+ content: [{
4840
+ type: "text",
4841
+ text: "当前没有任务。使用 action=\"write\" 创建任务列表来分解复杂工作。"
4842
+ }],
4843
+ details
4844
+ };
4845
+ const lines = [`共 ${summary.total} 个任务:`, ""];
4846
+ for (const task of tasks) {
4847
+ const marker = statusMarker(task.status);
4848
+ const desc = task.description ? ` — ${task.description}` : "";
4849
+ lines.push(` ${marker} [${task.id}] ${task.subject}${desc}`);
820
4850
  }
821
- /** 渲染历史记录 → 返回格式化行 */
822
- renderHistory(entries) {
823
- return this.formatter.formatHistory(entries);
4851
+ lines.push("");
4852
+ lines.push(`进度: ${summary.pending} 待处理 | ${summary.in_progress} 进行中 | ${summary.completed} 已完成 | ${summary.cancelled} 已取消`);
4853
+ return {
4854
+ content: [{
4855
+ type: "text",
4856
+ text: lines.join("\n")
4857
+ }],
4858
+ details
4859
+ };
4860
+ }
4861
+ function buildWriteResult(store, tasks, merge) {
4862
+ if (!tasks || tasks.length === 0) return {
4863
+ content: [{
4864
+ type: "text",
4865
+ text: "请提供 tasks 参数(任务列表)。使用空数组可清空所有任务。"
4866
+ }],
4867
+ details: {
4868
+ action: "write",
4869
+ tasks: [],
4870
+ summary: store.summary(),
4871
+ error: "tasks 参数为空"
4872
+ }
4873
+ };
4874
+ const error = store.write(tasks, merge ?? false);
4875
+ if (error) return {
4876
+ content: [{
4877
+ type: "text",
4878
+ text: `[任务更新失败] ${error}`
4879
+ }],
4880
+ details: {
4881
+ action: "write",
4882
+ tasks: store.read(),
4883
+ summary: store.summary(),
4884
+ error
4885
+ }
4886
+ };
4887
+ const summary = store.summary();
4888
+ return {
4889
+ content: [{
4890
+ type: "text",
4891
+ text: [`任务列表已更新(${merge ? "合并模式" : "替换模式"})`, `共 ${summary.total} 个任务: ${summary.pending} 待处理 | ${summary.in_progress} 进行中 | ${summary.completed} 已完成 | ${summary.cancelled} 已取消`].join("\n")
4892
+ }],
4893
+ details: {
4894
+ action: "write",
4895
+ tasks: store.read(),
4896
+ summary
4897
+ }
4898
+ };
4899
+ }
4900
+ function buildUpdateResult(store, tasks) {
4901
+ if (!tasks || tasks.length === 0) return {
4902
+ content: [{
4903
+ type: "text",
4904
+ text: "请提供 tasks 参数,包含要更新的任务(带 id 和新的 status)。"
4905
+ }],
4906
+ details: {
4907
+ action: "update",
4908
+ tasks: store.read(),
4909
+ summary: store.summary(),
4910
+ error: "tasks 参数为空"
4911
+ }
4912
+ };
4913
+ const results = [];
4914
+ let hasError = false;
4915
+ for (const item of tasks) {
4916
+ const updates = {
4917
+ subject: item.subject,
4918
+ status: item.status
4919
+ };
4920
+ if (item.description !== void 0) updates.description = item.description;
4921
+ const error = store.update(item.id, updates);
4922
+ if (error) {
4923
+ results.push(` ❌ [${item.id}]: ${error}`);
4924
+ hasError = true;
4925
+ } else {
4926
+ const task = store.read().find((t) => t.id === item.id);
4927
+ const marker = task ? statusMarker(task.status) : "";
4928
+ results.push(` ✅ [${item.id}] → ${marker} ${item.status}`);
4929
+ }
824
4930
  }
825
- /** 渲染会话状态 返回格式化行 */
826
- renderStatus(stats) {
827
- return this.formatter.formatStatus(stats);
4931
+ const summary = store.summary();
4932
+ return {
4933
+ content: [{
4934
+ type: "text",
4935
+ text: [hasError ? "部分任务更新失败:" : "任务已更新:", ...results].join("\n")
4936
+ }],
4937
+ details: {
4938
+ action: "update",
4939
+ tasks: store.read(),
4940
+ summary,
4941
+ error: hasError ? "部分更新失败,详见结果" : void 0
4942
+ }
4943
+ };
4944
+ }
4945
+ function statusMarker(status) {
4946
+ switch (status) {
4947
+ case "pending": return "○";
4948
+ case "in_progress": return "▶";
4949
+ case "completed": return "✅";
4950
+ case "cancelled": return "❌";
4951
+ default: return "?";
828
4952
  }
829
- };
4953
+ }
830
4954
 
831
4955
  //#endregion
832
4956
  //#region src/cli/repl/tools/cache.ts
@@ -1375,7 +5499,7 @@ function createWebFetchTool(webConfig) {
1375
5499
  const ssrfOptions = webConfig?.ssrf ?? {};
1376
5500
  const cacheTtlMinutes = fetchOptions.cacheTtlMinutes ?? 15;
1377
5501
  return {
1378
- id: "web_fetch",
5502
+ id: "WebFetch",
1379
5503
  label: "网页抓取",
1380
5504
  description: "抓取指定 URL 的网页内容并转换为 Markdown 格式。支持 HTML 正文提取、JSON 美化、内容截断。当用户需要访问网页获取信息时调用此工具。",
1381
5505
  parameters: {
@@ -1745,7 +5869,7 @@ function createWebSearchTool(webConfig) {
1745
5869
  const searchOptions = webConfig?.search ?? {};
1746
5870
  const cacheTtlMinutes = searchOptions.cacheTtlMinutes ?? 15;
1747
5871
  return {
1748
- id: "web_search",
5872
+ id: "WebSearch",
1749
5873
  label: "网页搜索",
1750
5874
  description: "在互联网上搜索信息。支持多种搜索引擎后端。当用户需要查找最新信息、技术文档、新闻等时调用此工具。",
1751
5875
  parameters: {
@@ -1850,37 +5974,35 @@ function createWebSearchTool(webConfig) {
1850
5974
  //#endregion
1851
5975
  //#region src/cli/repl/repl-agent-tools.ts
1852
5976
  /**
1853
- * REPL Agent 工具注册表
1854
- *
1855
- * 定义 REPL 场景下 Agent 可用的工具集合。
1856
- * 工具按能力域分组,支持按需加载。
1857
- *
1858
- * @module cli/repl/repl-agent-tools
1859
- */
1860
- /**
1861
5977
  * 创建 REPL 基础工具集
1862
5978
  *
1863
- * 第一阶段工具:验证 Agent 工具调用链路的端到端连通性。
1864
- * 后续阶段在此基础扩展:文件读写、Shell 执行、Git 操作等。
1865
- *
1866
- * @param webConfig - Web 工具配置(可选),传入时启用 web_fetch 和 web_search
5979
+ * @param webConfig - Web 工具配置(可选),传入时启用 WebFetch 和 WebSearch
5980
+ * @param taskStore - TaskStore 实例(可选),传入时启用 TaskManage 工具
1867
5981
  */
1868
- function createReplBuiltinTools(webConfig) {
5982
+ function createReplBuiltinTools(webConfig, taskStore, skillConfig, parentAgent, subAgentConfig, cronScheduler) {
1869
5983
  const tools = [
1870
5984
  {
1871
- id: "get_current_time",
5985
+ id: "GetCurrentTime",
1872
5986
  label: "获取当前时间",
1873
- description: "获取当前日期和时间。当用户询问时间、需要时间戳、或需要时间相关上下文时调用此工具。",
1874
- execute: async () => ({
1875
- content: [{
1876
- type: "text",
1877
- text: (/* @__PURE__ */ new Date()).toISOString()
1878
- }],
1879
- details: { timestamp: Date.now() }
1880
- })
5987
+ description: "获取当前日期和时间(含本地时间和 UTC 时间)。当用户询问时间、需要时间戳、或需要时间相关上下文时调用此工具。",
5988
+ execute: async () => {
5989
+ const now = /* @__PURE__ */ new Date();
5990
+ const offset = -now.getTimezoneOffset();
5991
+ const tz = `UTC${offset >= 0 ? "+" : ""}${Math.floor(offset / 60)}:${String(offset % 60).padStart(2, "0")}`;
5992
+ return {
5993
+ content: [{
5994
+ type: "text",
5995
+ text: `本地时间: ${now.toString()}\nISO (UTC): ${now.toISOString()}\n时区: ${tz}`
5996
+ }],
5997
+ details: {
5998
+ timestamp: Date.now(),
5999
+ timezone: tz
6000
+ }
6001
+ };
6002
+ }
1881
6003
  },
1882
6004
  {
1883
- id: "get_workdir_info",
6005
+ id: "GetWorkdirInfo",
1884
6006
  label: "获取工作目录信息",
1885
6007
  description: "获取当前工作目录路径和系统平台信息。当需要了解当前项目位置或运行环境时调用。",
1886
6008
  execute: async () => ({
@@ -1900,29 +6022,51 @@ function createReplBuiltinTools(webConfig) {
1900
6022
  })
1901
6023
  },
1902
6024
  {
1903
- id: "read_file",
6025
+ id: "ReadFile",
1904
6026
  label: "读取文件",
1905
- description: "读取指定路径的文本文件内容。参数 path 为文件绝对或相对路径。",
6027
+ description: "读取指定路径的文本文件内容。参数 file_path 为文件绝对路径。支持 offset(起始行号)和 limit(最大行数)参数用于分页读取大文件。",
1906
6028
  parameters: {
1907
6029
  type: "object",
1908
- properties: { path: {
1909
- type: "string",
1910
- description: "文件路径"
1911
- } },
1912
- required: ["path"]
6030
+ properties: {
6031
+ file_path: {
6032
+ type: "string",
6033
+ description: "文件绝对路径"
6034
+ },
6035
+ offset: {
6036
+ type: "number",
6037
+ description: "起始行号(1-based,可选)"
6038
+ },
6039
+ limit: {
6040
+ type: "number",
6041
+ description: "最大读取行数(可选,默认 2000)"
6042
+ }
6043
+ },
6044
+ required: ["file_path"]
1913
6045
  },
1914
6046
  execute: async (_toolCallId, params) => {
1915
6047
  const fs = await import("node:fs/promises");
6048
+ const resolvedPath = (await import("node:path")).resolve(params.file_path);
1916
6049
  try {
1917
- const content = await fs.readFile(params.path, "utf-8");
6050
+ const lines = (await fs.readFile(resolvedPath, "utf-8")).split("\n");
6051
+ const offset = params.offset ? Math.max(1, params.offset) : 1;
6052
+ const limit = params.limit ?? 2e3;
6053
+ const startIdx = offset - 1;
6054
+ const endIdx = Math.min(startIdx + limit, lines.length);
6055
+ const pageContent = lines.slice(startIdx, endIdx).map((l, i) => `${startIdx + i + 1}\t${l}`).join("\n");
6056
+ const truncated = endIdx < lines.length;
6057
+ readStateTracker.recordRead(resolvedPath);
1918
6058
  return {
1919
6059
  content: [{
1920
6060
  type: "text",
1921
- text: content
6061
+ text: pageContent + (truncated ? `\n\n[文件共 ${lines.length} 行,已显示 ${offset}-${endIdx} 行]` : "")
1922
6062
  }],
1923
6063
  details: {
1924
- path: params.path,
1925
- size: content.length
6064
+ path: resolvedPath,
6065
+ totalLines: lines.length,
6066
+ displayedLines: endIdx - startIdx,
6067
+ offset,
6068
+ limit,
6069
+ truncated
1926
6070
  }
1927
6071
  };
1928
6072
  } catch (error) {
@@ -1932,7 +6076,7 @@ function createReplBuiltinTools(webConfig) {
1932
6076
  text: `读取失败: ${error instanceof Error ? error.message : String(error)}`
1933
6077
  }],
1934
6078
  details: {
1935
- path: params.path,
6079
+ path: resolvedPath,
1936
6080
  error: true
1937
6081
  }
1938
6082
  };
@@ -1940,10 +6084,24 @@ function createReplBuiltinTools(webConfig) {
1940
6084
  }
1941
6085
  }
1942
6086
  ];
6087
+ tools.push(createWriteFileTool());
6088
+ tools.push(createEditFileTool());
6089
+ tools.push(createGlobTool());
6090
+ tools.push(createGrepTool());
6091
+ tools.push(createExecTool());
6092
+ tools.push(createProcessTool());
1943
6093
  if (webConfig?.enabled !== false) {
1944
6094
  tools.push(createWebFetchTool(webConfig));
1945
6095
  tools.push(createWebSearchTool(webConfig));
1946
6096
  }
6097
+ if (taskStore) tools.push(createTaskManageTool(taskStore));
6098
+ tools.push(createMemoryTool());
6099
+ if (skillConfig?.enabled !== false) tools.push(createSkillTool(skillConfig));
6100
+ if (parentAgent && subAgentConfig?.enabled !== false && subAgentConfig) {
6101
+ const manager = new SubAgentManager(subAgentConfig, parentAgent, tools);
6102
+ tools.push(createSpawnSubAgentsTool(manager, subAgentConfig));
6103
+ }
6104
+ if (cronScheduler) tools.push(createCronTool(cronScheduler));
1947
6105
  return tools;
1948
6106
  }
1949
6107
 
@@ -1993,6 +6151,411 @@ function createTheme(colorEnabled) {
1993
6151
  };
1994
6152
  }
1995
6153
 
6154
+ //#endregion
6155
+ //#region src/config/types.ts
6156
+ /**
6157
+ * 将用户配置的 MCP 格式标准化为 McpServerConfig 数组
6158
+ *
6159
+ * 支持两种输入格式自动检测:
6160
+ * - `{ servers: [...] }` → 直接返回数组
6161
+ * - `{ "server-a": {...}, "server-b": {...} }` → 以 key 作为 name 转换为数组
6162
+ */
6163
+ function normalizeMcpConfig(raw) {
6164
+ if (raw.servers && Array.isArray(raw.servers) && raw.servers.length > 0) return raw.servers;
6165
+ const servers = [];
6166
+ for (const [key, value] of Object.entries(raw)) {
6167
+ if (key === "servers") continue;
6168
+ if (value === null || value === void 0 || typeof value !== "object") continue;
6169
+ if (Array.isArray(value)) continue;
6170
+ const config = value;
6171
+ if (typeof config.command !== "string") continue;
6172
+ const server = {
6173
+ name: key,
6174
+ transport: "stdio",
6175
+ command: config.command
6176
+ };
6177
+ if (Array.isArray(config.args)) server.args = config.args;
6178
+ if (config.env && typeof config.env === "object") server.env = config.env;
6179
+ if (typeof config.cwd === "string") server.cwd = config.cwd;
6180
+ if (typeof config.enabled === "boolean") server.enabled = config.enabled;
6181
+ if (typeof config.connectTimeoutMs === "number") server.connectTimeoutMs = config.connectTimeoutMs;
6182
+ servers.push(server);
6183
+ }
6184
+ return servers;
6185
+ }
6186
+
6187
+ //#endregion
6188
+ //#region src/core/mcp/mcp-client.ts
6189
+ /**
6190
+ * MCP Server 连接管理 (stdio transport)
6191
+ *
6192
+ * 封装 MCP SDK 的 Client + StdioClientTransport,
6193
+ * 提供带超时的连接和优雅关闭能力。
6194
+ *
6195
+ * @module core/mcp/client
6196
+ */
6197
+ const log$2 = logger.child("mcp:client");
6198
+ /**
6199
+ * 连接到单个 MCP Server 并发现其工具
6200
+ *
6201
+ * 任何失败都会返回 null(优雅降级),不阻塞其他 server。
6202
+ *
6203
+ * @param config - Server 配置
6204
+ * @param signal - 可选的 AbortSignal 用于取消连接
6205
+ * @returns 连接对象,失败时返回 null
6206
+ */
6207
+ async function connectMcpServer(config, signal) {
6208
+ const timeoutMs = config.connectTimeoutMs ?? 15e3;
6209
+ const serverName = config.name;
6210
+ try {
6211
+ const client = new Client({
6212
+ name: "zapmyco",
6213
+ version: "0.3.0"
6214
+ });
6215
+ const transportOptions = {
6216
+ command: config.command,
6217
+ args: config.args ?? [],
6218
+ stderr: "pipe"
6219
+ };
6220
+ if (config.env) transportOptions.env = config.env;
6221
+ if (config.cwd) transportOptions.cwd = config.cwd;
6222
+ const transport = new StdioClientTransport(transportOptions);
6223
+ if (signal) {
6224
+ if (signal.aborted) return null;
6225
+ signal.addEventListener("abort", () => {
6226
+ transport.close().catch(() => {});
6227
+ client.close().catch(() => {});
6228
+ }, { once: true });
6229
+ }
6230
+ await withTimeout(client.connect(transport), timeoutMs);
6231
+ const { tools: rawTools } = await withTimeout(client.listTools(), timeoutMs);
6232
+ const tools = rawTools ?? [];
6233
+ log$2.debug(`MCP server "${serverName}" 已连接,发现 ${tools.length} 个工具`);
6234
+ return {
6235
+ client,
6236
+ transport,
6237
+ tools,
6238
+ serverName
6239
+ };
6240
+ } catch (error) {
6241
+ log$2.warn(`MCP server "${serverName}" 连接失败: ${error instanceof Error ? error.message : String(error)}`);
6242
+ return null;
6243
+ }
6244
+ }
6245
+ /**
6246
+ * 安全关闭单个 MCP Server 连接
6247
+ */
6248
+ async function closeMcpServer(conn) {
6249
+ try {
6250
+ await conn.transport.close();
6251
+ } catch {}
6252
+ try {
6253
+ await conn.client.close();
6254
+ } catch {}
6255
+ log$2.debug(`MCP server "${conn.serverName}" 已断开`);
6256
+ }
6257
+ /**
6258
+ * 简单的超时工具:promise 在 ms 毫秒内未完成则 reject
6259
+ */
6260
+ function withTimeout(promise, ms) {
6261
+ let timer;
6262
+ const timeout = new Promise((_resolve, reject) => {
6263
+ timer = setTimeout(() => reject(/* @__PURE__ */ new Error(`操作超时 (${ms}ms)`)), ms);
6264
+ });
6265
+ return Promise.race([promise, timeout]).finally(() => {
6266
+ if (timer) clearTimeout(timer);
6267
+ });
6268
+ }
6269
+
6270
+ //#endregion
6271
+ //#region src/core/mcp/mcp-tool-adapter.ts
6272
+ /**
6273
+ * 将单个 MCP Tool 转换为 zapmyco ToolRegistration
6274
+ *
6275
+ * MCP SDK 的 TextContent / ImageContent 与 pi-ai 对应类型结构兼容
6276
+ * (都是 { type, text/data, mimeType }),因此直接透传 content。
6277
+ */
6278
+ function mcpToolToRegistration(mcpTool, serverName, client) {
6279
+ const parameters = {
6280
+ type: "object",
6281
+ properties: { ...mcpTool.inputSchema?.properties ?? {} },
6282
+ required: [...mcpTool.inputSchema?.required ?? []]
6283
+ };
6284
+ return {
6285
+ id: `mcp__${serverName}__${mcpTool.name}`,
6286
+ label: `${serverName}:${mcpTool.name}`,
6287
+ description: mcpTool.description ?? `MCP tool: ${mcpTool.name} (from ${serverName})`,
6288
+ parameters,
6289
+ execute: async (_toolCallId, params) => {
6290
+ const result = await client.callTool({
6291
+ name: mcpTool.name,
6292
+ arguments: params
6293
+ });
6294
+ return {
6295
+ content: result.content,
6296
+ details: {
6297
+ isError: result.isError ?? false,
6298
+ serverName,
6299
+ toolName: mcpTool.name
6300
+ }
6301
+ };
6302
+ }
6303
+ };
6304
+ }
6305
+
6306
+ //#endregion
6307
+ //#region src/core/mcp/index.ts
6308
+ const log$1 = logger.child("mcp");
6309
+ /**
6310
+ * MCP 连接生命周期管理器
6311
+ *
6312
+ * 持有所有活跃的 MCP Server 连接及其转换后的 ToolRegistration。
6313
+ * 实例化 → initialize() → 使用 tools → shutdown()
6314
+ */
6315
+ var McpManager = class {
6316
+ connections = [];
6317
+ toolRegistrations = [];
6318
+ /**
6319
+ * 并行连接所有启用的 MCP Server 并收集工具
6320
+ *
6321
+ * 单个 server 连接失败不影响其他 server(Promise.allSettled)。
6322
+ *
6323
+ * @param servers - MCP Server 配置列表
6324
+ * @returns 所有成功连接的 server 的工具注册列表
6325
+ */
6326
+ async initialize(servers) {
6327
+ const enabledServers = servers.filter((s) => s.enabled !== false);
6328
+ if (enabledServers.length === 0) return [];
6329
+ log$1.info(`正在连接 ${enabledServers.length} 个 MCP Server...`);
6330
+ const connectedCount = (await Promise.allSettled(enabledServers.map((config) => this.connectAndCollect(config)))).filter((r) => r.status === "fulfilled" && r.value !== null).length;
6331
+ log$1.info(`MCP: ${connectedCount}/${enabledServers.length} 个 Server 已连接,共 ${this.toolRegistrations.length} 个工具`);
6332
+ return this.toolRegistrations;
6333
+ }
6334
+ /**
6335
+ * 关闭所有 MCP 连接,释放资源
6336
+ */
6337
+ async shutdown() {
6338
+ if (this.connections.length === 0) return;
6339
+ log$1.info(`正在关闭 ${this.connections.length} 个 MCP 连接...`);
6340
+ await Promise.allSettled(this.connections.map((conn) => closeMcpServer(conn)));
6341
+ this.connections = [];
6342
+ this.toolRegistrations = [];
6343
+ }
6344
+ /** 获取当前已注册的 MCP 工具列表(只读) */
6345
+ getTools() {
6346
+ return this.toolRegistrations;
6347
+ }
6348
+ /** 连接单个 server 并将其工具转为 ToolRegistration */
6349
+ async connectAndCollect(config) {
6350
+ const conn = await connectMcpServer(config);
6351
+ if (!conn) return null;
6352
+ this.connections.push(conn);
6353
+ for (const tool of conn.tools) this.toolRegistrations.push(mcpToolToRegistration(tool, conn.serverName, conn.client));
6354
+ return conn;
6355
+ }
6356
+ };
6357
+ /**
6358
+ * 便捷工厂函数:连接所有 MCP Server 并注册工具到 Agent
6359
+ *
6360
+ * @param servers - MCP Server 配置列表
6361
+ * @param agent - 目标 LlmBasedAgent 实例
6362
+ * @returns McpManager 实例(调用方负责在退出时 shutdown)
6363
+ */
6364
+ async function initializeMcpTools(servers, agent) {
6365
+ const manager = new McpManager();
6366
+ const tools = await manager.initialize(servers);
6367
+ if (tools.length > 0) agent.registerTools(tools);
6368
+ return manager;
6369
+ }
6370
+
6371
+ //#endregion
6372
+ //#region src/core/task/task-store.ts
6373
+ /**
6374
+ * TaskStore — Agent 任务项持久化存储
6375
+ *
6376
+ * 内存 Map + JSON 文件双写,支持跨会话恢复。
6377
+ * 参考 Hermes-Agent 的单工具 TodoStore 设计和 Claude Code 的文件持久化方案。
6378
+ *
6379
+ * @module core/task/task-store
6380
+ */
6381
+ /** 获取当前工作目录的短哈希(用于区分不同项目的任务列表) */
6382
+ function getCwdHash() {
6383
+ return createHash("sha256").update(process.cwd()).digest("hex").slice(0, 12);
6384
+ }
6385
+ /** 获取任务文件存储路径 */
6386
+ function getTaskFilePath() {
6387
+ return join(join(homedir(), ".zapmyco", "tasks"), `${getCwdHash()}.json`);
6388
+ }
6389
+ /** 确保存储目录存在 */
6390
+ function ensureTaskDir() {
6391
+ mkdirSync(dirname(getTaskFilePath()), { recursive: true });
6392
+ }
6393
+ var TaskStore = class {
6394
+ tasks = /* @__PURE__ */ new Map();
6395
+ /** 读取完整任务列表 */
6396
+ read() {
6397
+ return Array.from(this.tasks.values());
6398
+ }
6399
+ /** 获取摘要统计 */
6400
+ summary() {
6401
+ let pending = 0;
6402
+ let inProgress = 0;
6403
+ let completed = 0;
6404
+ let cancelled = 0;
6405
+ for (const task of this.tasks.values()) switch (task.status) {
6406
+ case "pending":
6407
+ pending++;
6408
+ break;
6409
+ case "in_progress":
6410
+ inProgress++;
6411
+ break;
6412
+ case "completed":
6413
+ completed++;
6414
+ break;
6415
+ case "cancelled":
6416
+ cancelled++;
6417
+ break;
6418
+ }
6419
+ return {
6420
+ total: this.tasks.size,
6421
+ pending,
6422
+ in_progress: inProgress,
6423
+ completed,
6424
+ cancelled
6425
+ };
6426
+ }
6427
+ /** 获取活跃任务(pending + in_progress),用于上下文压缩后注入 */
6428
+ getActiveTasks() {
6429
+ const result = [];
6430
+ for (const task of this.tasks.values()) if (task.status === "pending" || task.status === "in_progress") result.push(task);
6431
+ return result;
6432
+ }
6433
+ /** 检查是否有任务 */
6434
+ hasItems() {
6435
+ return this.tasks.size > 0;
6436
+ }
6437
+ /**
6438
+ * 全量替换任务列表
6439
+ *
6440
+ * @param items - 新任务列表
6441
+ * @param merge - true=按 ID 合并,false=全量替换
6442
+ * @returns 校验错误信息(如果有)
6443
+ */
6444
+ write(items, merge = false) {
6445
+ const now = Date.now();
6446
+ const newTasks = /* @__PURE__ */ new Map();
6447
+ let inProgressCount = 0;
6448
+ if (merge) for (const [id, existing] of this.tasks) {
6449
+ newTasks.set(id, { ...existing });
6450
+ if (existing.status === "in_progress") inProgressCount++;
6451
+ }
6452
+ for (const item of items) {
6453
+ const existing = merge ? newTasks.get(item.id) : this.tasks.get(item.id) ?? newTasks.get(item.id);
6454
+ if (existing && (existing.status === "completed" || existing.status === "cancelled")) {
6455
+ if (merge) continue;
6456
+ return `任务 "${item.id}" 已处于终态 (${existing.status}),不可修改`;
6457
+ }
6458
+ if (item.status === "in_progress") inProgressCount++;
6459
+ const taskItem = {
6460
+ id: item.id,
6461
+ subject: item.subject,
6462
+ status: item.status,
6463
+ createdAt: existing?.createdAt ?? now,
6464
+ updatedAt: now
6465
+ };
6466
+ if (item.description !== void 0) taskItem.description = item.description;
6467
+ newTasks.set(item.id, taskItem);
6468
+ }
6469
+ if (inProgressCount > 1) return `不允许同时有 ${inProgressCount} 个进行中的任务,请先将当前任务完成或取消后再开始新任务`;
6470
+ this.tasks = newTasks;
6471
+ this.persist();
6472
+ return null;
6473
+ }
6474
+ /**
6475
+ * 增量更新单个任务
6476
+ *
6477
+ * @param id - 任务 ID
6478
+ * @param updates - 要更新的字段
6479
+ * @returns 校验错误信息(如果有)
6480
+ */
6481
+ update(id, updates) {
6482
+ const existing = this.tasks.get(id);
6483
+ if (!existing) return `任务 "${id}" 不存在`;
6484
+ if (existing.status === "completed" || existing.status === "cancelled") return `任务 "${id}" 已处于终态 (${existing.status}),不可修改`;
6485
+ if (updates.status === "in_progress") {
6486
+ for (const task of this.tasks.values()) if (task.id !== id && task.status === "in_progress") return `已有进行中的任务 "${task.id}: ${task.subject}",请先将它完成或取消后再开始新任务`;
6487
+ }
6488
+ const updated = {
6489
+ ...existing,
6490
+ ...updates.subject !== void 0 ? { subject: updates.subject } : {},
6491
+ ...updates.description !== void 0 ? { description: updates.description } : {},
6492
+ ...updates.status !== void 0 ? { status: updates.status } : {},
6493
+ updatedAt: Date.now()
6494
+ };
6495
+ this.tasks.set(id, updated);
6496
+ this.persist();
6497
+ return null;
6498
+ }
6499
+ /** 清空所有任务 */
6500
+ clear() {
6501
+ this.tasks.clear();
6502
+ this.persist();
6503
+ }
6504
+ /** 持久化到文件系统 */
6505
+ persist() {
6506
+ try {
6507
+ ensureTaskDir();
6508
+ const data = JSON.stringify(this.read(), null, 2);
6509
+ writeFileSync(getTaskFilePath(), data, "utf-8");
6510
+ } catch {}
6511
+ }
6512
+ /** 从文件系统恢复 */
6513
+ load() {
6514
+ try {
6515
+ const raw = readFileSync(getTaskFilePath(), "utf-8");
6516
+ const data = JSON.parse(raw);
6517
+ if (!Array.isArray(data)) return false;
6518
+ const items = data;
6519
+ this.tasks.clear();
6520
+ for (const item of items) if (typeof item.id === "string" && typeof item.subject === "string" && typeof item.status === "string" && [
6521
+ "pending",
6522
+ "in_progress",
6523
+ "completed",
6524
+ "cancelled"
6525
+ ].includes(item.status)) {
6526
+ const taskItem = {
6527
+ id: item.id,
6528
+ subject: item.subject,
6529
+ status: item.status,
6530
+ createdAt: typeof item.createdAt === "number" ? item.createdAt : Date.now(),
6531
+ updatedAt: typeof item.updatedAt === "number" ? item.updatedAt : Date.now()
6532
+ };
6533
+ if (typeof item.description === "string") taskItem.description = item.description;
6534
+ this.tasks.set(item.id, taskItem);
6535
+ }
6536
+ return true;
6537
+ } catch {
6538
+ return false;
6539
+ }
6540
+ }
6541
+ /**
6542
+ * 格式化活跃任务为 LLM 注入文本
6543
+ *
6544
+ * 参考 Hermes-Agent 的 format_for_injection 设计,
6545
+ * 在上下文压缩后将活跃任务重新注入给模型。
6546
+ */
6547
+ formatForInjection() {
6548
+ const active = this.getActiveTasks();
6549
+ if (active.length === 0) return null;
6550
+ const lines = ["## 当前任务列表"];
6551
+ for (const task of active) {
6552
+ const marker = task.status === "in_progress" ? "▶" : "○";
6553
+ lines.push(` ${marker} [${task.id}] ${task.subject}`);
6554
+ }
6555
+ return lines.join("\n");
6556
+ }
6557
+ };
6558
+
1996
6559
  //#endregion
1997
6560
  //#region src/llm/pi-ai-provider.ts
1998
6561
  /**
@@ -2062,10 +6625,16 @@ var ReplSession = class {
2062
6625
  currentTaskAbort = null;
2063
6626
  /** Agent 实例(会话级复用,替代直接 LLM 调用) */
2064
6627
  agent;
6628
+ /** MCP 连接管理器(在 registerBuiltinTools 中异步初始化) */
6629
+ mcpManager = null;
2065
6630
  /** 当前正在执行的 taskId(用于取消操作) */
2066
6631
  currentTaskId = null;
2067
6632
  /** 多轮对话上下文(兼容保留,Agent 内部也维护历史) */
2068
6633
  conversationHistory = [];
6634
+ /** 定时任务调度器 */
6635
+ cronScheduler = null;
6636
+ /** 任务管理器(会话级持久化) */
6637
+ taskStore;
2069
6638
  stats = {
2070
6639
  totalRequests: 0,
2071
6640
  successCount: 0,
@@ -2098,6 +6667,18 @@ var ReplSession = class {
2098
6667
  this.renderer = new Renderer(this.options);
2099
6668
  this.history = new HistoryStore(this.options.maxHistorySize);
2100
6669
  this.agent = this.createReplAgent();
6670
+ this.taskStore = new TaskStore();
6671
+ this.taskStore.load();
6672
+ this.cronScheduler = new CronScheduler(getCronStore(), { isIdle: () => this._state === "idle" });
6673
+ this.cronScheduler.start();
6674
+ const memoryStore = getMemoryStore();
6675
+ memoryStore.freezeSnapshot().then(() => {
6676
+ this.agent.memorySnapshot = memoryStore.getSnapshot();
6677
+ }).catch((err) => {
6678
+ log.warn("记忆快照冻结失败,将使用空快照", { error: err instanceof Error ? err.message : String(err) });
6679
+ this.agent.memorySnapshot = "";
6680
+ });
6681
+ if (this.config.skill?.enabled !== false) this.initSkills();
2101
6682
  this.registerBuiltinCommands();
2102
6683
  this.registerBuiltinTools();
2103
6684
  this.setupEditorHandlers();
@@ -2125,6 +6706,14 @@ var ReplSession = class {
2125
6706
  log.info("REPL 关闭", { reason: reason ?? "未知" });
2126
6707
  this.cancelCurrentTask();
2127
6708
  eventBus.emit("system:shutdown", { reason });
6709
+ if (this.cronScheduler) {
6710
+ this.cronScheduler.stop();
6711
+ this.cronScheduler = null;
6712
+ }
6713
+ if (this.mcpManager) {
6714
+ await this.mcpManager.shutdown();
6715
+ this.mcpManager = null;
6716
+ }
2128
6717
  this.tui.stop();
2129
6718
  }
2130
6719
  /** 获取渲染器引用 */
@@ -2184,8 +6773,15 @@ var ReplSession = class {
2184
6773
  const errorHandler = (event) => {
2185
6774
  if (event.taskId === taskId) log.error("Agent 执行中收到 error 事件", { error: event.error.message });
2186
6775
  };
6776
+ const progressHandler = (event) => {
6777
+ if (event.taskId === taskId && event.percent === 0) {
6778
+ this.outputArea.append([` → ${event.message}`]);
6779
+ this.tui.requestRender();
6780
+ }
6781
+ };
2187
6782
  this.agent.on(this.agent.EVENT_OUTPUT, outputHandler);
2188
6783
  this.agent.on(this.agent.EVENT_ERROR, errorHandler);
6784
+ this.agent.on(this.agent.EVENT_PROGRESS, progressHandler);
2189
6785
  this.currentTaskId = taskId;
2190
6786
  log.debug("开始通过 Agent 执行目标", {
2191
6787
  taskId,
@@ -2202,6 +6798,7 @@ var ReplSession = class {
2202
6798
  });
2203
6799
  this.agent.off(this.agent.EVENT_OUTPUT, outputHandler);
2204
6800
  this.agent.off(this.agent.EVENT_ERROR, errorHandler);
6801
+ this.agent.off(this.agent.EVENT_PROGRESS, progressHandler);
2205
6802
  log.debug("Agent 执行完成", {
2206
6803
  taskId,
2207
6804
  status: taskResult.status,
@@ -2379,9 +6976,73 @@ var ReplSession = class {
2379
6976
  }
2380
6977
  /**
2381
6978
  * 注册 REPL 场景下的基础工具
6979
+ *
6980
+ * 在注册内置工具后,异步初始化 MCP 工具。
6981
+ * MCP 连接不阻塞内置工具注册——Agent 立即可用内置工具,
6982
+ * MCP 工具在连接完成后自动追加。
2382
6983
  */
2383
6984
  registerBuiltinTools() {
2384
- this.agent.registerTools(createReplBuiltinTools(this.config.web));
6985
+ this.agent.registerTools(createReplBuiltinTools(this.config.web, this.taskStore, this.config.skill, this.agent, this.config.subAgent, this.cronScheduler ?? void 0));
6986
+ const mcpServers = this.config.mcp ? normalizeMcpConfig(this.config.mcp) : [];
6987
+ if (mcpServers.length > 0) initializeMcpTools(mcpServers, this.agent).then((manager) => {
6988
+ this.mcpManager = manager;
6989
+ }).catch((err) => {
6990
+ log.error("MCP 初始化失败", { error: err instanceof Error ? err.message : String(err) });
6991
+ });
6992
+ }
6993
+ /**
6994
+ * 初始化 Skill 系统
6995
+ *
6996
+ * 从 bundled/user/project/workspace 四个来源加载技能,
6997
+ * 构建快照注入到 Agent 系统提示。
6998
+ */
6999
+ initSkills() {
7000
+ const skillConfig = this.config.skill;
7001
+ if (!skillConfig?.enabled) return;
7002
+ loadSkills({
7003
+ enabled: true,
7004
+ extraDirs: skillConfig.loadDirs,
7005
+ maxSkillsInPrompt: skillConfig.maxSkillsInPrompt,
7006
+ maxSkillFileBytes: skillConfig.maxSkillFileBytes
7007
+ }, process.cwd()).then((entries) => {
7008
+ setSkillEntries(entries);
7009
+ this.agent.skillEntries = entries;
7010
+ const snapshot = buildSkillSnapshot(entries, skillConfig.maxSkillsInPrompt);
7011
+ this.agent.skillPrompt = snapshot.prompt;
7012
+ this._registerSkillCommands(entries);
7013
+ log.info("Skill 系统初始化完成", {
7014
+ count: snapshot.count,
7015
+ names: snapshot.names
7016
+ });
7017
+ }).catch((err) => {
7018
+ log.error("Skill 加载失败", { error: err instanceof Error ? err.message : String(err) });
7019
+ });
7020
+ }
7021
+ /**
7022
+ * 为 user-invocable 技能注册斜杠命令
7023
+ *
7024
+ * 将每个用户可调用的技能注册为 /skill-name 格式的 CLI 命令。
7025
+ * 命令执行时,将技能名称和用户参数作为 goal 发送给 Agent 处理。
7026
+ */
7027
+ _registerSkillCommands(entries) {
7028
+ const specs = getSkillCommandSpecs(entries);
7029
+ for (const spec of specs) {
7030
+ if (this.registry.getCommand(spec.name)) {
7031
+ log.debug(`跳过 skill 命令 "${spec.name}":与内置命令冲突`);
7032
+ continue;
7033
+ }
7034
+ this.registry.register({
7035
+ name: spec.name,
7036
+ description: spec.description,
7037
+ aliases: [],
7038
+ usage: spec.name,
7039
+ handler: async (args) => {
7040
+ const argsStr = args.join(" ");
7041
+ const goalInput = argsStr ? `请使用 /${spec.name} 技能,参数: ${argsStr}` : `请使用 /${spec.name} 技能`;
7042
+ await this.executeGoal(goalInput);
7043
+ }
7044
+ });
7045
+ }
2385
7046
  }
2386
7047
  /** 设置编辑器事件绑定 */
2387
7048
  setupEditorHandlers() {