hob-bot 0.0.0-stage → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +111 -2
  3. package/README.zh-CN.md +112 -0
  4. package/bin/hob.mjs +7 -0
  5. package/dist/web/assets/index-D88l35PH.css +1 -0
  6. package/dist/web/assets/index-DUs8nN3E.js +218 -0
  7. package/dist/web/favicon.ico +0 -0
  8. package/dist/web/fonts/inter-latin.woff2 +0 -0
  9. package/dist/web/fonts/jetbrains-mono-latin.woff2 +0 -0
  10. package/dist/web/icons/apple-touch-icon-180.png +0 -0
  11. package/dist/web/icons/favicon-32.png +0 -0
  12. package/dist/web/icons/icon-192.png +0 -0
  13. package/dist/web/icons/icon-512.png +0 -0
  14. package/dist/web/icons/icon-maskable-512.png +0 -0
  15. package/dist/web/index.html +32 -0
  16. package/dist/web/manifest.webmanifest +16 -0
  17. package/dist/web/sw.js +131 -0
  18. package/package.json +62 -4
  19. package/server/src/actions.ts +230 -0
  20. package/server/src/boot.ts +85 -0
  21. package/server/src/bus.ts +21 -0
  22. package/server/src/config.ts +123 -0
  23. package/server/src/cron-text.ts +49 -0
  24. package/server/src/delegations.ts +113 -0
  25. package/server/src/files.ts +142 -0
  26. package/server/src/http.ts +802 -0
  27. package/server/src/ids.ts +19 -0
  28. package/server/src/image.ts +218 -0
  29. package/server/src/imports.ts +302 -0
  30. package/server/src/jobs.ts +730 -0
  31. package/server/src/main.ts +69 -0
  32. package/server/src/memory.ts +272 -0
  33. package/server/src/models.ts +358 -0
  34. package/server/src/overview.ts +116 -0
  35. package/server/src/pages.ts +54 -0
  36. package/server/src/password.ts +23 -0
  37. package/server/src/pi.ts +273 -0
  38. package/server/src/prompt.ts +227 -0
  39. package/server/src/providers.ts +532 -0
  40. package/server/src/push.ts +285 -0
  41. package/server/src/router-tools.ts +549 -0
  42. package/server/src/router.ts +841 -0
  43. package/server/src/routines.ts +129 -0
  44. package/server/src/runtime.ts +104 -0
  45. package/server/src/search.ts +91 -0
  46. package/server/src/secrets.ts +46 -0
  47. package/server/src/settings.ts +452 -0
  48. package/server/src/skills.ts +150 -0
  49. package/server/src/store.ts +324 -0
  50. package/server/src/text.ts +14 -0
  51. package/server/src/time.ts +42 -0
  52. package/server/src/transcript.ts +272 -0
  53. package/server/src/worker-tools.ts +129 -0
  54. package/shared/command.ts +42 -0
  55. package/shared/files.ts +141 -0
  56. package/shared/media.ts +17 -0
  57. package/shared/model-note.ts +32 -0
  58. package/shared/page-shell.ts +108 -0
  59. package/shared/search.ts +19 -0
  60. package/shared/skills.ts +37 -0
  61. package/shared/sprites.ts +422 -0
  62. package/shared/thinking.ts +118 -0
  63. package/shared/types.ts +631 -0
  64. package/vendor/lore-pi-extension/README.md +28 -0
  65. package/vendor/lore-pi-extension/api.ts +140 -0
  66. package/vendor/lore-pi-extension/formatters.ts +204 -0
  67. package/vendor/lore-pi-extension/hooks.ts +195 -0
  68. package/vendor/lore-pi-extension/index.ts +25 -0
  69. package/vendor/lore-pi-extension/package.json +23 -0
  70. package/vendor/lore-pi-extension/skills.ts +447 -0
  71. package/vendor/lore-pi-extension/tools.ts +344 -0
  72. package/vendor/lore-pi-extension/uri.ts +67 -0
  73. package/vendor/lore-pi-extension/vendor/skill-workcopy/index.d.mts +266 -0
  74. package/vendor/lore-pi-extension/vendor/skill-workcopy/index.mjs +1328 -0
  75. package/vendor/lore-pi-extension/vendor/skill-workcopy/skills-sync.d.mts +33 -0
  76. package/vendor/lore-pi-extension/vendor/skill-workcopy/skills-sync.mjs +202 -0
@@ -0,0 +1,123 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { hashPassword, isPasswordHash } from "./password.ts";
6
+ import type { ThinkingLevel } from "../../shared/types.ts";
7
+
8
+ export type { ThinkingLevel };
9
+
10
+ /**
11
+ * 启动用的配置(HOB_HOME/config.json)。界面能改的东西都在 settings.json,
12
+ * 这里只留启动项:端口、host、密码哈希和 cookie 签名密钥。模型在设置 → 模型里。
13
+ */
14
+ export interface HobConfig {
15
+ host: string;
16
+ port: number;
17
+ /** scrypt 哈希,见 password.ts;明文密码在首次启动时转成它 */
18
+ passwordHash: string;
19
+ cookieSecret: string;
20
+ }
21
+
22
+ export const DEFAULT_CONFIG: Omit<HobConfig, "passwordHash" | "cookieSecret"> = {
23
+ host: "127.0.0.1",
24
+ port: 30148,
25
+ };
26
+
27
+ export function expandHome(path: string): string {
28
+ return path.startsWith("~/") ? join(homedir(), path.slice(2)) : path;
29
+ }
30
+
31
+ export function resolveHome(env: NodeJS.ProcessEnv = process.env): string {
32
+ return expandHome(env.HOB_HOME?.trim() || join(homedir(), ".hob"));
33
+ }
34
+
35
+ /** 引擎目录(凭据、资源、记忆):Hob 自己管,放在 HOB_HOME 里。 */
36
+ export function engineDir(home: string): string {
37
+ return join(home, "engine");
38
+ }
39
+
40
+ /** 本地记忆插件的目录(插件在模块加载时就读这个环境变量)。 */
41
+ export function memoryDir(home: string): string {
42
+ return join(home, "memory");
43
+ }
44
+
45
+ /** Lore 连接的目录:Hob 自己的 config.json 在这里,不碰其他 Lore 客户端的那份。 */
46
+ export function loreDir(home: string): string {
47
+ return join(home, "lore");
48
+ }
49
+
50
+ const CONFIG_FILE = "config.json";
51
+ /** 搬进 settings.json 之后不再写回去的旧键。 */
52
+ export const LEGACY_MODEL_KEYS = ["routerModel", "routerThinking", "workerModel", "workerThinking"];
53
+
54
+
55
+ /** 里面有密码哈希和 cookieSecret:只给自己读写(和 secrets.json 一样 0600)。 */
56
+ function writeAtomic(path: string, text: string): void {
57
+ const tmp = `${path}.${process.pid}.tmp`;
58
+ writeFileSync(tmp, text, { mode: 0o600 });
59
+ renameSync(tmp, path);
60
+ }
61
+
62
+ /**
63
+ * 读 HOB_HOME/config.json;首次启动时创建(cookieSecret 随机生成)。
64
+ * 密码存哈希:文件里还是明文 password,或者用了 HOB_PASSWORD,就转成哈希写回。
65
+ */
66
+ export function loadConfig(home: string, env: NodeJS.ProcessEnv = process.env): HobConfig {
67
+ mkdirSync(home, { recursive: true });
68
+ const file = join(home, CONFIG_FILE);
69
+ const stored: Record<string, unknown> = existsSync(file)
70
+ ? (JSON.parse(readFileSync(file, "utf8")) as Record<string, unknown>)
71
+ : {};
72
+ const plain = typeof stored.password === "string" ? stored.password.trim() : "";
73
+ const fromEnv = (env.HOB_PASSWORD ?? "").trim();
74
+ const storedHash = typeof stored.passwordHash === "string" ? stored.passwordHash.trim() : "";
75
+ const passwordHash = isPasswordHash(storedHash)
76
+ ? storedHash
77
+ : plain || fromEnv
78
+ ? hashPassword(plain || fromEnv)
79
+ : "";
80
+ if (!passwordHash) {
81
+ throw new Error(
82
+ `没有设置密码:请在 ${file} 里填 password(会转成哈希),或启动时设置 HOB_PASSWORD。`,
83
+ );
84
+ }
85
+ const needWrite = !isPasswordHash(storedHash) || !stored.cookieSecret || hasLegacyModelKeys(home);
86
+ const config: HobConfig = {
87
+ host: (env.HOB_HOST ?? (typeof stored.host === "string" ? stored.host : "") ?? "").trim() || DEFAULT_CONFIG.host,
88
+ port: Number(stored.port) > 0 ? Number(stored.port) : DEFAULT_CONFIG.port,
89
+ passwordHash,
90
+ cookieSecret:
91
+ typeof stored.cookieSecret === "string" && stored.cookieSecret ? stored.cookieSecret : randomBytes(32).toString("hex"),
92
+ };
93
+ if (needWrite) writeConfig(home, config);
94
+ return config;
95
+ }
96
+
97
+ function hasLegacyModelKeys(home: string): boolean {
98
+ try {
99
+ const stored = JSON.parse(readFileSync(join(home, CONFIG_FILE), "utf8")) as Record<string, unknown>;
100
+ return LEGACY_MODEL_KEYS.some((key) => key in stored);
101
+ } catch {
102
+ return false;
103
+ }
104
+ }
105
+
106
+ /** 只写已知字段:搬走的旧键(模型、theme、timezone、maxJobs…)不留在文件里。 */
107
+ export function writeConfig(home: string, config: HobConfig): void {
108
+ mkdirSync(home, { recursive: true });
109
+ const out = {
110
+ host: config.host,
111
+ port: config.port,
112
+ passwordHash: config.passwordHash,
113
+ cookieSecret: config.cookieSecret,
114
+ };
115
+ writeAtomic(join(home, CONFIG_FILE), `${JSON.stringify(out, null, 2)}\n`);
116
+ }
117
+
118
+ /** 改密码:写回 config.json。 */
119
+ export function setPassword(home: string, config: HobConfig, password: string): HobConfig {
120
+ const next: HobConfig = { ...config, passwordHash: hashPassword(password) };
121
+ writeConfig(home, next);
122
+ return next;
123
+ }
@@ -0,0 +1,49 @@
1
+ const WEEKDAYS = ["日", "一", "二", "三", "四", "五", "六"];
2
+
3
+ function time(hour: string, minute: string): string {
4
+ return `${hour.padStart(2, "0")}:${minute.padStart(2, "0")}`;
5
+ }
6
+
7
+ function weekday(token: string): string | undefined {
8
+ const map: Record<string, string> = { "0": "日", "7": "日", "1": "一", "2": "二", "3": "三", "4": "四", "5": "五", "6": "六" };
9
+ return map[token];
10
+ }
11
+
12
+ /**
13
+ * cron 转中文:覆盖常见的每天 / 每周 / 每月 / 每隔几分钟,认不出的原样返回。
14
+ * 只处理 5 段表达式(分 时 日 月 周)。
15
+ */
16
+ export function cronToChinese(expression: string): string {
17
+ const parts = expression.trim().split(/\s+/);
18
+ if (parts.length !== 5) return expression;
19
+ const [minute, hour, day, month, dow] = parts;
20
+ const at = /^\d+$/.test(minute) && /^\d+$/.test(hour) ? time(hour, minute) : undefined;
21
+
22
+ if (minute.startsWith("*/") && hour === "*" && day === "*" && month === "*" && dow === "*") {
23
+ return `每 ${minute.slice(2)} 分钟`;
24
+ }
25
+ if (minute === "0" && hour === "*" && day === "*" && month === "*" && dow === "*") return "每小时整点";
26
+ if (!at) return expression;
27
+
28
+ if (day === "L" && month === "*" && dow === "*") return `每月最后一天 ${at}`;
29
+ if (/^\d+$/.test(day) && month === "*" && dow === "*") return `每月 ${day} 日 ${at}`;
30
+ if (day === "*" && month === "*") {
31
+ if (dow === "*") return `每天 ${at}`;
32
+ if (/^\d+$/.test(dow)) {
33
+ const name = weekday(dow);
34
+ return name ? `每周${name} ${at}` : expression;
35
+ }
36
+ const range = dow.match(/^(\d)-(\d)$/);
37
+ if (range) {
38
+ const from = weekday(range[1]);
39
+ const to = weekday(range[2]);
40
+ if (from && to) return `每周${from}到${to} ${at}`;
41
+ }
42
+ const list = dow.split(",");
43
+ if (list.length > 1 && list.every((item) => /^\d$/.test(item))) {
44
+ const names = list.map((item) => WEEKDAYS[Number(item) % 7]);
45
+ return `每周${names.join("、")} ${at}`;
46
+ }
47
+ }
48
+ return expression;
49
+ }
@@ -0,0 +1,113 @@
1
+ import { readFileSync } from "node:fs";
2
+ import type { Delegation } from "../../shared/types.ts";
3
+ import { newId } from "./ids.ts";
4
+ import type { HobRuntime } from "./runtime.ts";
5
+
6
+ /** 转交状态由目标回合、任务和审批共同决定;判定和标记回流不跨异步边界。 */
7
+ export class DelegationManager {
8
+ recovering = false;
9
+
10
+ constructor(private readonly runtime: HobRuntime) {
11
+ runtime.bus.subscribe((event) => {
12
+ if (this.recovering) return;
13
+ if (event.type === "job" && event.job.delegationId) this.update(event.job.delegationId);
14
+ if (event.type === "approval") {
15
+ for (const delegation of runtime.store.delegations()) {
16
+ if (delegation.approvalIds?.includes(event.approval.id) || (event.approval.jobId && delegation.jobIds.includes(event.approval.jobId))) this.update(delegation.id);
17
+ }
18
+ }
19
+ });
20
+ }
21
+
22
+ async start(to: string, text: string): Promise<Delegation> {
23
+ const target = this.runtime.store.assistant(to);
24
+ if (!target || to === "hob") throw new Error("目标助手不存在或不能转交给自己。");
25
+ const delegation: Delegation = { id: newId("dg-"), from: "hob", to, text, createdAt: new Date().toISOString(), status: "running", jobIds: [], approvalIds: [] };
26
+ this.runtime.store.saveDelegation(delegation);
27
+ await this.runtime.router.addItem("hob", { kind: "delegation", delegationId: delegation.id });
28
+ try {
29
+ await this.runtime.router.addForwarded(to, text, delegation.id);
30
+ } catch (error) {
31
+ this.turnEnded(delegation.id, error instanceof Error ? error.message : String(error), true);
32
+ throw error;
33
+ }
34
+ this.runtime.bus.emit({ type: "delegation", delegation });
35
+ void this.runtime.router.relay(to, text, delegation.id).catch((error: unknown) => {
36
+ this.turnEnded(delegation.id, error instanceof Error ? error.message : String(error), true);
37
+ });
38
+ return delegation;
39
+ }
40
+
41
+ addJob(id: string, jobId: string): void {
42
+ const record = this.runtime.store.delegation(id);
43
+ if (!record || record.jobIds.includes(jobId)) return;
44
+ this.runtime.store.saveDelegation({ ...record, jobIds: [...record.jobIds, jobId] });
45
+ }
46
+
47
+ addApproval(id: string, approvalId: string): void {
48
+ const record = this.runtime.store.delegation(id);
49
+ if (!record) return;
50
+ this.runtime.store.saveDelegation({ ...record, approvalIds: [...(record.approvalIds ?? []), approvalId] });
51
+ this.update(id);
52
+ }
53
+
54
+ turnEnded(id: string, reply: string, failed = false): void {
55
+ const record = this.runtime.store.delegation(id);
56
+ if (!record || record.turnFinished) return;
57
+ this.runtime.store.saveDelegation({ ...record, reply, turnFinished: true, ...(failed ? { turnFailed: true } : {}) });
58
+ this.update(id);
59
+ }
60
+
61
+ returnToHob(id: string, text: string, reason: string): void {
62
+ const record = this.runtime.store.delegation(id);
63
+ if (!record || record.turnFinished) throw new Error("转交已结束,无法退回");
64
+ this.runtime.store.saveDelegation({ ...record, status: "returned", reply: `退回:${reason}`, returnedText: text, turnFinished: true });
65
+ this.update(id);
66
+ }
67
+
68
+ update(id: string): void {
69
+ const { store, bus, router } = this.runtime;
70
+ const record = store.delegation(id);
71
+ if (!record || record.reportedAt || record.status === "interrupted") return;
72
+ const jobs = record.jobIds.map((jobId) => store.job(jobId));
73
+ const waiting = jobs.some((job) => job?.status === "waiting") || store.approvals().some((approval) => approval.status === "pending" && (record.approvalIds?.includes(approval.id) || (approval.jobId && record.jobIds.includes(approval.jobId))));
74
+ const active = jobs.some((job) => job && ["queued", "running", "waiting"].includes(job.status));
75
+ const failed = record.turnFailed || jobs.some((job) => !job || job.status === "stuck" || job.status === "failed");
76
+ const status = record.status === "returned" ? "returned" : waiting ? "waiting" : !record.turnFinished || active ? "running" : failed ? "stuck" : "done";
77
+ const complete = record.turnFinished && !active && !waiting;
78
+ const now = new Date().toISOString();
79
+ const next: Delegation = { ...record, status, ...(complete ? { endedAt: now, reportedAt: now } : {}) };
80
+ store.saveDelegation(next);
81
+ bus.emit({ type: "delegation", delegation: next });
82
+ if (complete) {
83
+ const reports = jobs.map((job) => {
84
+ if (!job) return "任务记录丢失";
85
+ let report = job.summary ?? job.statusReason ?? "无报告";
86
+ if (job.reportPath) {
87
+ try { report = readFileSync(job.reportPath, "utf8"); }
88
+ catch (error) { report = `报告读取失败:${error instanceof Error ? error.message : String(error)};摘要:${report}`; }
89
+ }
90
+ return `任务「${job.title}」${job.status}:${report.length > 6000 ? `${report.slice(0, 6000)}…(报告过长已截断,完整内容在任务详情)` : report}`;
91
+ });
92
+ const name = store.assistant(record.to)?.name ?? record.to;
93
+ const message = record.status === "returned"
94
+ ? `转交给「${name}」的事已退回:${record.reply}。原请求:${record.text}\n交回内容:${record.returnedText ?? record.text}\n「${name}」已退回过一次,别再转给它。`
95
+ : `转交给「${name}」的事已结束:${record.reply || "(没有回复)"}`;
96
+ void router.event(record.from, `${message}\n${reports.join("\n")}`).catch(() => undefined);
97
+ }
98
+ }
99
+
100
+ recover(): void {
101
+ this.recovering = false;
102
+ for (const record of this.runtime.store.delegations()) {
103
+ if (record.reportedAt || !["running", "waiting", "returned"].includes(record.status)) continue;
104
+ if (record.jobIds.some((id) => { const job = this.runtime.store.job(id); return job && ["running", "queued", "waiting"].includes(job.status); })) continue;
105
+ const now = new Date().toISOString();
106
+ const next: Delegation = { ...record, status: "interrupted", endedAt: now, reportedAt: now };
107
+ this.runtime.store.saveDelegation(next);
108
+ this.runtime.bus.emit({ type: "delegation", delegation: next });
109
+ const name = this.runtime.store.assistant(record.to)?.name ?? record.to;
110
+ void this.runtime.router.event(record.from, `转交给「${name}」的事因服务重启中断`).catch(() => undefined);
111
+ }
112
+ }
113
+ }
@@ -0,0 +1,142 @@
1
+ // /api/files 的取文件规则:路径先解开符号链接,再判断能不能给页面看。
2
+ import { closeSync, openSync, readSync, readdirSync, realpathSync, statSync } from "node:fs";
3
+ import { basename, join, relative, resolve, sep } from "node:path";
4
+ import { fileKind, isTextKind, mimeOf } from "../../shared/files.ts";
5
+ import type { FileKind, JobFileEntry, JobFilesView } from "../../shared/types.ts";
6
+
7
+ /** 数据目录根上不对页面开放的文件:Hob 自己的凭据和内部状态(连同它们的临时文件、备份)。 */
8
+ const DENY_ROOT_FILE = /^(secrets|config|settings|push)\.json/i;
9
+ /** 数据目录根上不对页面开放的目录:引擎目录(凭据、bin)和 Lore 连接。 */
10
+ const DENY_ROOT_DIRS = new Set(["engine", "lore"]);
11
+ /** 备份文件一律不给(凭据的备份也在里面)。 */
12
+ const DENY_ANY_FILE = /\.bak/i;
13
+
14
+ /** 预览面板最多拉 1 MB 文本。 */
15
+ export const MAX_TEXT_BYTES = 1024 * 1024;
16
+
17
+ /** 任务工作目录里最多列 200 个文件。 */
18
+ const MAX_JOB_FILES = 200;
19
+ /** 递归太深的目录不进去(产出文件不该有这么深)。 */
20
+ const MAX_DEPTH = 8;
21
+ const SKIP_DIRS = new Set(["node_modules", ".git"]);
22
+
23
+ export interface FileInfo {
24
+ path: string;
25
+ name: string;
26
+ size: number;
27
+ modifiedAt: string;
28
+ kind: FileKind;
29
+ text: boolean;
30
+ mime: string;
31
+ page?: boolean;
32
+ }
33
+
34
+ export type Lookup = { ok: true; info: FileInfo } | { ok: false; status: 403 | 404; error: string };
35
+
36
+ function denied(root: string, path: string): boolean {
37
+ const rel = relative(root, path);
38
+ const parts = rel.split(sep);
39
+ if (DENY_ROOT_DIRS.has(parts[0] ?? "")) return true;
40
+ // 根上的凭据文件(含 settings.json.1234.tmp 这种原子写临时文件)
41
+ if (parts.length === 1 && DENY_ROOT_FILE.test(parts[0] ?? "")) return true;
42
+ return DENY_ANY_FILE.test(basename(path));
43
+ }
44
+
45
+ /** 开头几个字节里有没有 NUL 或控制字符:没有就当文本。 */
46
+ export function looksText(bytes: Uint8Array): boolean {
47
+ const head = bytes.subarray(0, 4096);
48
+ let bad = 0;
49
+ for (const byte of head) {
50
+ if (byte === 0) return false;
51
+ if (byte < 9 || (byte > 13 && byte < 32)) bad += 1;
52
+ }
53
+ return bad <= head.length * 0.01;
54
+ }
55
+
56
+ function headOf(path: string): Uint8Array {
57
+ const buffer = Buffer.alloc(4096);
58
+ let fd: number | undefined;
59
+ try {
60
+ fd = openSync(path, "r");
61
+ const read = readSync(fd, buffer, 0, buffer.length, 0);
62
+ return buffer.subarray(0, read);
63
+ } catch {
64
+ return new Uint8Array();
65
+ } finally {
66
+ if (fd !== undefined) closeSync(fd);
67
+ }
68
+ }
69
+
70
+ /**
71
+ * 解析一个路径:realpath 后必须还在数据目录里(符号链接指出去就是 403),
72
+ * 凭据和内部状态文件不开放,不是普通文件当 404。
73
+ */
74
+ export function lookupFile(home: string, requested: string): Lookup {
75
+ const root = realpathSync(resolve(home));
76
+ let path: string;
77
+ let size: number;
78
+ let modifiedAt: string;
79
+ try {
80
+ path = realpathSync(resolve(requested));
81
+ const stats = statSync(path);
82
+ if (!stats.isFile()) return { ok: false, status: 404, error: "文件不存在" };
83
+ size = stats.size;
84
+ modifiedAt = stats.mtime.toISOString();
85
+ } catch {
86
+ return { ok: false, status: 404, error: "文件不存在" };
87
+ }
88
+ if (!path.startsWith(`${root}${sep}`)) return { ok: false, status: 403, error: "只允许读 Hob 自己的文件" };
89
+ if (denied(root, path)) return { ok: false, status: 403, error: "这个文件不对页面开放" };
90
+ const name = basename(path);
91
+ const kind = fileKind(name) ?? (looksText(headOf(path)) ? "text" : "other");
92
+ const text = isTextKind(kind);
93
+ return {
94
+ ok: true,
95
+ info: { path, name, size, modifiedAt, kind, text, mime: mimeOf(name), ...(/^pages[/\\][0-9a-f-]{36}\.html$/i.test(relative(root, path)) ? { page: true } : {}) },
96
+ };
97
+ }
98
+
99
+ function walk(dir: string, base: string, depth: number, out: JobFileEntry[]): boolean {
100
+ if (depth > MAX_DEPTH) return false;
101
+ let entries;
102
+ try {
103
+ entries = readdirSync(dir, { withFileTypes: true });
104
+ } catch {
105
+ return false;
106
+ }
107
+ for (const entry of entries) {
108
+ // 符号链接不跟:可能指到目录外面,也可能绕成环
109
+ if (entry.isSymbolicLink()) continue;
110
+ const path = join(dir, entry.name);
111
+ if (entry.isDirectory()) {
112
+ if (SKIP_DIRS.has(entry.name)) continue;
113
+ if (walk(path, base, depth + 1, out)) return true;
114
+ continue;
115
+ }
116
+ if (!entry.isFile()) continue;
117
+ let stats;
118
+ try {
119
+ stats = statSync(path);
120
+ } catch {
121
+ continue;
122
+ }
123
+ out.push({
124
+ path,
125
+ rel: relative(base, path),
126
+ name: entry.name,
127
+ size: stats.size,
128
+ modifiedAt: stats.mtime.toISOString(),
129
+ kind: fileKind(entry.name) ?? (looksText(headOf(path)) ? "text" : "other"),
130
+ });
131
+ if (out.length >= MAX_JOB_FILES + 1) return true;
132
+ }
133
+ return false;
134
+ }
135
+
136
+ /** 任务工作目录里的文件:递归、修改时间倒序、最多 200 个,跳过 node_modules 和 .git。 */
137
+ export function listJobFiles(workDir: string): JobFilesView {
138
+ const all: JobFileEntry[] = [];
139
+ walk(workDir, workDir, 0, all);
140
+ all.sort((a, b) => b.modifiedAt.localeCompare(a.modifiedAt));
141
+ return { files: all.slice(0, MAX_JOB_FILES), hasMore: all.length > MAX_JOB_FILES };
142
+ }