@yoooclaw/cli 0.0.1 → 0.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.
package/dist/bin.cjs CHANGED
@@ -59,13 +59,34 @@ module.exports = __toCommonJS(exports_src);
59
59
  var import_commander = require("commander");
60
60
 
61
61
  // src/command-tree.ts
62
+ var NOTIFICATION_QUERY_OPTIONS = [
63
+ { flags: "--from <iso8601>", summary: "开始时间,如 2026-03-01T09:00:00+08:00" },
64
+ { flags: "--to <iso8601>", summary: "结束时间" },
65
+ { flags: "--app <name>", summary: "按应用过滤(支持中英文别名)" },
66
+ { flags: "--sender <name>", summary: "按发送人/标题过滤" },
67
+ { flags: "--conversation-type <type>", summary: "会话类型 group|private" },
68
+ { flags: "--keyword <text>", summary: "在标题/内容/发送人/会话名中搜索" },
69
+ { flags: "--limit <n>", summary: "最大返回条数", default: "100" }
70
+ ];
62
71
  var COMMAND_TREE = [
63
72
  {
64
73
  name: "config",
65
74
  summary: "配置管理 \uD83D\uDFE2",
66
75
  subcommands: [
67
- { name: "init", summary: "交互式首次向导,生成 config + gateway token" },
68
- { name: "show", summary: "显示当前 profile 配置(敏感字段遮罩)" },
76
+ {
77
+ name: "init",
78
+ summary: "交互式首次向导,生成 config + gateway token",
79
+ options: [
80
+ { flags: "--non-interactive", summary: "跳过向导(配合 --from-file)" },
81
+ { flags: "--from-file <path>", summary: "从 JSON 文件导入配置(- 为 stdin)" },
82
+ { flags: "--force", summary: "已存在 config 时覆盖" }
83
+ ]
84
+ },
85
+ {
86
+ name: "show",
87
+ summary: "显示当前 profile 配置(敏感字段遮罩)",
88
+ options: [{ flags: "--show-secrets", summary: "输出敏感字段明文(需 TTY)" }]
89
+ },
69
90
  { name: "set <key> <value>", summary: "设置单个配置项(点号路径)" },
70
91
  { name: "unset <key>", summary: "删除单个配置项" }
71
92
  ]
@@ -77,15 +98,27 @@ var COMMAND_TREE = [
77
98
  { name: "list", summary: "列出所有 profile,标注 active" },
78
99
  { name: "use <name>", summary: "切换 active profile" },
79
100
  { name: "create <name>", summary: "新建 profile(走 config init 向导)" },
80
- { name: "delete <name>", summary: "删除 profile(非 active,需 --yes)" }
101
+ {
102
+ name: "delete <name>",
103
+ summary: "删除 profile(非 active,需 --yes)",
104
+ options: [{ flags: "--yes", summary: "跳过确认" }]
105
+ }
81
106
  ]
82
107
  },
83
108
  {
84
109
  name: "auth",
85
110
  summary: "凭据与鉴权 \uD83D\uDFE2/\uD83D\uDFE1",
86
111
  subcommands: [
87
- { name: "set-api-key <key>", summary: "写入 account 级 api-key 到共享凭据文件 \uD83D\uDFE2" },
88
- { name: "token rotate", summary: "生成新 gateway token 并热重载 \uD83D\uDFE1" },
112
+ {
113
+ name: "set-api-key <key>",
114
+ summary: "写入 account 级 api-key 到共享凭据文件(- 从 stdin 读)\uD83D\uDFE2",
115
+ options: [{ flags: "--keychain", summary: "写入 OS keychain 而非文件" }]
116
+ },
117
+ {
118
+ name: "token-rotate",
119
+ summary: "生成新 gateway token 并热重载 \uD83D\uDFE1",
120
+ options: [{ flags: "--length <n>", summary: "token 字节长度", default: "32" }]
121
+ },
89
122
  { name: "status", summary: "显示鉴权状态(本地检查,不调 daemon)\uD83D\uDFE2" },
90
123
  { name: "check", summary: "端到端鉴权体检(调 daemon /daemon/status)\uD83D\uDFE1" }
91
124
  ]
@@ -94,20 +127,63 @@ var COMMAND_TREE = [
94
127
  name: "daemon",
95
128
  summary: "守护进程管理 \uD83D\uDD35",
96
129
  subcommands: [
97
- { name: "start", summary: "启动 daemon(默认后台 detach)" },
130
+ {
131
+ name: "start",
132
+ summary: "启动 daemon(默认后台 detach)",
133
+ options: [
134
+ { flags: "--bind <host>", summary: "监听地址(默认 config.daemon.bind)" },
135
+ { flags: "--port <n>", summary: "监听端口(默认 config.daemon.port)" },
136
+ { flags: "--no-detach", summary: "前台运行(systemd/launchd 用)" },
137
+ { flags: "--log-level <level>", summary: "error|warn|info|debug|trace" }
138
+ ]
139
+ },
98
140
  { name: "stop", summary: "停止 daemon(SIGTERM → 10s → SIGKILL)" },
99
141
  { name: "restart", summary: "stop + start,保留原启动参数" },
100
142
  { name: "status", summary: "打印 daemon 状态(PID/端口/relay/规则数...)" },
101
- { name: "logs", summary: "跟踪 daemon 日志" }
143
+ {
144
+ name: "logs",
145
+ summary: "跟踪 daemon 日志",
146
+ options: [
147
+ { flags: "-f, --follow", summary: "持续 tail" },
148
+ { flags: "--lines <n>", summary: "初始展示行数", default: "100" },
149
+ { flags: "--level <level>", summary: "过滤日志级别" }
150
+ ]
151
+ },
152
+ {
153
+ name: "run-foreground",
154
+ summary: "(内部)前台运行 daemon 主循环,供 detach 子进程调用",
155
+ options: [
156
+ { flags: "--bind <host>", summary: "监听地址" },
157
+ { flags: "--port <n>", summary: "监听端口" },
158
+ { flags: "--log-level <level>", summary: "日志级别" }
159
+ ]
160
+ }
102
161
  ]
103
162
  },
104
163
  {
105
164
  name: "notification",
106
165
  summary: "通知查询 \uD83D\uDFE2",
107
166
  subcommands: [
108
- { name: "search", summary: "按筛选条件查询通知,时间倒序" },
109
- { name: "summary", summary: "聚合统计 + 样例摘要,供 Agent 总结" },
110
- { name: "stats", summary: "按维度聚合统计" },
167
+ { name: "search", summary: "按筛选条件查询通知,时间倒序", options: NOTIFICATION_QUERY_OPTIONS },
168
+ {
169
+ name: "summary",
170
+ summary: "聚合统计 + 样例摘要,供 Agent 总结",
171
+ options: [
172
+ ...NOTIFICATION_QUERY_OPTIONS,
173
+ { flags: "--sample <n>", summary: "返回最近样例条数", default: "30" },
174
+ { flags: "--top <n>", summary: "聚合榜单条数", default: "10" }
175
+ ]
176
+ },
177
+ {
178
+ name: "stats",
179
+ summary: "按维度聚合统计",
180
+ options: [
181
+ { flags: "--from <date>", summary: "YYYY-MM-DD,默认 7 天前" },
182
+ { flags: "--to <date>", summary: "YYYY-MM-DD,默认今天" },
183
+ { flags: "--app <name>", summary: "仅统计指定应用" },
184
+ { flags: "--dim <dim>", summary: "date|app|sender|hour|all", default: "all" }
185
+ ]
186
+ },
111
187
  { name: "storage-path", summary: "打印 notifications 目录绝对路径" }
112
188
  ],
113
189
  shortcuts: [
@@ -121,18 +197,44 @@ var COMMAND_TREE = [
121
197
  summary: "通知同步给记忆系统 \uD83D\uDFE2",
122
198
  subcommands: [
123
199
  { name: "scan", summary: "扫描未处理通知,返回各日期待同步摘要" },
124
- { name: "fetch", summary: "获取指定日期未处理通知详情" },
125
- { name: "commit", summary: "标记指定日期当前批次处理完成" }
200
+ {
201
+ name: "fetch",
202
+ summary: "获取指定日期未处理通知详情",
203
+ options: [
204
+ { flags: "--date <YYYY-MM-DD>", summary: "目标日期(必填)" },
205
+ { flags: "--max-end-index <index>", summary: "本次快照允许读取的最大 endIndex" }
206
+ ]
207
+ },
208
+ {
209
+ name: "commit",
210
+ summary: "标记指定日期当前批次处理完成",
211
+ options: [
212
+ { flags: "--date <YYYY-MM-DD>", summary: "目标日期(必填)" },
213
+ { flags: "--end-index <index>", summary: "本批次 fetch 返回的 endIndex" }
214
+ ]
215
+ }
126
216
  ]
127
217
  },
128
218
  {
129
219
  name: "recording",
130
220
  summary: "录音管理 \uD83D\uDFE2/\uD83D\uDFE1",
131
221
  subcommands: [
132
- { name: "list", summary: "列出所有录音 \uD83D\uDFE2" },
222
+ {
223
+ name: "list",
224
+ summary: "列出所有录音 \uD83D\uDFE2",
225
+ options: [{ flags: "--status <status>", summary: "按传输状态过滤" }]
226
+ },
133
227
  { name: "status <id>", summary: "查看单条录音详情 \uD83D\uDFE2" },
134
228
  { name: "storage-path", summary: "打印录音存储目录绝对路径 \uD83D\uDFE2" },
135
- { name: "setup-asr", summary: "交互式配置 ASR 转写参数 \uD83D\uDFE2" }
229
+ {
230
+ name: "setup-asr",
231
+ summary: "交互式配置 ASR 转写参数 \uD83D\uDFE2",
232
+ options: [
233
+ { flags: "--provider <name>", summary: "volcengine | whisper-local | ..." },
234
+ { flags: "--api-key <key>", summary: "非交互模式直接传入" },
235
+ { flags: "--non-interactive", summary: "跳过向导,从参数构造配置" }
236
+ ]
237
+ }
136
238
  ],
137
239
  shortcuts: [{ name: "+latest", summary: "展示最新一条录音详情" }]
138
240
  },
@@ -140,9 +242,23 @@ var COMMAND_TREE = [
140
242
  name: "image",
141
243
  summary: "图片管理 \uD83D\uDFE2",
142
244
  subcommands: [
143
- { name: "list", summary: "列出所有图片" },
245
+ {
246
+ name: "list",
247
+ summary: "列出所有图片",
248
+ options: [
249
+ { flags: "--status <status>", summary: "syncing|synced|sync_failed" },
250
+ { flags: "--app <name>", summary: "按来源应用过滤" },
251
+ { flags: "--from <iso8601>", summary: "created_at 起" },
252
+ { flags: "--to <iso8601>", summary: "created_at 止" },
253
+ { flags: "--limit <n>", summary: "最大返回条数", default: "100" }
254
+ ]
255
+ },
144
256
  { name: "status <id>", summary: "查看单张图片详情" },
145
- { name: "path <id>", summary: "打印图片本地文件绝对路径" },
257
+ {
258
+ name: "path <id>",
259
+ summary: "打印图片本地文件绝对路径",
260
+ options: [{ flags: "--thumbnail", summary: "返回缩略图路径(若有)" }]
261
+ },
146
262
  { name: "storage-path", summary: "打印图片存储目录绝对路径" }
147
263
  ],
148
264
  shortcuts: [{ name: "+latest", summary: "展示最新一张图片详情" }]
@@ -151,7 +267,16 @@ var COMMAND_TREE = [
151
267
  name: "light",
152
268
  summary: "灯效硬件控制 \uD83D\uDFE1",
153
269
  subcommands: [
154
- { name: "send", summary: "发送灯效指令到硬件(--segments / --preset)" }
270
+ {
271
+ name: "send",
272
+ summary: "发送灯效指令到硬件(--segments / --preset)",
273
+ options: [
274
+ { flags: "--segments <json>", summary: "灯效参数 JSON" },
275
+ { flags: "--preset <name>", summary: "预设名,如 red-blink" },
276
+ { flags: "--repeat", summary: "无限循环播放" },
277
+ { flags: "--repeat-times <n>", summary: "整条组合重复次数(0=无限)" }
278
+ ]
279
+ }
155
280
  ],
156
281
  shortcuts: [{ name: "+blink", summary: "灯效连通性测试" }]
157
282
  },
@@ -161,9 +286,33 @@ var COMMAND_TREE = [
161
286
  subcommands: [
162
287
  { name: "list", summary: "列出所有规则及状态" },
163
288
  { name: "show <id>", summary: "查看单条规则详情" },
164
- { name: "create", summary: "创建规则(--from-file / --intent ...)" },
165
- { name: "update <id>", summary: "更新现有规则" },
166
- { name: "delete <id>", summary: "删除规则(--yes)" },
289
+ {
290
+ name: "create",
291
+ summary: "创建规则(--from-file / --intent ...)",
292
+ options: [
293
+ { flags: "--from-file <path>", summary: "从 JSON/YAML 读规则(- 为 stdin)" },
294
+ { flags: "--name <text>", summary: "规则名" },
295
+ { flags: "--intent <text>", summary: "自然语言意图描述" },
296
+ { flags: "--light-action <json>", summary: "命中后的 light 动作 JSON" },
297
+ { flags: "--match-rules <json>", summary: "前置硬过滤规则 JSON" }
298
+ ]
299
+ },
300
+ {
301
+ name: "update <id>",
302
+ summary: "更新现有规则",
303
+ options: [
304
+ { flags: "--from-file <path>", summary: "从 JSON/YAML 读规则(- 为 stdin)" },
305
+ { flags: "--name <text>", summary: "规则名" },
306
+ { flags: "--intent <text>", summary: "自然语言意图描述" },
307
+ { flags: "--light-action <json>", summary: "命中后的 light 动作 JSON" },
308
+ { flags: "--match-rules <json>", summary: "前置硬过滤规则 JSON" }
309
+ ]
310
+ },
311
+ {
312
+ name: "delete <id>",
313
+ summary: "删除规则(--yes)",
314
+ options: [{ flags: "--yes", summary: "跳过确认" }]
315
+ },
167
316
  { name: "enable <id>", summary: "启用单条规则" },
168
317
  { name: "disable <id>", summary: "停用单条规则" }
169
318
  ],
@@ -178,8 +327,20 @@ var COMMAND_TREE = [
178
327
  subcommands: [
179
328
  { name: "list", summary: "列出所有监控任务" },
180
329
  { name: "show <name>", summary: "查看监控任务详情" },
181
- { name: "create <name>", summary: "创建监控任务(cron 驱动)" },
182
- { name: "delete <name>", summary: "删除监控任务(--yes)" },
330
+ {
331
+ name: "create <name>",
332
+ summary: "创建监控任务(cron 驱动)",
333
+ options: [
334
+ { flags: "--description <text>", summary: "任务描述(必填)" },
335
+ { flags: "--match-rules <json>", summary: "匹配规则 JSON(必填)" },
336
+ { flags: "--schedule <cron>", summary: "cron 表达式(必填)" }
337
+ ]
338
+ },
339
+ {
340
+ name: "delete <name>",
341
+ summary: "删除监控任务(--yes)",
342
+ options: [{ flags: "--yes", summary: "跳过确认" }]
343
+ },
183
344
  { name: "enable <name>", summary: "启用监控任务" },
184
345
  { name: "disable <name>", summary: "暂停监控任务" }
185
346
  ]
@@ -197,53 +358,92 @@ var COMMAND_TREE = [
197
358
  name: "log",
198
359
  summary: "日志检索 \uD83D\uDFE2",
199
360
  args: "[keyword]",
361
+ options: [
362
+ { flags: "--from <date>", summary: "YYYY-MM-DD,默认 7 天前" },
363
+ { flags: "--to <date>", summary: "YYYY-MM-DD,默认今天" },
364
+ { flags: "--limit <n>", summary: "最大返回条数", default: "50" },
365
+ { flags: "--level <level>", summary: "过滤日志级别" }
366
+ ],
200
367
  shortcuts: [{ name: "+errors", summary: "昨天起的 error 级日志" }]
201
368
  },
202
369
  {
203
370
  name: "gateway",
204
371
  summary: "协议自检 \uD83D\uDFE2/\uD83D\uDFE1",
205
372
  subcommands: [
206
- { name: "test", summary: "模拟手机端调 daemon /notifications,验证连通/鉴权/relay \uD83D\uDFE1" }
373
+ {
374
+ name: "test",
375
+ summary: "模拟手机端调 daemon /notifications,验证连通/鉴权/relay \uD83D\uDFE1",
376
+ options: [
377
+ { flags: "--from-phone-ip <ip>", summary: "模拟来源 IP" },
378
+ { flags: "--via-relay", summary: "强制走 Relay 隧道" }
379
+ ]
380
+ }
207
381
  ]
208
382
  },
209
383
  {
210
384
  name: "api",
211
385
  summary: "Raw HTTP escape hatch \uD83D\uDFE1",
212
- args: "<method> <path>"
386
+ args: "<method> <path>",
387
+ options: [
388
+ { flags: "--data <json>", summary: "请求体(@file 读文件、- 读 stdin)" },
389
+ { flags: "--header <key:value>", summary: "追加 header(可重复)" }
390
+ ]
213
391
  },
214
392
  {
215
393
  name: "migrate",
216
394
  summary: "从 openclaw 插件迁移数据 \uD83D\uDFE2",
217
395
  subcommands: [
218
- { name: "from-openclaw", summary: "迁移 notifications/recordings/规则/api-key 到 ~/.yoooclaw" }
396
+ {
397
+ name: "from-openclaw",
398
+ summary: "迁移 notifications/recordings/规则/api-key 到 ~/.yoooclaw",
399
+ options: [
400
+ { flags: "--dry-run", summary: "只打印迁移计划,不写入" },
401
+ { flags: "--source <path>", summary: "自定义源目录,默认 ~/.openclaw" }
402
+ ]
403
+ }
219
404
  ]
220
405
  },
221
406
  {
222
407
  name: "update",
223
408
  summary: "版本检查 \uD83D\uDFE2",
224
409
  subcommands: [
225
- { name: "self", summary: "检查 npm 最新版本并提示(不自动更新)" }
410
+ {
411
+ name: "self",
412
+ summary: "检查 npm 最新版本并提示(不自动更新)",
413
+ options: [
414
+ { flags: "--beta", summary: "检查 beta channel" },
415
+ { flags: "--json", summary: "只输出版本信息 JSON" }
416
+ ]
417
+ }
226
418
  ]
227
419
  },
228
420
  {
229
421
  name: "doctor",
230
- summary: "环境自检:Node/目录/keychain/daemon/relay \uD83D\uDFE2/\uD83D\uDFE1"
422
+ summary: "环境自检:Node/目录/keychain/daemon/relay \uD83D\uDFE2/\uD83D\uDFE1",
423
+ options: [
424
+ { flags: "--json", summary: "JSON 输出(给脚本用)" },
425
+ { flags: "--fix", summary: "自动修复可修复的问题" }
426
+ ]
231
427
  }
232
428
  ];
233
429
 
234
430
  // src/context.ts
235
- var import_node_fs = require("node:fs");
431
+ var import_node_fs2 = require("node:fs");
236
432
 
237
433
  // src/paths.ts
238
434
  var exports_paths = {};
239
435
  __export(exports_paths, {
240
436
  sharedCredentialsPath: () => sharedCredentialsPath,
241
437
  rootDir: () => rootDir,
438
+ readActiveProfile: () => readActiveProfile,
439
+ profilesRoot: () => profilesRoot,
242
440
  profilePaths: () => profilePaths,
243
441
  profileDir: () => profileDir,
442
+ listProfileNames: () => listProfileNames,
244
443
  activeProfilePath: () => activeProfilePath,
245
444
  DEFAULT_PROFILE: () => DEFAULT_PROFILE
246
445
  });
446
+ var import_node_fs = require("node:fs");
247
447
  var import_node_os = require("node:os");
248
448
  var import_node_path = require("node:path");
249
449
  var DEFAULT_PROFILE = "default";
@@ -259,6 +459,22 @@ function activeProfilePath() {
259
459
  function profileDir(profile) {
260
460
  return import_node_path.join(rootDir(), "profiles", profile);
261
461
  }
462
+ function profilesRoot() {
463
+ return import_node_path.join(rootDir(), "profiles");
464
+ }
465
+ function listProfileNames() {
466
+ const root = profilesRoot();
467
+ if (!import_node_fs.existsSync(root))
468
+ return [];
469
+ return import_node_fs.readdirSync(root, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name).sort((a, b) => a.localeCompare(b));
470
+ }
471
+ function readActiveProfile() {
472
+ const file = activeProfilePath();
473
+ if (!import_node_fs.existsSync(file))
474
+ return;
475
+ const name = import_node_fs.readFileSync(file, "utf-8").trim();
476
+ return name || undefined;
477
+ }
262
478
  function profilePaths(profile) {
263
479
  const dir = profileDir(profile);
264
480
  return {
@@ -287,7 +503,14 @@ var ErrorCode = {
287
503
  CONFIG_INVALID: "YOOOCLAW_CONFIG_INVALID",
288
504
  PROFILE_NOT_FOUND: "YOOOCLAW_PROFILE_NOT_FOUND",
289
505
  IMAGE_NOT_READY: "YOOOCLAW_IMAGE_NOT_READY",
290
- NOT_FOUND: "YOOOCLAW_NOT_FOUND"
506
+ NOT_FOUND: "YOOOCLAW_NOT_FOUND",
507
+ ALREADY_EXISTS: "YOOOCLAW_ALREADY_EXISTS",
508
+ STORAGE_UNAVAILABLE: "YOOOCLAW_STORAGE_UNAVAILABLE",
509
+ CREDENTIAL_MISSING: "YOOOCLAW_CREDENTIAL_MISSING",
510
+ KEYCHAIN_UNAVAILABLE: "YOOOCLAW_KEYCHAIN_UNAVAILABLE",
511
+ NETWORK_ERROR: "YOOOCLAW_NETWORK_ERROR",
512
+ CONFIRMATION_REQUIRED: "YOOOCLAW_CONFIRMATION_REQUIRED",
513
+ NOT_INTERACTIVE: "YOOOCLAW_NOT_INTERACTIVE"
291
514
  };
292
515
 
293
516
  class YoooclawError extends Error {
@@ -393,8 +616,8 @@ function resolveActiveProfile(flagProfile) {
393
616
  if (envProfile)
394
617
  return envProfile;
395
618
  const file = activeProfilePath();
396
- if (import_node_fs.existsSync(file)) {
397
- const name = import_node_fs.readFileSync(file, "utf-8").trim();
619
+ if (import_node_fs2.existsSync(file)) {
620
+ const name = import_node_fs2.readFileSync(file, "utf-8").trim();
398
621
  if (name)
399
622
  return name;
400
623
  }
@@ -412,16 +635,16 @@ function buildContext(flags) {
412
635
  }
413
636
 
414
637
  // src/version.ts
415
- var import_node_fs2 = require("node:fs");
638
+ var import_node_fs3 = require("node:fs");
416
639
  function readBuildInjectedVersion() {
417
640
  if (false) {}
418
- const version = "0.0.1".trim();
641
+ const version = "0.0.2".trim();
419
642
  return version || undefined;
420
643
  }
421
644
  function readVersionFromPackageJson() {
422
645
  try {
423
646
  const packageJsonUrl = new URL("../package.json", "file:///Users/cobb/github/openclaw-plugin/packages/cli/src/version.ts");
424
- const packageJson = JSON.parse(import_node_fs2.readFileSync(packageJsonUrl, "utf-8"));
647
+ const packageJson = JSON.parse(import_node_fs3.readFileSync(packageJsonUrl, "utf-8"));
425
648
  const version = packageJson.version?.trim();
426
649
  return version || undefined;
427
650
  } catch {
@@ -430,47 +653,4137 @@ function readVersionFromPackageJson() {
430
653
  }
431
654
  var CLI_VERSION = readBuildInjectedVersion() ?? readVersionFromPackageJson() ?? "unknown";
432
655
 
433
- // src/program.ts
434
- function wrapAction(handler) {
435
- return async (...rawArgs) => {
436
- const command = rawArgs.at(-1);
437
- const opts = rawArgs.at(-2);
438
- const positionals = rawArgs.slice(0, -2);
439
- const globals = command.optsWithGlobals();
440
- let ctx;
656
+ // src/fs-utils.ts
657
+ var import_node_fs4 = require("node:fs");
658
+ var import_node_path2 = require("node:path");
659
+ var DIR_MODE = 448;
660
+ var SECRET_FILE_MODE = 384;
661
+ var CONFIG_FILE_MODE = 420;
662
+ function ensureDir(dir, mode = DIR_MODE) {
663
+ import_node_fs4.mkdirSync(dir, { recursive: true, mode });
664
+ try {
665
+ import_node_fs4.chmodSync(dir, mode);
666
+ } catch {}
667
+ }
668
+ function writeFileAtomic(filePath, contents, mode = CONFIG_FILE_MODE) {
669
+ ensureDir(import_node_path2.dirname(filePath));
670
+ const tmp = import_node_path2.join(import_node_path2.dirname(filePath), `.${Date.now()}-${Math.random().toString(36).slice(2)}.tmp`);
671
+ import_node_fs4.writeFileSync(tmp, contents, { mode });
672
+ import_node_fs4.renameSync(tmp, filePath);
673
+ try {
674
+ import_node_fs4.chmodSync(filePath, mode);
675
+ } catch {}
676
+ }
677
+ function writeJsonFile(filePath, data, mode = CONFIG_FILE_MODE) {
678
+ writeFileAtomic(filePath, JSON.stringify(data, null, 2) + `
679
+ `, mode);
680
+ }
681
+ function readJsonFile(filePath) {
682
+ if (!import_node_fs4.existsSync(filePath))
683
+ return;
684
+ let raw;
685
+ try {
686
+ raw = import_node_fs4.readFileSync(filePath, "utf-8");
687
+ } catch (err) {
688
+ throw new YoooclawError("YOOOCLAW_CONFIG_INVALID", `读取文件失败:${filePath}`, { cause: err instanceof Error ? err.message : String(err) });
689
+ }
690
+ try {
691
+ return JSON.parse(raw);
692
+ } catch {
693
+ throw new YoooclawError("YOOOCLAW_CONFIG_INVALID", `文件不是合法 JSON:${filePath}`, { hint: "可手动修复或删除后重新生成" });
694
+ }
695
+ }
696
+
697
+ // src/prompt.ts
698
+ var import_promises = require("node:readline/promises");
699
+ var import_node_process = require("node:process");
700
+ function isInteractive() {
701
+ return Boolean(import_node_process.stdin.isTTY && import_node_process.stdout.isTTY);
702
+ }
703
+ function ensureInteractive() {
704
+ if (!isInteractive()) {
705
+ throw new YoooclawError("YOOOCLAW_NOT_INTERACTIVE", "当前为非交互环境,无法进行交互式输入", { hint: "改用 --non-interactive --from-file,或在 TTY 中运行" });
706
+ }
707
+ }
708
+ async function ask(question, defaultValue) {
709
+ ensureInteractive();
710
+ const suffix = defaultValue ? ` [${defaultValue}]` : "";
711
+ const rl = import_promises.createInterface({ input: import_node_process.stdin, output: import_node_process.stdout });
712
+ try {
713
+ const answer = (await rl.question(`${question}${suffix}: `)).trim();
714
+ return answer || defaultValue || "";
715
+ } finally {
716
+ rl.close();
717
+ }
718
+ }
719
+ async function confirm(question, defaultYes = false) {
720
+ const hint = defaultYes ? "Y/n" : "y/N";
721
+ const answer = (await ask(`${question} (${hint})`)).toLowerCase();
722
+ if (!answer)
723
+ return defaultYes;
724
+ return answer === "y" || answer === "yes";
725
+ }
726
+ async function readStdin() {
727
+ const chunks = [];
728
+ for await (const chunk of import_node_process.stdin) {
729
+ chunks.push(Buffer.from(chunk));
730
+ }
731
+ return Buffer.concat(chunks).toString("utf-8");
732
+ }
733
+
734
+ // src/config/store.ts
735
+ var import_node_fs5 = require("node:fs");
736
+
737
+ // src/config/schema.ts
738
+ var CONFIG_VERSION = 1;
739
+ var DEFAULT_RELAY_URL = "wss://relay.yoooclaw.com/tunnel";
740
+ var DEFAULT_PORT = 18789;
741
+ var DEFAULT_BIND = "127.0.0.1";
742
+ var DEFAULT_IMAGE_MAX_BYTES = 20 * 1024 * 1024;
743
+ function defaultConfig(credentialsPath) {
744
+ return {
745
+ version: CONFIG_VERSION,
746
+ daemon: {
747
+ bind: DEFAULT_BIND,
748
+ port: DEFAULT_PORT,
749
+ logLevel: "info",
750
+ detach: true
751
+ },
752
+ auth: {
753
+ mode: "token",
754
+ tokenRef: `file:${credentialsPath}#gatewayToken`
755
+ },
756
+ relay: {
757
+ url: DEFAULT_RELAY_URL,
758
+ heartbeatSec: 10,
759
+ reconnectBackoffMs: 2000,
760
+ enabled: true
761
+ },
762
+ notification: {
763
+ retentionDays: null,
764
+ ignoredApps: []
765
+ },
766
+ lightRules: {
767
+ enabled: true
768
+ },
769
+ autoUpdate: {
770
+ enabled: true,
771
+ channel: "stable"
772
+ },
773
+ output: {
774
+ defaultFormat: "auto"
775
+ },
776
+ image: {
777
+ maxBytes: DEFAULT_IMAGE_MAX_BYTES
778
+ }
779
+ };
780
+ }
781
+ function defaultEvaluator(credentialsPath) {
782
+ return {
783
+ mode: "webhook",
784
+ webhookUrl: "",
785
+ webhookSecretRef: `file:${credentialsPath}#evaluatorSecret`,
786
+ timeoutMs: 5000,
787
+ retries: 1
788
+ };
789
+ }
790
+ var SECRET_REF_PATHS = [
791
+ "auth.tokenRef",
792
+ "lightRules.evaluator.webhookSecretRef"
793
+ ];
794
+
795
+ // src/config/store.ts
796
+ function isPlainObject(value) {
797
+ return typeof value === "object" && value !== null && !Array.isArray(value);
798
+ }
799
+ function deepMerge(target, source) {
800
+ if (!isPlainObject(source))
801
+ return target;
802
+ const out = isPlainObject(target) ? { ...target } : {};
803
+ for (const [key, value] of Object.entries(source)) {
804
+ const prev = out[key];
805
+ out[key] = isPlainObject(value) && isPlainObject(prev) ? deepMerge(prev, value) : value;
806
+ }
807
+ return out;
808
+ }
809
+ function configExists(paths) {
810
+ return import_node_fs5.existsSync(paths.config);
811
+ }
812
+ function loadConfig(paths) {
813
+ const base = defaultConfig(paths.credentials);
814
+ const stored = readJsonFile(paths.config);
815
+ if (!stored)
816
+ return base;
817
+ return deepMerge(base, stored);
818
+ }
819
+ function requireConfig(paths) {
820
+ if (!configExists(paths)) {
821
+ throw new YoooclawError("YOOOCLAW_CONFIG_INVALID", `profile \`${paths.profile}\` 尚未初始化`, { hint: "先运行 yoooclaw config init", checkedPaths: [paths.config] });
822
+ }
823
+ return loadConfig(paths);
824
+ }
825
+ function saveConfig(paths, config) {
826
+ writeJsonFile(paths.config, config, CONFIG_FILE_MODE);
827
+ }
828
+ function getByPath(obj, path) {
829
+ const segments = path.split(".");
830
+ let cursor = obj;
831
+ for (const seg of segments) {
832
+ if (!isPlainObject(cursor))
833
+ return;
834
+ cursor = cursor[seg];
835
+ }
836
+ return cursor;
837
+ }
838
+ function setByPath(obj, path, value) {
839
+ const segments = path.split(".");
840
+ let cursor = obj;
841
+ for (let i = 0;i < segments.length - 1; i += 1) {
842
+ const seg = segments[i];
843
+ if (!isPlainObject(cursor[seg]))
844
+ cursor[seg] = {};
845
+ cursor = cursor[seg];
846
+ }
847
+ cursor[segments.at(-1)] = value;
848
+ }
849
+ function unsetByPath(obj, path) {
850
+ const segments = path.split(".");
851
+ let cursor = obj;
852
+ for (let i = 0;i < segments.length - 1; i += 1) {
853
+ const seg = segments[i];
854
+ if (!isPlainObject(cursor[seg]))
855
+ return false;
856
+ cursor = cursor[seg];
857
+ }
858
+ const last = segments.at(-1);
859
+ if (!(last in cursor))
860
+ return false;
861
+ delete cursor[last];
862
+ return true;
863
+ }
864
+ var NUMBER_PATHS = new Set([
865
+ "daemon.port",
866
+ "relay.heartbeatSec",
867
+ "relay.reconnectBackoffMs",
868
+ "notification.retentionDays",
869
+ "lightRules.evaluator.timeoutMs",
870
+ "lightRules.evaluator.retries",
871
+ "image.maxBytes"
872
+ ]);
873
+ var BOOLEAN_PATHS = new Set([
874
+ "daemon.detach",
875
+ "relay.enabled",
876
+ "lightRules.enabled",
877
+ "autoUpdate.enabled"
878
+ ]);
879
+ var ARRAY_PATHS = new Set(["notification.ignoredApps"]);
880
+ function coerceConfigValue(path, raw) {
881
+ if (NUMBER_PATHS.has(path)) {
882
+ if (path === "notification.retentionDays" && (raw === "null" || raw === "")) {
883
+ return null;
884
+ }
885
+ const n = Number(raw);
886
+ if (!Number.isFinite(n)) {
887
+ throw new YoooclawError("YOOOCLAW_INVALID_ARGUMENT", `${path} 需要数字,收到:${raw}`);
888
+ }
889
+ return n;
890
+ }
891
+ if (BOOLEAN_PATHS.has(path)) {
892
+ if (raw === "true")
893
+ return true;
894
+ if (raw === "false")
895
+ return false;
896
+ throw new YoooclawError("YOOOCLAW_INVALID_ARGUMENT", `${path} 需要 true/false,收到:${raw}`);
897
+ }
898
+ if (ARRAY_PATHS.has(path)) {
899
+ return raw.split(",").map((s) => s.trim()).filter(Boolean);
900
+ }
901
+ return raw;
902
+ }
903
+ function maskConfig(config) {
904
+ const clone = structuredClone(config);
905
+ for (const path of SECRET_REF_PATHS) {
906
+ const value = getByPath(clone, path);
907
+ if (typeof value === "string" && value.startsWith("inline:")) {
908
+ setByPath(clone, path, "inline:****");
909
+ }
910
+ }
911
+ return clone;
912
+ }
913
+
914
+ // src/credentials/store.ts
915
+ var import_node_crypto = require("node:crypto");
916
+
917
+ // src/credentials/keychain.ts
918
+ var import_node_child_process = require("node:child_process");
919
+ var import_node_os2 = require("node:os");
920
+ function hasCommand(cmd) {
921
+ const probe = import_node_child_process.spawnSync(process.platform === "win32" ? "where" : "which", [cmd], {
922
+ encoding: "utf-8"
923
+ });
924
+ return probe.status === 0;
925
+ }
926
+ function keychainAvailable() {
927
+ switch (import_node_os2.platform()) {
928
+ case "darwin":
929
+ return hasCommand("security");
930
+ case "linux":
931
+ return hasCommand("secret-tool");
932
+ default:
933
+ return false;
934
+ }
935
+ }
936
+ function keychainGet(service, account) {
937
+ switch (import_node_os2.platform()) {
938
+ case "darwin": {
939
+ if (!hasCommand("security"))
940
+ return { available: false };
941
+ const r = import_node_child_process.spawnSync("security", ["find-generic-password", "-s", service, "-a", account, "-w"], { encoding: "utf-8" });
942
+ if (r.status !== 0)
943
+ return { available: true, value: undefined };
944
+ return { available: true, value: r.stdout.replace(/\n$/, "") };
945
+ }
946
+ case "linux": {
947
+ if (!hasCommand("secret-tool"))
948
+ return { available: false };
949
+ const r = import_node_child_process.spawnSync("secret-tool", ["lookup", "service", service, "account", account], { encoding: "utf-8" });
950
+ if (r.status !== 0)
951
+ return { available: true, value: undefined };
952
+ return { available: true, value: r.stdout.replace(/\n$/, "") };
953
+ }
954
+ default:
955
+ return { available: false };
956
+ }
957
+ }
958
+ function keychainSet(service, account, value) {
959
+ switch (import_node_os2.platform()) {
960
+ case "darwin": {
961
+ if (!hasCommand("security"))
962
+ return false;
963
+ const r = import_node_child_process.spawnSync("security", ["add-generic-password", "-U", "-s", service, "-a", account, "-w", value], { encoding: "utf-8" });
964
+ return r.status === 0;
965
+ }
966
+ case "linux": {
967
+ if (!hasCommand("secret-tool"))
968
+ return false;
969
+ const r = import_node_child_process.spawnSync("secret-tool", ["store", "--label", `${service}:${account}`, "service", service, "account", account], { encoding: "utf-8", input: value });
970
+ return r.status === 0;
971
+ }
972
+ default:
973
+ return false;
974
+ }
975
+ }
976
+
977
+ // src/credentials/refs.ts
978
+ var import_node_os3 = require("node:os");
979
+ var import_node_path3 = require("node:path");
980
+ function expandHome(path) {
981
+ if (path === "~")
982
+ return import_node_os3.homedir();
983
+ if (path.startsWith("~/"))
984
+ return import_node_path3.join(import_node_os3.homedir(), path.slice(2));
985
+ return path;
986
+ }
987
+ function parseFileRef(rest) {
988
+ const hashIdx = rest.lastIndexOf("#");
989
+ if (hashIdx < 0) {
990
+ throw new YoooclawError("YOOOCLAW_CONFIG_INVALID", `file: 引用缺少 #字段:${rest}`, { hint: "格式应为 file:<path>#<jsonField>" });
991
+ }
992
+ return {
993
+ path: expandHome(rest.slice(0, hashIdx)),
994
+ field: rest.slice(hashIdx + 1)
995
+ };
996
+ }
997
+ function parseKeychainRef(rest) {
998
+ const slashIdx = rest.indexOf("/");
999
+ if (slashIdx < 0) {
1000
+ throw new YoooclawError("YOOOCLAW_CONFIG_INVALID", `keychain: 引用缺少 /account:${rest}`, { hint: "格式应为 keychain:<service>/<account>" });
1001
+ }
1002
+ return {
1003
+ service: rest.slice(0, slashIdx),
1004
+ account: rest.slice(slashIdx + 1)
1005
+ };
1006
+ }
1007
+ function resolveRef(ref) {
1008
+ const colonIdx = ref.indexOf(":");
1009
+ if (colonIdx < 0) {
1010
+ throw new YoooclawError("YOOOCLAW_CONFIG_INVALID", `非法凭据引用:${ref}`, { hint: "支持 env: / file: / keychain: / inline:" });
1011
+ }
1012
+ const scheme = ref.slice(0, colonIdx);
1013
+ const rest = ref.slice(colonIdx + 1);
1014
+ switch (scheme) {
1015
+ case "env": {
1016
+ const value = process.env[rest]?.trim();
1017
+ return { source: "env", value: value || undefined, location: rest };
1018
+ }
1019
+ case "file": {
1020
+ const { path, field } = parseFileRef(rest);
1021
+ const data = readJsonFile(path);
1022
+ const value = data?.[field];
1023
+ return {
1024
+ source: "file",
1025
+ value: typeof value === "string" && value ? value : undefined,
1026
+ location: path
1027
+ };
1028
+ }
1029
+ case "keychain": {
1030
+ const { service, account } = parseKeychainRef(rest);
1031
+ const r = keychainGet(service, account);
1032
+ return { source: "keychain", value: r.value, location: `${service}/${account}` };
1033
+ }
1034
+ case "inline": {
1035
+ const decoded = Buffer.from(rest, "base64").toString("utf-8");
1036
+ return { source: "inline", value: decoded || undefined };
1037
+ }
1038
+ default:
1039
+ throw new YoooclawError("YOOOCLAW_CONFIG_INVALID", `不支持的凭据引用 scheme:${scheme}`, { hint: "支持 env: / file: / keychain: / inline:" });
1040
+ }
1041
+ }
1042
+ function writeRef(ref, value) {
1043
+ const colonIdx = ref.indexOf(":");
1044
+ const scheme = ref.slice(0, colonIdx);
1045
+ const rest = ref.slice(colonIdx + 1);
1046
+ switch (scheme) {
1047
+ case "file": {
1048
+ const { path, field } = parseFileRef(rest);
1049
+ const data = readJsonFile(path) ?? {};
1050
+ data[field] = value;
1051
+ writeJsonFile(path, data, SECRET_FILE_MODE);
1052
+ return { source: "file", value, location: path };
1053
+ }
1054
+ case "keychain": {
1055
+ const { service, account } = parseKeychainRef(rest);
1056
+ if (!keychainSet(service, account, value)) {
1057
+ throw new YoooclawError("YOOOCLAW_KEYCHAIN_UNAVAILABLE", `keychain 写入失败:${service}/${account}`, { hint: "当前平台可能没有可用的 keychain 工具,请改用 file: 引用" });
1058
+ }
1059
+ return { source: "keychain", value, location: `${service}/${account}` };
1060
+ }
1061
+ case "env":
1062
+ throw new YoooclawError("YOOOCLAW_INVALID_ARGUMENT", "env: 引用无法持久化写入;请改用 file: 或 keychain:");
1063
+ case "inline":
1064
+ throw new YoooclawError("YOOOCLAW_INVALID_ARGUMENT", "inline: 引用需直接写进 config,不通过 writeRef");
1065
+ default:
1066
+ throw new YoooclawError("YOOOCLAW_CONFIG_INVALID", `不支持的凭据引用 scheme:${scheme}`);
1067
+ }
1068
+ }
1069
+
1070
+ // src/credentials/store.ts
1071
+ var API_KEY_ENV = "YOOOCLAW_API_KEY";
1072
+ var KEYCHAIN_SERVICE = "yoooclaw";
1073
+ var KEYCHAIN_API_KEY_ACCOUNT = "api-key";
1074
+ var API_KEY_FIELD = "apiKey";
1075
+ function resolveApiKey() {
1076
+ const env = process.env[API_KEY_ENV]?.trim();
1077
+ if (env)
1078
+ return { value: env, source: "env", location: API_KEY_ENV };
1079
+ if (keychainAvailable()) {
1080
+ const r = keychainGet(KEYCHAIN_SERVICE, KEYCHAIN_API_KEY_ACCOUNT);
1081
+ if (r.value) {
1082
+ return { value: r.value, source: "keychain", location: `${KEYCHAIN_SERVICE}/${KEYCHAIN_API_KEY_ACCOUNT}` };
1083
+ }
1084
+ }
1085
+ const file = sharedCredentialsPath();
1086
+ const data = readJsonFile(file);
1087
+ const value = data?.[API_KEY_FIELD];
1088
+ if (typeof value === "string" && value) {
1089
+ return { value, source: "file", location: file };
1090
+ }
1091
+ return { source: "none" };
1092
+ }
1093
+ function setApiKey(key, useKeychain) {
1094
+ if (useKeychain) {
1095
+ if (!keychainSet(KEYCHAIN_SERVICE, KEYCHAIN_API_KEY_ACCOUNT, key)) {
1096
+ throw new YoooclawError("YOOOCLAW_KEYCHAIN_UNAVAILABLE", "当前平台无法写入 keychain,请去掉 --keychain 改写共享文件");
1097
+ }
1098
+ return { value: key, source: "keychain", location: `${KEYCHAIN_SERVICE}/${KEYCHAIN_API_KEY_ACCOUNT}` };
1099
+ }
1100
+ const file = sharedCredentialsPath();
1101
+ const data = readJsonFile(file) ?? {};
1102
+ data[API_KEY_FIELD] = key;
1103
+ writeJsonFile(file, data, SECRET_FILE_MODE);
1104
+ return { value: key, source: "file", location: file };
1105
+ }
1106
+ function resolveGatewayToken(config) {
1107
+ return resolveRef(config.auth.tokenRef);
1108
+ }
1109
+ function writeGatewayToken(config, value) {
1110
+ return writeRef(config.auth.tokenRef, value);
1111
+ }
1112
+ function generateToken(bytes = 32) {
1113
+ return import_node_crypto.randomBytes(bytes).toString("hex");
1114
+ }
1115
+
1116
+ // src/commands/config.ts
1117
+ function phoneSummary(ctx, config, token) {
1118
+ return {
1119
+ ok: true,
1120
+ profile: ctx.profile,
1121
+ daemon: {
1122
+ bind: config.daemon.bind,
1123
+ port: config.daemon.port,
1124
+ localUrl: `http://${config.daemon.bind}:${config.daemon.port}`
1125
+ },
1126
+ relay: { url: config.relay.url, enabled: config.relay.enabled },
1127
+ gatewayToken: token,
1128
+ configPath: ctx.paths.config,
1129
+ hint: "在手机 App 中填入上面的地址与 token;公网部署请用 cloudflared / tailscale serve 反代到 localUrl"
1130
+ };
1131
+ }
1132
+ async function configInit(ctx, _args, opts) {
1133
+ if (configExists(ctx.paths) && !opts.force) {
1134
+ throw new YoooclawError("YOOOCLAW_ALREADY_EXISTS", `profile \`${ctx.profile}\` 已初始化`, { hint: "加 --force 覆盖,或换一个 --profile", checkedPaths: [ctx.paths.config] });
1135
+ }
1136
+ ensureDir(ctx.paths.dir);
1137
+ let config = defaultConfig(ctx.paths.credentials);
1138
+ if (opts.nonInteractive || !isInteractive()) {
1139
+ if (!opts.fromFile) {
1140
+ throw new YoooclawError("YOOOCLAW_NOT_INTERACTIVE", "非交互模式必须提供 --from-file <config.json>(- 为 stdin)");
1141
+ }
1142
+ const raw = opts.fromFile === "-" ? await readStdin() : undefined;
1143
+ const imported = raw ? JSON.parse(raw) : readJsonFile(opts.fromFile);
1144
+ if (!imported) {
1145
+ throw new YoooclawError("YOOOCLAW_INVALID_ARGUMENT", `无法读取配置文件:${opts.fromFile}`);
1146
+ }
1147
+ config = deepMerge(config, imported);
1148
+ config.version = defaultConfig(ctx.paths.credentials).version;
1149
+ } else {
1150
+ config.daemon.bind = await ask("daemon 监听地址", config.daemon.bind);
1151
+ config.daemon.port = Number(await ask("daemon 监听端口", String(config.daemon.port)));
1152
+ config.relay.url = await ask("Relay 地址", config.relay.url);
1153
+ config.relay.enabled = await confirm("启用 Relay 隧道?", config.relay.enabled);
1154
+ const enableEval = await confirm("启用灯效 webhook 评估器?", false);
1155
+ if (enableEval) {
1156
+ const evaluator = defaultEvaluator(ctx.paths.credentials);
1157
+ evaluator.webhookUrl = await ask("评估器 webhook URL");
1158
+ config.lightRules.evaluator = evaluator;
1159
+ }
1160
+ }
1161
+ const token = generateToken();
1162
+ saveConfig(ctx.paths, config);
1163
+ writeGatewayToken(config, token);
1164
+ return phoneSummary(ctx, config, token);
1165
+ }
1166
+ async function configShow(ctx, _args, opts) {
1167
+ if (!configExists(ctx.paths)) {
1168
+ throw new YoooclawError("YOOOCLAW_CONFIG_INVALID", `profile \`${ctx.profile}\` 尚未初始化`, { hint: "先运行 yoooclaw config init", checkedPaths: [ctx.paths.config] });
1169
+ }
1170
+ const config = loadConfig(ctx.paths);
1171
+ if (opts.showSecrets) {
1172
+ if (!isInteractive()) {
1173
+ throw new YoooclawError("YOOOCLAW_NOT_INTERACTIVE", "--show-secrets 需要在 TTY 中运行");
1174
+ }
1175
+ if (!await confirm("确认明文输出敏感字段?", false)) {
1176
+ throw new YoooclawError("YOOOCLAW_CONFIRMATION_REQUIRED", "已取消");
1177
+ }
1178
+ return config;
1179
+ }
1180
+ return maskConfig(config);
1181
+ }
1182
+ function configSet(ctx, args, _opts) {
1183
+ const [key, value] = args;
1184
+ if (key === "version") {
1185
+ throw new YoooclawError("YOOOCLAW_INVALID_ARGUMENT", "version 字段不可修改");
1186
+ }
1187
+ const config = loadConfig(ctx.paths);
1188
+ setByPath(config, key, coerceConfigValue(key, value));
1189
+ saveConfig(ctx.paths, config);
1190
+ return { ok: true, key, value: getByPath(config, key) };
1191
+ }
1192
+ function configUnset(ctx, args, _opts) {
1193
+ const [key] = args;
1194
+ const config = loadConfig(ctx.paths);
1195
+ const removed = unsetByPath(config, key);
1196
+ if (removed)
1197
+ saveConfig(ctx.paths, config);
1198
+ return { ok: true, key, removed };
1199
+ }
1200
+
1201
+ // src/commands/profile.ts
1202
+ var import_node_fs6 = require("node:fs");
1203
+ function profileList(ctx) {
1204
+ const active = readActiveProfile() ?? DEFAULT_PROFILE;
1205
+ const names = listProfileNames();
1206
+ for (const implied of [active, DEFAULT_PROFILE]) {
1207
+ if (!names.includes(implied))
1208
+ names.push(implied);
1209
+ }
1210
+ return names.sort((a, b) => a.localeCompare(b)).map((name) => ({
1211
+ name,
1212
+ active: name === active,
1213
+ initialized: configExists(profilePaths(name))
1214
+ }));
1215
+ }
1216
+ function profileUse(_ctx, args) {
1217
+ const [name] = args;
1218
+ const paths = profilePaths(name);
1219
+ if (!import_node_fs6.existsSync(paths.dir)) {
1220
+ throw new YoooclawError("YOOOCLAW_PROFILE_NOT_FOUND", `profile \`${name}\` 不存在`, { hint: "用 yoooclaw profile create 新建", checkedPaths: [paths.dir] });
1221
+ }
1222
+ writeFileAtomic(activeProfilePath(), `${name}
1223
+ `);
1224
+ return { ok: true, active: name };
1225
+ }
1226
+ async function profileCreate(ctx, args, opts) {
1227
+ const [name] = args;
1228
+ const paths = profilePaths(name);
1229
+ if (configExists(paths)) {
1230
+ throw new YoooclawError("YOOOCLAW_ALREADY_EXISTS", `profile \`${name}\` 已存在`);
1231
+ }
1232
+ const subCtx = { ...ctx, profile: name, paths };
1233
+ return configInit(subCtx, [], opts);
1234
+ }
1235
+ async function profileDelete(ctx, args, opts) {
1236
+ const [name] = args;
1237
+ const active = readActiveProfile() ?? DEFAULT_PROFILE;
1238
+ if (name === active) {
1239
+ throw new YoooclawError("YOOOCLAW_INVALID_ARGUMENT", `不能删除当前 active profile \`${name}\``, { hint: "先 yoooclaw profile use <其他 profile> 再删除" });
1240
+ }
1241
+ const paths = profilePaths(name);
1242
+ if (!import_node_fs6.existsSync(paths.dir)) {
1243
+ throw new YoooclawError("YOOOCLAW_PROFILE_NOT_FOUND", `profile \`${name}\` 不存在`);
1244
+ }
1245
+ if (!opts.yes && !await confirm(`确认删除 profile \`${name}\` 及其全部数据?`, false)) {
1246
+ throw new YoooclawError("YOOOCLAW_CONFIRMATION_REQUIRED", "已取消");
1247
+ }
1248
+ import_node_fs6.rmSync(paths.dir, { recursive: true, force: true });
1249
+ return { ok: true, deleted: name };
1250
+ }
1251
+
1252
+ // src/daemon/lock.ts
1253
+ var import_node_fs7 = require("node:fs");
1254
+ function isProcessAlive(pid) {
1255
+ if (!Number.isInteger(pid) || pid <= 0)
1256
+ return false;
1257
+ try {
1258
+ process.kill(pid, 0);
1259
+ return true;
1260
+ } catch (err) {
1261
+ return err.code === "EPERM";
1262
+ }
1263
+ }
1264
+ function readLock(paths) {
1265
+ if (!import_node_fs7.existsSync(paths.daemonLock))
1266
+ return;
1267
+ return readJsonFile(paths.daemonLock);
1268
+ }
1269
+ function daemonState(paths) {
1270
+ const lock = readLock(paths);
1271
+ if (!lock)
1272
+ return { running: false, stale: false };
1273
+ const alive = isProcessAlive(lock.pid);
1274
+ return { running: alive, lock, stale: !alive };
1275
+ }
1276
+ function writeLock(paths, lock) {
1277
+ writeJsonFile(paths.daemonLock, lock);
1278
+ }
1279
+ function removeLock(paths) {
1280
+ import_node_fs7.rmSync(paths.daemonLock, { force: true });
1281
+ }
1282
+
1283
+ // src/daemon/client.ts
1284
+ class DaemonClient {
1285
+ paths;
1286
+ baseUrl;
1287
+ token;
1288
+ timeoutMs;
1289
+ constructor(paths, opts = {}) {
1290
+ this.paths = paths;
1291
+ const config = loadConfig(paths);
1292
+ const state = daemonState(paths);
1293
+ const bind = state.lock?.bind ?? config.daemon.bind;
1294
+ const host = bind === "0.0.0.0" ? "127.0.0.1" : bind;
1295
+ const port = state.lock?.port ?? config.daemon.port;
1296
+ this.baseUrl = opts.baseUrl ?? `http://${host}:${port}`;
1297
+ this.token = resolveGatewayToken(config).value;
1298
+ this.timeoutMs = opts.timeoutMs ?? 1e4;
1299
+ }
1300
+ async request(method, path, body, extraHeaders) {
1301
+ const headers = { ...extraHeaders };
1302
+ if (this.token)
1303
+ headers["Authorization"] = `Bearer ${this.token}`;
1304
+ let payload;
1305
+ if (body !== undefined) {
1306
+ headers["Content-Type"] ??= "application/json";
1307
+ payload = typeof body === "string" ? body : JSON.stringify(body);
1308
+ }
1309
+ const controller = new AbortController;
1310
+ const timer = setTimeout(() => controller.abort(), this.timeoutMs);
1311
+ let res;
441
1312
  try {
442
- ctx = buildContext(globals);
443
- const result = await handler(ctx, positionals, opts);
444
- renderResult(result, { format: ctx.format });
1313
+ res = await fetch(`${this.baseUrl}${path}`, {
1314
+ method,
1315
+ headers,
1316
+ body: payload,
1317
+ signal: controller.signal
1318
+ });
445
1319
  } catch (err) {
446
- const format = ctx?.format ?? "json";
447
- renderError(err, { format });
448
- process.exitCode = err instanceof YoooclawError ? err.exitCode : 1;
1320
+ const code = err.code;
1321
+ if (code === "ECONNREFUSED" || err.name === "AbortError") {
1322
+ throw new YoooclawError("YOOOCLAW_DAEMON_NOT_RUNNING", "daemon 未启动或无响应", { hint: "先执行 yoooclaw daemon start", baseUrl: this.baseUrl });
1323
+ }
1324
+ throw new YoooclawError("YOOOCLAW_NETWORK_ERROR", `调用 daemon 失败:${err.message}`);
1325
+ } finally {
1326
+ clearTimeout(timer);
449
1327
  }
450
- };
1328
+ const text = await res.text();
1329
+ let parsed = text;
1330
+ if (text) {
1331
+ try {
1332
+ parsed = JSON.parse(text);
1333
+ } catch {}
1334
+ }
1335
+ if (res.status === 401 || res.status === 403) {
1336
+ throw new YoooclawError("YOOOCLAW_UNAUTHORIZED", "daemon 拒绝鉴权(token 不一致)", {
1337
+ status: res.status
1338
+ });
1339
+ }
1340
+ return { status: res.status, body: parsed };
1341
+ }
1342
+ get(path) {
1343
+ return this.request("GET", path);
1344
+ }
1345
+ post(path, body) {
1346
+ return this.request("POST", path, body);
1347
+ }
451
1348
  }
452
- function stubHandler(commandPath) {
453
- return () => {
454
- throw notImplemented(commandPath);
1349
+ function assertDaemonRunning(paths) {
1350
+ const state = daemonState(paths);
1351
+ if (!state.running) {
1352
+ throw new YoooclawError("YOOOCLAW_DAEMON_NOT_RUNNING", "daemon 未运行", { hint: "先执行 yoooclaw daemon start", stale: state.stale });
1353
+ }
1354
+ }
1355
+
1356
+ // src/commands/auth.ts
1357
+ function mask(value) {
1358
+ if (!value)
1359
+ return;
1360
+ if (value.length <= 8)
1361
+ return "***";
1362
+ return `${value.slice(0, 4)}***${value.slice(-4)}`;
1363
+ }
1364
+ async function authSetApiKey(ctx, args, opts) {
1365
+ let [key] = args;
1366
+ if (key === "-") {
1367
+ key = (await readStdin()).trim();
1368
+ }
1369
+ if (!key) {
1370
+ throw new YoooclawError("YOOOCLAW_INVALID_ARGUMENT", "api-key 不能为空");
1371
+ }
1372
+ const result = setApiKey(key, Boolean(opts.keychain));
1373
+ return {
1374
+ ok: true,
1375
+ source: result.source,
1376
+ location: result.location,
1377
+ masked: mask(result.value),
1378
+ hint: "插件 / CLI / daemon 共用同一份;daemon 在跑时会经文件 watch 热生效"
455
1379
  };
456
1380
  }
457
- function attachService(program, spec) {
458
- const service = program.command(spec.name).description(spec.summary);
459
- if (!spec.subcommands || spec.subcommands.length === 0) {
460
- if (spec.args) {
461
- service.arguments(spec.args);
1381
+ function authStatus(ctx) {
1382
+ const apiKey = resolveApiKey();
1383
+ const config = configExists(ctx.paths) ? loadConfig(ctx.paths) : undefined;
1384
+ const token = config ? resolveGatewayToken(config) : undefined;
1385
+ const state = daemonState(ctx.paths);
1386
+ return {
1387
+ ok: true,
1388
+ profile: ctx.profile,
1389
+ apiKey: {
1390
+ present: Boolean(apiKey.value),
1391
+ source: apiKey.source,
1392
+ location: apiKey.location,
1393
+ masked: mask(apiKey.value)
1394
+ },
1395
+ gatewayToken: {
1396
+ present: Boolean(token?.value),
1397
+ source: token?.source,
1398
+ location: token?.location,
1399
+ masked: mask(token?.value)
1400
+ },
1401
+ daemon: {
1402
+ running: state.running,
1403
+ pid: state.lock?.pid,
1404
+ stale: state.stale
462
1405
  }
463
- service.action(wrapAction(stubHandler(spec.name)));
1406
+ };
1407
+ }
1408
+ function authTokenRotate(ctx, _args, opts) {
1409
+ if (!configExists(ctx.paths)) {
1410
+ throw new YoooclawError("YOOOCLAW_CONFIG_INVALID", `profile \`${ctx.profile}\` 尚未初始化`, {
1411
+ hint: "先运行 yoooclaw config init"
1412
+ });
1413
+ }
1414
+ const config = loadConfig(ctx.paths);
1415
+ const bytes = opts.length ? Number(opts.length) : 32;
1416
+ if (!Number.isInteger(bytes) || bytes < 16) {
1417
+ throw new YoooclawError("YOOOCLAW_INVALID_ARGUMENT", "--length 至少 16 字节");
1418
+ }
1419
+ const token = generateToken(bytes);
1420
+ const written = writeGatewayToken(config, token);
1421
+ const state = daemonState(ctx.paths);
1422
+ let hotReload;
1423
+ if (state.running) {
1424
+ hotReload = "daemon 在运行:请执行 yoooclaw daemon restart 使新 token 生效";
464
1425
  } else {
465
- for (const sub of spec.subcommands) {
466
- const path = `${spec.name} ${sub.name}`;
467
- service.command(sub.name).description(sub.summary).action(wrapAction(stubHandler(path)));
1426
+ hotReload = "daemon 未运行:下次启动即用新 token";
1427
+ }
1428
+ return {
1429
+ ok: true,
1430
+ token,
1431
+ source: written.source,
1432
+ location: written.location,
1433
+ hint: hotReload
1434
+ };
1435
+ }
1436
+ async function authCheck(ctx) {
1437
+ assertDaemonRunning(ctx.paths);
1438
+ const client = new DaemonClient(ctx.paths);
1439
+ const res = await client.get("/daemon/status");
1440
+ return {
1441
+ ok: res.status >= 200 && res.status < 300,
1442
+ profile: ctx.profile,
1443
+ daemonReachable: true,
1444
+ status: res.status,
1445
+ daemon: res.body
1446
+ };
1447
+ }
1448
+
1449
+ // src/notification/query.ts
1450
+ var import_node_fs11 = require("node:fs");
1451
+
1452
+ // ../phone-notifications/src/cli/helpers.ts
1453
+ var import_node_fs8 = require("node:fs");
1454
+ var import_promises2 = require("node:fs/promises");
1455
+ var import_node_path4 = require("node:path");
1456
+ function today() {
1457
+ return formatDate(new Date);
1458
+ }
1459
+ function formatDate(d) {
1460
+ const y = d.getFullYear();
1461
+ const m = String(d.getMonth() + 1).padStart(2, "0");
1462
+ const day = String(d.getDate()).padStart(2, "0");
1463
+ return `${y}-${m}-${day}`;
1464
+ }
1465
+ function daysAgo(n) {
1466
+ const d = new Date;
1467
+ d.setDate(d.getDate() - n);
1468
+ return formatDate(d);
1469
+ }
1470
+ function sortNotificationsByTimestampDesc(items) {
1471
+ return items.sort((a, b) => Date.parse(b.timestamp) - Date.parse(a.timestamp));
1472
+ }
1473
+ var APP_ALIAS_GROUPS = [
1474
+ ["微信", "wechat", "weixin", "com.tencent.mm", "com.tencent.xin"],
1475
+ ["企业微信", "wecom", "wxwork", "com.tencent.wework"],
1476
+ ["飞书", "feishu", "lark", "com.ss.android.lark", "com.bytedance.ee.lark", "com.larksuite.suite"],
1477
+ ["钉钉", "dingtalk", "com.alibaba.android.rimet"],
1478
+ ["qq", "腾讯qq", "com.tencent.mobileqq"]
1479
+ ];
1480
+ function normalizeLookupText(value) {
1481
+ return value.trim().toLowerCase();
1482
+ }
1483
+ function appAliasGroup(value) {
1484
+ const normalized = normalizeLookupText(value);
1485
+ if (!normalized)
1486
+ return null;
1487
+ return APP_ALIAS_GROUPS.find((group) => group.some((candidate) => normalizeLookupText(candidate) === normalized)) ?? null;
1488
+ }
1489
+ function matchesNotificationAppFilter(item, app) {
1490
+ const filter = normalizeLookupText(app);
1491
+ if (!filter)
1492
+ return true;
1493
+ const candidates = [item.appName, item.appDisplayName].filter((value) => typeof value === "string" && value.length > 0);
1494
+ if (candidates.some((candidate) => normalizeLookupText(candidate) === filter)) {
1495
+ return true;
1496
+ }
1497
+ const filterGroup = appAliasGroup(app);
1498
+ if (!filterGroup)
1499
+ return false;
1500
+ return candidates.some((candidate) => appAliasGroup(candidate) === filterGroup);
1501
+ }
1502
+ async function listDateKeysAsync(dir) {
1503
+ const pattern = /^(\d{4}-\d{2}-\d{2})\.json$/;
1504
+ const keys = [];
1505
+ const entries = await import_promises2.readdir(dir, { withFileTypes: true });
1506
+ for (const entry of entries) {
1507
+ if (!entry.isFile())
1508
+ continue;
1509
+ const m = pattern.exec(entry.name);
1510
+ if (m)
1511
+ keys.push(m[1]);
1512
+ }
1513
+ return keys.sort().reverse();
1514
+ }
1515
+ async function readDateFileAsync(dir, dateKey) {
1516
+ const filePath = import_node_path4.join(dir, `${dateKey}.json`);
1517
+ if (!import_node_fs8.existsSync(filePath))
1518
+ return [];
1519
+ try {
1520
+ const content = await import_promises2.readFile(filePath, "utf-8");
1521
+ return JSON.parse(content);
1522
+ } catch {
1523
+ return [];
1524
+ }
1525
+ }
1526
+ function readRecordingIndex(dir) {
1527
+ const indexPath = import_node_path4.join(dir, "index.json");
1528
+ if (!import_node_fs8.existsSync(indexPath))
1529
+ return [];
1530
+ try {
1531
+ const raw = JSON.parse(import_node_fs8.readFileSync(indexPath, "utf-8"));
1532
+ return Array.isArray(raw?.recordings) ? raw.recordings : [];
1533
+ } catch {
1534
+ return [];
1535
+ }
1536
+ }
1537
+
1538
+ // ../phone-notifications/src/cli/ntf-query.ts
1539
+ function matchesNotificationQuery(item, opts) {
1540
+ if (opts.app && !matchesNotificationAppFilter(item, opts.app))
1541
+ return false;
1542
+ if (opts.conversationType && item.conversationType !== opts.conversationType) {
1543
+ return false;
1544
+ }
1545
+ const senderHaystack = [item.senderName, item.title].filter((value) => typeof value === "string" && value.length > 0).join(`
1546
+ `);
1547
+ if (opts.sender && !senderHaystack.includes(opts.sender))
1548
+ return false;
1549
+ const keywordHaystack = [
1550
+ item.title,
1551
+ item.content,
1552
+ item.senderName,
1553
+ item.conversationName
1554
+ ].filter((value) => typeof value === "string" && value.length > 0).join(`
1555
+ `);
1556
+ if (opts.keyword && !keywordHaystack.includes(opts.keyword))
1557
+ return false;
1558
+ const itemTs = Date.parse(item.timestamp);
1559
+ if (Number.isNaN(itemTs))
1560
+ return false;
1561
+ if (opts.fromTs !== null && itemTs < opts.fromTs)
1562
+ return false;
1563
+ if (opts.toTs !== null && itemTs > opts.toTs)
1564
+ return false;
1565
+ return true;
1566
+ }
1567
+ async function collectMatchingNotifications(dir, opts) {
1568
+ const keys = await listDateKeysAsync(dir);
1569
+ const results = [];
1570
+ const canStopAfterLimit = opts.fromTs === null && opts.toTs === null;
1571
+ for (const dateKey of keys) {
1572
+ if (opts.fromDateKey && dateKey < opts.fromDateKey)
1573
+ continue;
1574
+ if (opts.toDateKey && dateKey > opts.toDateKey)
1575
+ continue;
1576
+ const items = sortNotificationsByTimestampDesc([
1577
+ ...await readDateFileAsync(dir, dateKey)
1578
+ ]);
1579
+ for (const item of items) {
1580
+ if (!matchesNotificationQuery(item, opts))
1581
+ continue;
1582
+ results.push(item);
1583
+ if (canStopAfterLimit && results.length >= opts.limit) {
1584
+ return results;
1585
+ }
468
1586
  }
469
1587
  }
470
- for (const shortcut of spec.shortcuts ?? []) {
471
- const path = `${spec.name} ${shortcut.name}`;
472
- service.command(shortcut.name).description(shortcut.summary).action(wrapAction(stubHandler(path)));
1588
+ return sortNotificationsByTimestampDesc(results).slice(0, opts.limit);
1589
+ }
1590
+ // ../phone-notifications/src/notification/storage.ts
1591
+ var import_node_fs9 = require("node:fs");
1592
+ var import_node_crypto2 = require("node:crypto");
1593
+ var import_node_path5 = require("node:path");
1594
+
1595
+ // ../phone-notifications/src/notification/feishu-normalize.ts
1596
+ function normalizeOptionalText(value) {
1597
+ if (typeof value !== "string") {
1598
+ return;
1599
+ }
1600
+ const trimmed = value.trim();
1601
+ return trimmed ? trimmed : undefined;
1602
+ }
1603
+ function isFeishuApp(appName) {
1604
+ const normalized = appName.trim().toLowerCase();
1605
+ if (!normalized) {
1606
+ return false;
1607
+ }
1608
+ return normalized === "飞书" || normalized === "feishu" || normalized === "lark" || normalized === "com.ss.android.lark" || normalized === "com.bytedance.ee.lark" || normalized === "com.larksuite.suite" || normalized.includes("feishu") || normalized.includes("lark") || normalized.includes("飞书");
1609
+ }
1610
+ function isFeishuAppLabel(text) {
1611
+ const t = text.trim().toLowerCase();
1612
+ return t === "飞书" || t === "lark" || t === "feishu";
1613
+ }
1614
+ function extractColonSender(body) {
1615
+ if (!body)
1616
+ return;
1617
+ const candidates = [body.indexOf(":"), body.indexOf(":")].filter((i) => i > 0);
1618
+ if (candidates.length === 0)
1619
+ return;
1620
+ const idx = Math.min(...candidates);
1621
+ const senderName = body.slice(0, idx).trim();
1622
+ return senderName || undefined;
1623
+ }
1624
+ function findMetadataTextByKey(value, targetKey, depth = 0) {
1625
+ if (!value || typeof value !== "object" || depth > 4) {
1626
+ return { found: false };
1627
+ }
1628
+ if (Array.isArray(value)) {
1629
+ for (const item of value) {
1630
+ const match = findMetadataTextByKey(item, targetKey, depth + 1);
1631
+ if (match.found) {
1632
+ return match;
1633
+ }
1634
+ }
1635
+ return { found: false };
1636
+ }
1637
+ for (const [key, child] of Object.entries(value)) {
1638
+ if (key.trim().toLowerCase() === targetKey) {
1639
+ return { found: true, value: normalizeOptionalText(child) };
1640
+ }
1641
+ }
1642
+ for (const child of Object.values(value)) {
1643
+ const match = findMetadataTextByKey(child, targetKey, depth + 1);
1644
+ if (match.found) {
1645
+ return match;
1646
+ }
1647
+ }
1648
+ return { found: false };
1649
+ }
1650
+ function deriveStructured(n) {
1651
+ const subtitle = findMetadataTextByKey(n.metadata, "subtitle");
1652
+ if (subtitle.found) {
1653
+ const senderName = normalizeOptionalText(n.title);
1654
+ const structured = {
1655
+ conversationType: subtitle.value ? "group" : "private"
1656
+ };
1657
+ if (senderName) {
1658
+ structured.senderName = senderName;
1659
+ }
1660
+ if (subtitle.value) {
1661
+ structured.conversationName = subtitle.value;
1662
+ }
1663
+ return structured;
1664
+ }
1665
+ const rawTitle = typeof n.title === "string" ? n.title : "";
1666
+ if (isFeishuAppLabel(rawTitle)) {
1667
+ const senderName = extractColonSender(n.body ?? "");
1668
+ if (senderName) {
1669
+ return {
1670
+ senderName,
1671
+ conversationType: "private"
1672
+ };
1673
+ }
473
1674
  }
1675
+ return {};
1676
+ }
1677
+ function buildTitle(n, structured) {
1678
+ if (structured.conversationType === "group" && structured.conversationName) {
1679
+ return structured.conversationName;
1680
+ }
1681
+ if (structured.conversationType === "private" && structured.senderName) {
1682
+ return structured.senderName;
1683
+ }
1684
+ return typeof n.title === "string" ? n.title : "";
1685
+ }
1686
+ function buildContent(n, structured) {
1687
+ const body = n.body?.trim();
1688
+ if (structured.conversationType === "group" && structured.senderName && body) {
1689
+ const half = `${structured.senderName}:`;
1690
+ const full = `${structured.senderName}:`;
1691
+ return body.startsWith(half) || body.startsWith(full) ? body : `${structured.senderName}: ${body}`;
1692
+ }
1693
+ if (structured.conversationType === "private" && structured.senderName && body) {
1694
+ const half = `${structured.senderName}:`;
1695
+ const full = `${structured.senderName}:`;
1696
+ if (body.startsWith(half))
1697
+ return body.slice(half.length).trimStart();
1698
+ if (body.startsWith(full))
1699
+ return body.slice(full.length).trimStart();
1700
+ return body;
1701
+ }
1702
+ return body ?? "";
1703
+ }
1704
+ function normalizeFeishuFields(n) {
1705
+ const appName = typeof n.app === "string" ? n.app : "";
1706
+ if (!isFeishuApp(appName)) {
1707
+ return;
1708
+ }
1709
+ const structured = deriveStructured(n);
1710
+ if (structured.senderName === undefined && structured.conversationType === undefined && structured.conversationName === undefined) {
1711
+ return;
1712
+ }
1713
+ return {
1714
+ title: buildTitle(n, structured),
1715
+ content: buildContent(n, structured),
1716
+ structured
1717
+ };
1718
+ }
1719
+
1720
+ // ../phone-notifications/src/notification/storage.ts
1721
+ var ID_INDEX_DIR_NAME = ".ids";
1722
+ var CONTENT_KEY_INDEX_DIR_NAME = ".keys";
1723
+ function computeLagMs(timestamp) {
1724
+ if (!timestamp)
1725
+ return null;
1726
+ const t = new Date(timestamp).getTime();
1727
+ if (Number.isNaN(t))
1728
+ return null;
1729
+ return Date.now() - t;
1730
+ }
1731
+ function buildFallbackContent(n) {
1732
+ const body = n.body?.trim();
1733
+ if (body) {
1734
+ return body;
1735
+ }
1736
+ const fallback = [];
1737
+ if (n.category) {
1738
+ fallback.push(`category:${n.category}`);
1739
+ }
1740
+ if (n.metadata && Object.keys(n.metadata).length > 0) {
1741
+ fallback.push(`metadata:${JSON.stringify(n.metadata)}`);
1742
+ }
1743
+ return fallback.join(" ; ") || "-";
1744
+ }
1745
+ class NotificationStorage {
1746
+ config;
1747
+ logger;
1748
+ dir;
1749
+ idIndexDir;
1750
+ contentKeyIndexDir;
1751
+ idCache = new Map;
1752
+ contentKeyCache = new Map;
1753
+ dateWriteChains = new Map;
1754
+ resolveDisplayName;
1755
+ constructor(dir, config, logger, resolveDisplayName) {
1756
+ this.config = config;
1757
+ this.logger = logger;
1758
+ this.dir = dir;
1759
+ this.idIndexDir = import_node_path5.join(dir, ID_INDEX_DIR_NAME);
1760
+ this.contentKeyIndexDir = import_node_path5.join(dir, CONTENT_KEY_INDEX_DIR_NAME);
1761
+ this.resolveDisplayName = resolveDisplayName;
1762
+ }
1763
+ async init() {
1764
+ import_node_fs9.mkdirSync(this.dir, { recursive: true });
1765
+ import_node_fs9.mkdirSync(this.idIndexDir, { recursive: true });
1766
+ import_node_fs9.rmSync(this.contentKeyIndexDir, { recursive: true, force: true });
1767
+ import_node_fs9.mkdirSync(this.contentKeyIndexDir, { recursive: true });
1768
+ }
1769
+ async ingest(items, ingestId) {
1770
+ const result = {
1771
+ received: items.length,
1772
+ ingested: 0,
1773
+ dedupedById: 0,
1774
+ dedupedByContent: 0,
1775
+ invalid: 0,
1776
+ inserted: []
1777
+ };
1778
+ const traceTag = ingestId ? `[${ingestId}]` : "";
1779
+ for (const n of items) {
1780
+ const outcome = await this.writeNotification(n);
1781
+ const lagMs = computeLagMs(n.timestamp);
1782
+ const lagPart = lagMs === null ? "lag=?" : `lag=${lagMs}ms`;
1783
+ this.logger.info(`ingest${traceTag}: app=${n.app ?? "Unknown"} id=${n.id ?? "-"} ts=${n.timestamp} ${lagPart} outcome=${outcome.kind}`);
1784
+ switch (outcome.kind) {
1785
+ case "ingested":
1786
+ result.ingested += 1;
1787
+ result.inserted.push(outcome.entry);
1788
+ break;
1789
+ case "dedupedById":
1790
+ result.dedupedById += 1;
1791
+ break;
1792
+ case "dedupedByContent":
1793
+ result.dedupedByContent += 1;
1794
+ break;
1795
+ case "invalid":
1796
+ result.invalid += 1;
1797
+ break;
1798
+ }
1799
+ }
1800
+ this.prune();
1801
+ return result;
1802
+ }
1803
+ async writeNotification(n) {
1804
+ const ts = new Date(n.timestamp);
1805
+ if (Number.isNaN(ts.getTime())) {
1806
+ this.logger.warn(`忽略非法 timestamp 的通知: ${n.id}`);
1807
+ return { kind: "invalid" };
1808
+ }
1809
+ const dateKey = this.formatDate(ts);
1810
+ const filePath = import_node_path5.join(this.dir, `${dateKey}.json`);
1811
+ const normalizedId = typeof n.id === "string" ? n.id.trim() : "";
1812
+ const entry = this.buildStoredNotification(n);
1813
+ return this.withDateWriteLock(dateKey, async () => {
1814
+ if (normalizedId && this.hasNotificationId(dateKey, normalizedId)) {
1815
+ return { kind: "dedupedById" };
1816
+ }
1817
+ if (this.hasNotificationContentKey(dateKey, filePath, entry)) {
1818
+ return { kind: "dedupedByContent" };
1819
+ }
1820
+ const appDisplayName = this.resolveDisplayName ? await this.resolveDisplayName(entry.appName) : entry.appName;
1821
+ const storedEntry = {
1822
+ ...entry,
1823
+ appDisplayName
1824
+ };
1825
+ const arr = this.readStoredNotifications(filePath);
1826
+ arr.push(storedEntry);
1827
+ import_node_fs9.writeFileSync(filePath, JSON.stringify(arr, null, 2), "utf-8");
1828
+ if (normalizedId) {
1829
+ this.recordNotificationId(dateKey, normalizedId);
1830
+ }
1831
+ this.recordNotificationContentKey(dateKey, filePath, storedEntry);
1832
+ return { kind: "ingested", entry: storedEntry };
1833
+ });
1834
+ }
1835
+ buildStoredNotification(n) {
1836
+ const appName = typeof n.app === "string" && n.app ? n.app : "Unknown";
1837
+ const feishu = normalizeFeishuFields(n);
1838
+ if (feishu) {
1839
+ return {
1840
+ appName,
1841
+ title: feishu.title,
1842
+ content: feishu.content || buildFallbackContent(n),
1843
+ timestamp: n.timestamp,
1844
+ ...feishu.structured
1845
+ };
1846
+ }
1847
+ return {
1848
+ appName,
1849
+ title: typeof n.title === "string" ? n.title : "",
1850
+ content: buildFallbackContent(n),
1851
+ timestamp: n.timestamp
1852
+ };
1853
+ }
1854
+ formatDate(d) {
1855
+ const year = d.getFullYear();
1856
+ const month = String(d.getMonth() + 1).padStart(2, "0");
1857
+ const day = String(d.getDate()).padStart(2, "0");
1858
+ return `${year}-${month}-${day}`;
1859
+ }
1860
+ getIdIndexPath(dateKey) {
1861
+ return import_node_path5.join(this.idIndexDir, `${dateKey}.ids`);
1862
+ }
1863
+ getIdSet(dateKey) {
1864
+ const cached = this.idCache.get(dateKey);
1865
+ if (cached) {
1866
+ return cached;
1867
+ }
1868
+ const idPath = this.getIdIndexPath(dateKey);
1869
+ const ids = new Set;
1870
+ if (import_node_fs9.existsSync(idPath)) {
1871
+ const lines = import_node_fs9.readFileSync(idPath, "utf-8").split(/\r?\n/);
1872
+ for (const line of lines) {
1873
+ const id = line.trim();
1874
+ if (id) {
1875
+ ids.add(id);
1876
+ }
1877
+ }
1878
+ }
1879
+ this.idCache.set(dateKey, ids);
1880
+ return ids;
1881
+ }
1882
+ hasNotificationId(dateKey, id) {
1883
+ return this.getIdSet(dateKey).has(id);
1884
+ }
1885
+ getContentKeyIndexPath(dateKey) {
1886
+ return import_node_path5.join(this.contentKeyIndexDir, `${dateKey}.keys`);
1887
+ }
1888
+ getContentKeySet(dateKey, filePath) {
1889
+ const cached = this.contentKeyCache.get(dateKey);
1890
+ if (cached) {
1891
+ return cached;
1892
+ }
1893
+ const keyPath = this.getContentKeyIndexPath(dateKey);
1894
+ const keys = new Set;
1895
+ if (import_node_fs9.existsSync(filePath)) {
1896
+ for (const item of this.readStoredNotifications(filePath)) {
1897
+ keys.add(this.buildNotificationContentKey(item));
1898
+ }
1899
+ }
1900
+ if (keys.size > 0) {
1901
+ import_node_fs9.writeFileSync(keyPath, `${Array.from(keys).join(`
1902
+ `)}
1903
+ `, "utf-8");
1904
+ } else if (import_node_fs9.existsSync(keyPath)) {
1905
+ import_node_fs9.rmSync(keyPath, { force: true });
1906
+ }
1907
+ this.contentKeyCache.set(dateKey, keys);
1908
+ return keys;
1909
+ }
1910
+ hasNotificationContentKey(dateKey, filePath, entry) {
1911
+ return this.getContentKeySet(dateKey, filePath).has(this.buildNotificationContentKey(entry));
1912
+ }
1913
+ recordNotificationId(dateKey, id) {
1914
+ const ids = this.getIdSet(dateKey);
1915
+ if (ids.has(id)) {
1916
+ return;
1917
+ }
1918
+ import_node_fs9.appendFileSync(this.getIdIndexPath(dateKey), `${id}
1919
+ `, "utf-8");
1920
+ ids.add(id);
1921
+ }
1922
+ recordNotificationContentKey(dateKey, filePath, entry) {
1923
+ const keys = this.getContentKeySet(dateKey, filePath);
1924
+ const key = this.buildNotificationContentKey(entry);
1925
+ if (keys.has(key)) {
1926
+ return;
1927
+ }
1928
+ import_node_fs9.appendFileSync(this.getContentKeyIndexPath(dateKey), `${key}
1929
+ `, "utf-8");
1930
+ keys.add(key);
1931
+ }
1932
+ buildNotificationContentKey(entry) {
1933
+ return import_node_crypto2.createHash("sha256").update(entry.appName).update("\x1F").update(entry.title).update("\x1F").update(entry.content).update("\x1F").update(entry.timestamp).digest("hex");
1934
+ }
1935
+ readStoredNotifications(filePath) {
1936
+ if (!import_node_fs9.existsSync(filePath)) {
1937
+ return [];
1938
+ }
1939
+ try {
1940
+ const parsed = JSON.parse(import_node_fs9.readFileSync(filePath, "utf-8"));
1941
+ return Array.isArray(parsed) ? parsed : [];
1942
+ } catch {
1943
+ return [];
1944
+ }
1945
+ }
1946
+ async withDateWriteLock(dateKey, task) {
1947
+ const previous = this.dateWriteChains.get(dateKey) ?? Promise.resolve();
1948
+ let release;
1949
+ const current = new Promise((resolve) => {
1950
+ release = resolve;
1951
+ });
1952
+ const chain = previous.then(() => current);
1953
+ this.dateWriteChains.set(dateKey, chain);
1954
+ await previous;
1955
+ try {
1956
+ return await task();
1957
+ } finally {
1958
+ release();
1959
+ if (this.dateWriteChains.get(dateKey) === chain) {
1960
+ this.dateWriteChains.delete(dateKey);
1961
+ }
1962
+ }
1963
+ }
1964
+ prune() {
1965
+ const retentionDays = this.config.retentionDays;
1966
+ if (retentionDays === undefined) {
1967
+ return;
1968
+ }
1969
+ const cutoffMs = Date.now() - retentionDays * 24 * 60 * 60 * 1000;
1970
+ const cutoffDate = this.formatDate(new Date(cutoffMs));
1971
+ this.pruneDataFiles(cutoffDate);
1972
+ this.pruneIdIndex(cutoffDate);
1973
+ this.pruneContentKeyIndex(cutoffDate);
1974
+ }
1975
+ pruneDataFiles(cutoffDate) {
1976
+ const dateFilePattern = /^(\d{4}-\d{2}-\d{2})\.(json|md)$/;
1977
+ const dateDirPattern = /^\d{4}-\d{2}-\d{2}$/;
1978
+ try {
1979
+ for (const entry of import_node_fs9.readdirSync(this.dir, { withFileTypes: true })) {
1980
+ if (entry.isFile()) {
1981
+ const match = dateFilePattern.exec(entry.name);
1982
+ if (match && match[1] < cutoffDate) {
1983
+ import_node_fs9.rmSync(import_node_path5.join(this.dir, entry.name), { force: true });
1984
+ }
1985
+ } else if (entry.isDirectory() && dateDirPattern.test(entry.name) && entry.name < cutoffDate) {
1986
+ import_node_fs9.rmSync(import_node_path5.join(this.dir, entry.name), { recursive: true, force: true });
1987
+ }
1988
+ }
1989
+ } catch {}
1990
+ }
1991
+ pruneIdIndex(cutoffDate) {
1992
+ try {
1993
+ for (const entry of import_node_fs9.readdirSync(this.idIndexDir, { withFileTypes: true })) {
1994
+ if (!entry.isFile())
1995
+ continue;
1996
+ const match = /^(\d{4}-\d{2}-\d{2})\.ids$/.exec(entry.name);
1997
+ if (match && match[1] < cutoffDate) {
1998
+ import_node_fs9.rmSync(import_node_path5.join(this.idIndexDir, entry.name), { force: true });
1999
+ this.idCache.delete(match[1]);
2000
+ }
2001
+ }
2002
+ } catch {}
2003
+ }
2004
+ pruneContentKeyIndex(cutoffDate) {
2005
+ try {
2006
+ for (const entry of import_node_fs9.readdirSync(this.contentKeyIndexDir, { withFileTypes: true })) {
2007
+ if (!entry.isFile())
2008
+ continue;
2009
+ const match = /^(\d{4}-\d{2}-\d{2})\.keys$/.exec(entry.name);
2010
+ if (match && match[1] < cutoffDate) {
2011
+ import_node_fs9.rmSync(import_node_path5.join(this.contentKeyIndexDir, entry.name), { force: true });
2012
+ this.contentKeyCache.delete(match[1]);
2013
+ }
2014
+ }
2015
+ } catch {}
2016
+ }
2017
+ async close() {
2018
+ this.idCache.clear();
2019
+ this.contentKeyCache.clear();
2020
+ this.dateWriteChains.clear();
2021
+ }
2022
+ }
2023
+ // ../phone-notifications/src/plugin/notifications.ts
2024
+ var import_node_crypto3 = require("node:crypto");
2025
+
2026
+ // ../phone-notifications/src/tunnel/http-proxy.ts
2027
+ var RELAY_INTERNAL_HTTP_HEADER = "x-openclaw-relay-internal";
2028
+
2029
+ // ../phone-notifications/src/plugin/shared.ts
2030
+ function readBody(req) {
2031
+ return new Promise((resolve, reject) => {
2032
+ const chunks = [];
2033
+ req.on("data", (chunk) => chunks.push(chunk));
2034
+ req.on("end", () => resolve(Buffer.concat(chunks).toString()));
2035
+ req.on("error", reject);
2036
+ });
2037
+ }
2038
+
2039
+ // ../phone-notifications/src/plugin/notifications.ts
2040
+ function newIngestId() {
2041
+ return `ing_${import_node_crypto3.randomBytes(4).toString("hex")}`;
2042
+ }
2043
+ function summarizeIncomingBatch(items) {
2044
+ const total = items.length;
2045
+ if (total === 0)
2046
+ return "items=0";
2047
+ const now = Date.now();
2048
+ let earliest = Number.POSITIVE_INFINITY;
2049
+ let latest = Number.NEGATIVE_INFINITY;
2050
+ for (const n of items) {
2051
+ const t = new Date(n.timestamp).getTime();
2052
+ if (Number.isNaN(t))
2053
+ continue;
2054
+ if (t < earliest)
2055
+ earliest = t;
2056
+ if (t > latest)
2057
+ latest = t;
2058
+ }
2059
+ if (!Number.isFinite(earliest)) {
2060
+ return `items=${total} (all ts invalid)`;
2061
+ }
2062
+ const maxLag = now - earliest;
2063
+ const minLag = now - latest;
2064
+ return `items=${total} earliestTs=${new Date(earliest).toISOString()} lag=${minLag}..${maxLag}ms`;
2065
+ }
2066
+ function createEmptyIngestResult() {
2067
+ return {
2068
+ received: 0,
2069
+ ingested: 0,
2070
+ dedupedById: 0,
2071
+ dedupedByContent: 0,
2072
+ invalid: 0,
2073
+ inserted: []
2074
+ };
2075
+ }
2076
+ function isRelayInternalHttpRequest(req) {
2077
+ const header = req.headers?.[RELAY_INTERNAL_HTTP_HEADER];
2078
+ if (Array.isArray(header)) {
2079
+ return header.includes("1");
2080
+ }
2081
+ return header === "1";
2082
+ }
2083
+ function toIngestResponse(result) {
2084
+ const { inserted: _inserted, ...rest } = result;
2085
+ return rest;
2086
+ }
2087
+ function registerNotificationInterfaces(deps) {
2088
+ const {
2089
+ api,
2090
+ logger,
2091
+ getStorage,
2092
+ filterNotifications,
2093
+ registerGatewayMethod,
2094
+ tunnelService,
2095
+ onAfterIngest
2096
+ } = deps;
2097
+ function triggerAfterIngest(inserted, ingestId) {
2098
+ if (inserted.length === 0 || !onAfterIngest)
2099
+ return;
2100
+ logger.info(`notifications[${ingestId}]: enqueue ${inserted.length} for light-rule evaluation`);
2101
+ Promise.resolve().then(() => onAfterIngest(inserted, ingestId)).catch((err) => logger.warn(`notifications[${ingestId}]: onAfterIngest failed: ${err?.message ?? err}`));
2102
+ }
2103
+ registerGatewayMethod("notifications.push", async ({ params, respond }) => {
2104
+ const storage = getStorage();
2105
+ if (!storage) {
2106
+ respond(false, null, {
2107
+ code: "NOT_READY",
2108
+ message: "Storage service not ready"
2109
+ });
2110
+ return;
2111
+ }
2112
+ const { items } = params;
2113
+ if (!Array.isArray(items)) {
2114
+ respond(false, null, {
2115
+ code: "INVALID_PARAMS",
2116
+ message: "items must be an array"
2117
+ });
2118
+ return;
2119
+ }
2120
+ const ingestId = newIngestId();
2121
+ const startMs = Date.now();
2122
+ logger.info(`notifications[${ingestId}]: gateway notifications.push received ${summarizeIncomingBatch(items)}`);
2123
+ const filtered = filterNotifications(items);
2124
+ if (filtered.length !== items.length) {
2125
+ logger.info(`notifications[${ingestId}]: filtered ${items.length - filtered.length} ignored-app item(s), kept=${filtered.length}`);
2126
+ }
2127
+ const result = filtered.length ? await storage.ingest(filtered, ingestId) : createEmptyIngestResult();
2128
+ logger.info(`notifications[${ingestId}]: ingest done in ${Date.now() - startMs}ms ` + `(received=${result.received} ingested=${result.ingested} ` + `dedupedById=${result.dedupedById} dedupedByContent=${result.dedupedByContent} invalid=${result.invalid})`);
2129
+ respond(true, toIngestResponse(result));
2130
+ triggerAfterIngest(result.inserted, ingestId);
2131
+ });
2132
+ api.registerHttpRoute({
2133
+ path: "/notifications",
2134
+ auth: "gateway",
2135
+ async handler(req, res) {
2136
+ if (req.method !== "POST") {
2137
+ res.writeHead(405, { "Content-Type": "application/json" });
2138
+ res.end(JSON.stringify({ ok: false, error: "Method Not Allowed" }));
2139
+ return;
2140
+ }
2141
+ if (!isRelayInternalHttpRequest(req)) {
2142
+ await tunnelService?.deactivateForExternalTunnel("POST /notifications");
2143
+ }
2144
+ const storage = getStorage();
2145
+ if (!storage) {
2146
+ res.writeHead(503, { "Content-Type": "application/json" });
2147
+ res.end(JSON.stringify({ ok: false, error: "Service Not Ready" }));
2148
+ return;
2149
+ }
2150
+ let body;
2151
+ try {
2152
+ const raw = await readBody(req);
2153
+ body = JSON.parse(raw);
2154
+ } catch {
2155
+ res.writeHead(400, { "Content-Type": "application/json" });
2156
+ res.end(JSON.stringify({ ok: false, error: "Invalid JSON" }));
2157
+ return;
2158
+ }
2159
+ if (!Array.isArray(body.notifications)) {
2160
+ res.writeHead(400, { "Content-Type": "application/json" });
2161
+ res.end(JSON.stringify({
2162
+ ok: false,
2163
+ error: "notifications must be an array"
2164
+ }));
2165
+ return;
2166
+ }
2167
+ const ingestId = newIngestId();
2168
+ const startMs = Date.now();
2169
+ logger.info(`notifications[${ingestId}]: HTTP POST /notifications received ${summarizeIncomingBatch(body.notifications)}`);
2170
+ const filtered = filterNotifications(body.notifications);
2171
+ if (filtered.length !== body.notifications.length) {
2172
+ logger.info(`notifications[${ingestId}]: filtered ${body.notifications.length - filtered.length} ignored-app item(s), kept=${filtered.length}`);
2173
+ }
2174
+ const result = filtered.length ? await storage.ingest(filtered, ingestId) : createEmptyIngestResult();
2175
+ logger.info(`notifications[${ingestId}]: ingest done in ${Date.now() - startMs}ms ` + `(received=${result.received} ingested=${result.ingested} ` + `dedupedById=${result.dedupedById} dedupedByContent=${result.dedupedByContent} invalid=${result.invalid})`);
2176
+ res.writeHead(200, { "Content-Type": "application/json" });
2177
+ res.end(JSON.stringify({ ok: true, ...toIngestResponse(result) }));
2178
+ triggerAfterIngest(result.inserted, ingestId);
2179
+ }
2180
+ });
2181
+ logger.info("Gateway 通知方法已注册: notifications.push");
2182
+ logger.info("HTTP 通知端点已注册: POST /notifications");
2183
+ }
2184
+ // ../phone-notifications/src/light-rules/storage.ts
2185
+ var import_node_fs10 = require("node:fs");
2186
+ var import_node_path6 = require("node:path");
2187
+
2188
+ // ../phone-notifications/src/light/repeat.ts
2189
+ function normalizeRepeatTimes(input) {
2190
+ if (typeof input === "boolean") {
2191
+ return input ? 0 : 1;
2192
+ }
2193
+ if (typeof input === "number") {
2194
+ return validateRepeatTimes(input);
2195
+ }
2196
+ if (!input) {
2197
+ return 1;
2198
+ }
2199
+ if (input.repeat_times !== undefined) {
2200
+ return validateRepeatTimes(input.repeat_times);
2201
+ }
2202
+ if (input.repeat !== undefined) {
2203
+ return input.repeat ? 0 : 1;
2204
+ }
2205
+ return 1;
2206
+ }
2207
+ function assertAncsRepeatTimes(repeatTimes) {
2208
+ if (repeatTimes !== 0 && repeatTimes !== 1) {
2209
+ throw new Error("当前 ANCS 路径仅支持 repeat_times=0(无限循环)或 1(播放一轮);N>=2 需非 ANCS 路径");
2210
+ }
2211
+ }
2212
+ function validateRepeatTimes(value) {
2213
+ if (!Number.isInteger(value) || value < 0) {
2214
+ throw new Error("repeat_times 必须是 >=0 的整数");
2215
+ }
2216
+ return value;
2217
+ }
2218
+
2219
+ // ../phone-notifications/src/light-rules/storage.ts
2220
+ function resolveBaseDir(ctx) {
2221
+ if (ctx.workspaceDir)
2222
+ return ctx.workspaceDir;
2223
+ if (ctx.stateDir) {
2224
+ const inferredWorkspaceDir = import_node_path6.join(ctx.stateDir, "workspace");
2225
+ if (import_node_fs10.existsSync(inferredWorkspaceDir))
2226
+ return inferredWorkspaceDir;
2227
+ return ctx.stateDir;
2228
+ }
2229
+ throw new Error("workspaceDir and stateDir both unavailable");
2230
+ }
2231
+ function tasksDir(ctx) {
2232
+ return import_node_path6.join(resolveBaseDir(ctx), "tasks");
2233
+ }
2234
+ function normalizeLightRuleLookupName(name) {
2235
+ return name.trim().replace(/\.json$/i, "");
2236
+ }
2237
+ function resolveLightRuleTask(ctx, name) {
2238
+ const dir = tasksDir(ctx);
2239
+ const normalizedName = normalizeLightRuleLookupName(name);
2240
+ if (!normalizedName)
2241
+ return null;
2242
+ const directTaskDir = import_node_path6.join(dir, normalizedName);
2243
+ const directMeta = readMeta(directTaskDir);
2244
+ if (directMeta) {
2245
+ return {
2246
+ taskDir: directTaskDir,
2247
+ meta: directMeta
2248
+ };
2249
+ }
2250
+ if (!import_node_fs10.existsSync(dir))
2251
+ return null;
2252
+ for (const entry of import_node_fs10.readdirSync(dir, { withFileTypes: true })) {
2253
+ if (!entry.isDirectory())
2254
+ continue;
2255
+ const taskDir = import_node_path6.join(dir, entry.name);
2256
+ const meta = readMeta(taskDir);
2257
+ if (meta?.name === normalizedName) {
2258
+ return {
2259
+ taskDir,
2260
+ meta
2261
+ };
2262
+ }
2263
+ }
2264
+ return null;
2265
+ }
2266
+ function readOptionalString(value) {
2267
+ if (typeof value !== "string")
2268
+ return;
2269
+ const trimmed = value.trim();
2270
+ return trimmed || undefined;
2271
+ }
2272
+ function readMeta(taskDir) {
2273
+ const metaPath = import_node_path6.join(taskDir, "meta.json");
2274
+ if (!import_node_fs10.existsSync(metaPath))
2275
+ return null;
2276
+ try {
2277
+ const raw = JSON.parse(import_node_fs10.readFileSync(metaPath, "utf-8"));
2278
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
2279
+ return null;
2280
+ if (raw.type !== "light-rule")
2281
+ return null;
2282
+ if (!Array.isArray(raw.segments))
2283
+ return null;
2284
+ const name = readOptionalString(raw.name) ?? import_node_path6.basename(taskDir);
2285
+ const title = readOptionalString(raw.title) ?? name;
2286
+ const description = readOptionalString(raw.description) ?? name;
2287
+ const createdAt = readOptionalString(raw.createdAt) ?? import_node_fs10.statSync(metaPath).birthtime.toISOString();
2288
+ const enabled = typeof raw.enabled === "boolean" ? raw.enabled : true;
2289
+ const repeatTimes = normalizeRepeatTimes({
2290
+ repeat: raw.repeat,
2291
+ repeat_times: raw.repeat_times
2292
+ });
2293
+ assertAncsRepeatTimes(repeatTimes);
2294
+ return {
2295
+ ...raw,
2296
+ name,
2297
+ title,
2298
+ type: "light-rule",
2299
+ description,
2300
+ segments: raw.segments,
2301
+ repeat_times: repeatTimes,
2302
+ enabled,
2303
+ createdAt
2304
+ };
2305
+ } catch {
2306
+ return null;
2307
+ }
2308
+ }
2309
+ function writeMeta(taskDir, meta) {
2310
+ import_node_fs10.writeFileSync(import_node_path6.join(taskDir, "meta.json"), JSON.stringify(meta, null, 2), "utf-8");
2311
+ }
2312
+ function listLightRules(ctx) {
2313
+ const dir = tasksDir(ctx);
2314
+ if (!import_node_fs10.existsSync(dir))
2315
+ return [];
2316
+ const rules = [];
2317
+ for (const entry of import_node_fs10.readdirSync(dir, { withFileTypes: true })) {
2318
+ if (!entry.isDirectory())
2319
+ continue;
2320
+ const meta = readMeta(import_node_path6.join(dir, entry.name));
2321
+ if (meta)
2322
+ rules.push(meta);
2323
+ }
2324
+ return rules;
2325
+ }
2326
+ function createLightRule(ctx, params) {
2327
+ const dir = tasksDir(ctx);
2328
+ const taskDir = import_node_path6.join(dir, params.name);
2329
+ if (import_node_fs10.existsSync(taskDir)) {
2330
+ throw new LightRuleError("ALREADY_EXISTS", `灯效规则 '${params.name}' 已存在`);
2331
+ }
2332
+ import_node_fs10.mkdirSync(taskDir, { recursive: true });
2333
+ const repeatTimes = normalizeRepeatTimes({
2334
+ repeat: params.repeat,
2335
+ repeat_times: params.repeat_times
2336
+ });
2337
+ assertAncsRepeatTimes(repeatTimes);
2338
+ const meta = {
2339
+ name: params.name,
2340
+ title: params.title,
2341
+ type: "light-rule",
2342
+ description: params.description,
2343
+ segments: params.segments,
2344
+ repeat_times: repeatTimes,
2345
+ enabled: true,
2346
+ createdAt: new Date().toISOString()
2347
+ };
2348
+ writeMeta(taskDir, meta);
2349
+ return { meta };
2350
+ }
2351
+ function updateLightRule(ctx, params) {
2352
+ const resolved = resolveLightRuleTask(ctx, params.name);
2353
+ const taskDir = resolved?.taskDir;
2354
+ const meta = resolved?.meta;
2355
+ if (!taskDir || !meta) {
2356
+ throw new LightRuleError("NOT_FOUND", `灯效规则 '${params.name}' 不存在`);
2357
+ }
2358
+ if (params.description !== undefined) {
2359
+ meta.description = params.description;
2360
+ }
2361
+ if (params.title !== undefined) {
2362
+ meta.title = params.title;
2363
+ }
2364
+ if (params.segments !== undefined) {
2365
+ meta.segments = params.segments;
2366
+ }
2367
+ if (params.repeat !== undefined || params.repeat_times !== undefined) {
2368
+ meta.repeat_times = normalizeRepeatTimes({
2369
+ repeat: params.repeat,
2370
+ repeat_times: params.repeat_times
2371
+ });
2372
+ assertAncsRepeatTimes(meta.repeat_times);
2373
+ }
2374
+ if (params.enabled !== undefined) {
2375
+ meta.enabled = params.enabled;
2376
+ }
2377
+ meta.updatedAt = new Date().toISOString();
2378
+ writeMeta(taskDir, meta);
2379
+ return { meta };
2380
+ }
2381
+ function deleteLightRule(ctx, name) {
2382
+ const resolved = resolveLightRuleTask(ctx, name);
2383
+ const taskDir = resolved?.taskDir;
2384
+ const meta = resolved?.meta;
2385
+ if (!taskDir || !meta) {
2386
+ throw new LightRuleError("NOT_FOUND", `灯效规则 '${name}' 不存在`);
2387
+ }
2388
+ import_node_fs10.rmSync(taskDir, { recursive: true, force: true });
2389
+ return { name: meta.name };
2390
+ }
2391
+
2392
+ class LightRuleError extends Error {
2393
+ code;
2394
+ constructor(code, message) {
2395
+ super(message);
2396
+ this.code = code;
2397
+ this.name = "LightRuleError";
2398
+ }
2399
+ }
2400
+
2401
+ // ../phone-notifications/src/light-rules/registry.ts
2402
+ class LightRuleRegistry {
2403
+ ctx;
2404
+ index = new Map;
2405
+ writeChain = Promise.resolve();
2406
+ constructor(ctx) {
2407
+ this.ctx = ctx;
2408
+ this.reload();
2409
+ }
2410
+ reload() {
2411
+ this.index.clear();
2412
+ for (const meta of listLightRules(this.ctx)) {
2413
+ this.index.set(meta.name, meta);
2414
+ }
2415
+ }
2416
+ list() {
2417
+ return Array.from(this.index.values());
2418
+ }
2419
+ getEnabled() {
2420
+ return this.list().filter((rule) => rule.enabled);
2421
+ }
2422
+ get(name) {
2423
+ return this.index.get(name) ?? null;
2424
+ }
2425
+ async create(params) {
2426
+ return this.runExclusive(() => {
2427
+ const result = createLightRule(this.ctx, params);
2428
+ this.index.set(result.meta.name, result.meta);
2429
+ return result;
2430
+ });
2431
+ }
2432
+ async update(params) {
2433
+ return this.runExclusive(() => {
2434
+ const result = updateLightRule(this.ctx, params);
2435
+ this.index.set(result.meta.name, result.meta);
2436
+ return result;
2437
+ });
2438
+ }
2439
+ async delete(name) {
2440
+ return this.runExclusive(() => {
2441
+ const result = deleteLightRule(this.ctx, name);
2442
+ this.index.delete(result.name);
2443
+ return result;
2444
+ });
2445
+ }
2446
+ buildSystemPrompt() {
2447
+ const enabled = this.getEnabled();
2448
+ if (enabled.length === 0) {
2449
+ return "当前没有启用任何灯效规则。";
2450
+ }
2451
+ const lines = [
2452
+ '你是一个判断"通知 → 灯效规则"是否命中的助手。',
2453
+ "",
2454
+ "下面是用户当前启用的全部灯效规则(按创建时间倒序):",
2455
+ ""
2456
+ ];
2457
+ const sorted = [...enabled].sort((a, b) => (b.createdAt ?? "").localeCompare(a.createdAt ?? ""));
2458
+ sorted.forEach((rule, idx) => {
2459
+ lines.push(`[${idx + 1}] title: ${rule.title}`, ` name: ${rule.name}`, ` description: ${rule.description}`, "");
2460
+ });
2461
+ lines.push('任务:根据用户接下来发给你的"通知"内容,判断哪些规则被命中。', "- 命中 0 条:不调用任何工具,直接结束。", "- 命中 1 条或多条:对每一条命中的规则调用一次 trigger_light(rule_name)。", "- 不要回复任何自由文本;判定结果**只**通过 tool calling 表达。", '- 拿不准的时候倾向于"不触发",避免骚扰用户。');
2462
+ return lines.join(`
2463
+ `);
2464
+ }
2465
+ runExclusive(fn) {
2466
+ const next = this.writeChain.then(() => fn(), () => fn());
2467
+ this.writeChain = next.catch(() => {
2468
+ return;
2469
+ });
2470
+ return next;
2471
+ }
2472
+ }
2473
+ // ../phone-notifications/src/light/validators.ts
2474
+ var VALID_MODES = [
2475
+ "wave",
2476
+ "breath",
2477
+ "strobe",
2478
+ "steady",
2479
+ "color_flow",
2480
+ "pixel_frame"
2481
+ ];
2482
+ var MAX_SEGMENTS = 12;
2483
+ function validateSegments(segments) {
2484
+ if (!Array.isArray(segments)) {
2485
+ return { valid: false, errors: [{ field: "segments", message: "必须是数组" }] };
2486
+ }
2487
+ if (segments.length === 0) {
2488
+ return { valid: false, errors: [{ field: "segments", message: "不能为空" }] };
2489
+ }
2490
+ if (segments.length > MAX_SEGMENTS) {
2491
+ return {
2492
+ valid: false,
2493
+ errors: [{ field: "segments", message: `最多 ${MAX_SEGMENTS} 段` }]
2494
+ };
2495
+ }
2496
+ const errors = [];
2497
+ const warnings = [];
2498
+ for (let i = 0;i < segments.length; i++) {
2499
+ validateSegment(segments[i], `segments[${i}]`, errors, warnings);
2500
+ }
2501
+ if (errors.length > 0)
2502
+ return { valid: false, errors };
2503
+ return { valid: true, segments, warnings };
2504
+ }
2505
+ function validateSegment(seg, prefix, errors, warnings) {
2506
+ if (!isRecord(seg)) {
2507
+ errors.push({ field: prefix, message: "必须是对象" });
2508
+ return;
2509
+ }
2510
+ const mode = seg.mode;
2511
+ if (!VALID_MODES.includes(mode)) {
2512
+ errors.push({
2513
+ field: `${prefix}.mode`,
2514
+ message: `不支持的模式 '${String(mode)}',可选:${VALID_MODES.join("/")}`
2515
+ });
2516
+ }
2517
+ validateNonNegativeNumber(seg.duration_s, `${prefix}.duration_s`, errors, "必须是 ≥0 的数字(0 表示无限时长)");
2518
+ switch (mode) {
2519
+ case "wave":
2520
+ validateForegroundSegment(seg, prefix, errors);
2521
+ validateOptionalNonNegativeNumber(seg.interval_ms, `${prefix}.interval_ms`, errors);
2522
+ validateOptionalDirection(seg.direction, `${prefix}.direction`, errors);
2523
+ validateOptionalWindow(seg.window, `${prefix}.window`, errors);
2524
+ validateOptionalBackground(seg.background, `${prefix}.background`, errors);
2525
+ break;
2526
+ case "color_flow":
2527
+ validateForegroundSegment(seg, prefix, errors);
2528
+ validateOptionalNonNegativeNumber(seg.interval_ms, `${prefix}.interval_ms`, errors);
2529
+ validateOptionalDirection(seg.direction, `${prefix}.direction`, errors);
2530
+ validateOptionalWindow(seg.window, `${prefix}.window`, errors);
2531
+ validateOptionalBackground(seg.background, `${prefix}.background`, errors);
2532
+ if (!hasNonZeroRgb(seg.color) && !hasNonZeroRgb(seg.background)) {
2533
+ errors.push({
2534
+ field: prefix,
2535
+ message: "color_flow 至少需要一组非零颜色锚点(color 或 background)"
2536
+ });
2537
+ }
2538
+ detectColorFlowSingleAnchorMisuse(seg, prefix, warnings);
2539
+ break;
2540
+ case "breath":
2541
+ validateForegroundSegment(seg, prefix, errors);
2542
+ validateOptionalBreathTiming(seg.breath_timing, `${prefix}.breath_timing`, errors);
2543
+ break;
2544
+ case "strobe":
2545
+ validateForegroundSegment(seg, prefix, errors);
2546
+ validateOptionalNonNegativeNumber(seg.interval_ms, `${prefix}.interval_ms`, errors);
2547
+ break;
2548
+ case "steady":
2549
+ validateForegroundSegment(seg, prefix, errors);
2550
+ break;
2551
+ case "pixel_frame":
2552
+ validatePixelFrame(seg.pixels, `${prefix}.pixels`, errors);
2553
+ break;
2554
+ default:
2555
+ validateOptionalNonNegativeNumber(seg.brightness, `${prefix}.brightness`, errors);
2556
+ validateOptionalColor(seg.color, `${prefix}.color`, errors);
2557
+ validateOptionalNonNegativeNumber(seg.interval_ms, `${prefix}.interval_ms`, errors);
2558
+ validateOptionalDirection(seg.direction, `${prefix}.direction`, errors);
2559
+ validateOptionalWindow(seg.window, `${prefix}.window`, errors);
2560
+ validateOptionalBreathTiming(seg.breath_timing, `${prefix}.breath_timing`, errors);
2561
+ validateOptionalBackground(seg.background, `${prefix}.background`, errors);
2562
+ }
2563
+ }
2564
+ function validateForegroundSegment(seg, prefix, errors) {
2565
+ validateNumberInRange(seg.brightness, `${prefix}.brightness`, errors, 0, 255, "必须是 0–255 的数字");
2566
+ validateColor(seg.color, `${prefix}.color`, errors);
2567
+ if (seg.mode !== "steady" && seg.brightness === 0) {
2568
+ errors.push({
2569
+ field: `${prefix}.brightness`,
2570
+ message: "brightness=0 仅 steady 模式允许;其它模式会在固件侧被过滤"
2571
+ });
2572
+ }
2573
+ }
2574
+ function validatePixelFrame(value, field, errors) {
2575
+ if (!Array.isArray(value)) {
2576
+ errors.push({ field, message: "pixel_frame 必须提供 pixels 数组(1–7 项)" });
2577
+ return;
2578
+ }
2579
+ if (value.length < 1 || value.length > 7) {
2580
+ errors.push({ field, message: "pixels 必须为 1–7 项" });
2581
+ }
2582
+ const seen = new Set;
2583
+ for (let i = 0;i < value.length; i++) {
2584
+ const pixel = value[i];
2585
+ const prefix = `${field}[${i}]`;
2586
+ if (!isRecord(pixel)) {
2587
+ errors.push({ field: prefix, message: "必须是对象" });
2588
+ continue;
2589
+ }
2590
+ const idx = pixel.index;
2591
+ if (!Number.isInteger(idx) || idx < 0 || idx > 6) {
2592
+ errors.push({ field: `${prefix}.index`, message: "index 必须是 0–6 的整数" });
2593
+ } else if (seen.has(idx)) {
2594
+ errors.push({ field: `${prefix}.index`, message: `index=${idx} 重复` });
2595
+ } else {
2596
+ seen.add(idx);
2597
+ }
2598
+ validateNumberInRange(pixel.brightness, `${prefix}.brightness`, errors, 0, 255, "必须是 0–255 的数字");
2599
+ validateColor(pixel.color, `${prefix}.color`, errors);
2600
+ }
2601
+ }
2602
+ function validateOptionalBreathTiming(value, field, errors) {
2603
+ if (value === undefined)
2604
+ return;
2605
+ if (!isRecord(value)) {
2606
+ errors.push({ field, message: "必须是对象" });
2607
+ return;
2608
+ }
2609
+ validatePositiveNumber(value.rise_ms, `${field}.rise_ms`, errors, "rise_ms 必须是 >0 的数字(不支持 0ms)");
2610
+ validateNonNegativeNumber(value.hold_ms, `${field}.hold_ms`, errors, "hold_ms 必须是 ≥0 的数字");
2611
+ validatePositiveNumber(value.fall_ms, `${field}.fall_ms`, errors, "fall_ms 必须是 >0 的数字(不支持 0ms)");
2612
+ validateNonNegativeNumber(value.off_ms, `${field}.off_ms`, errors, "off_ms 必须是 ≥0 的数字");
2613
+ }
2614
+ function validateOptionalBackground(value, field, errors) {
2615
+ if (value === undefined)
2616
+ return;
2617
+ if (!isRecord(value)) {
2618
+ errors.push({ field, message: "必须包含 r/g/b/brightness 数值" });
2619
+ return;
2620
+ }
2621
+ validateColor(value, field, errors);
2622
+ validateNumberInRange(value.brightness, `${field}.brightness`, errors, 0, 255, "必须是 0–255 的数字");
2623
+ }
2624
+ function validateOptionalColor(value, field, errors) {
2625
+ if (value === undefined)
2626
+ return;
2627
+ validateColor(value, field, errors);
2628
+ }
2629
+ function validateColor(value, field, errors) {
2630
+ if (!isRecord(value)) {
2631
+ errors.push({ field, message: "必须包含 r/g/b 数值" });
2632
+ return;
2633
+ }
2634
+ validateNumberInRange(value.r, `${field}.r`, errors, 0, 255, "必须是 0–255 的数字");
2635
+ validateNumberInRange(value.g, `${field}.g`, errors, 0, 255, "必须是 0–255 的数字");
2636
+ validateNumberInRange(value.b, `${field}.b`, errors, 0, 255, "必须是 0–255 的数字");
2637
+ }
2638
+ function validateOptionalDirection(value, field, errors) {
2639
+ if (value === undefined)
2640
+ return;
2641
+ if (value !== "ltr" && value !== "rtl") {
2642
+ errors.push({ field, message: "direction 必须是 ltr 或 rtl" });
2643
+ }
2644
+ }
2645
+ function validateOptionalWindow(value, field, errors) {
2646
+ if (value === undefined)
2647
+ return;
2648
+ if (value !== 1 && value !== 2 && value !== 3) {
2649
+ errors.push({ field, message: "window 仅支持 1/2/3" });
2650
+ }
2651
+ }
2652
+ function validateOptionalNonNegativeNumber(value, field, errors) {
2653
+ if (value === undefined)
2654
+ return;
2655
+ validateNonNegativeNumber(value, field, errors, "必须是 ≥0 的数字");
2656
+ }
2657
+ function validatePositiveNumber(value, field, errors, message) {
2658
+ if (value === undefined)
2659
+ return;
2660
+ if (!isFiniteNumber(value) || value <= 0) {
2661
+ errors.push({ field, message });
2662
+ }
2663
+ }
2664
+ function validateNonNegativeNumber(value, field, errors, message) {
2665
+ if (!isFiniteNumber(value) || value < 0) {
2666
+ errors.push({ field, message });
2667
+ }
2668
+ }
2669
+ function validateNumberInRange(value, field, errors, min, max, message) {
2670
+ if (!isFiniteNumber(value) || value < min || value > max) {
2671
+ errors.push({ field, message });
2672
+ }
2673
+ }
2674
+ function hasNonZeroRgb(value) {
2675
+ if (!value)
2676
+ return false;
2677
+ return [value.r, value.g, value.b].some((channel) => isFiniteNumber(channel) && channel > 0);
2678
+ }
2679
+ function detectColorFlowSingleAnchorMisuse(seg, prefix, warnings) {
2680
+ const color = seg.color;
2681
+ const background = seg.background;
2682
+ const fgChannels = extractChannels(color);
2683
+ const bgChannels = extractChannels(background);
2684
+ if (!fgChannels)
2685
+ return;
2686
+ const bgBrightnessRaw = background?.brightness;
2687
+ const bgBrightness = isFiniteNumber(bgBrightnessRaw) ? bgBrightnessRaw : 0;
2688
+ const bgActive = !!bgChannels && bgChannels.some((c) => c > 0) && bgBrightness > 0;
2689
+ if (bgActive)
2690
+ return;
2691
+ const fgActiveChannels = fgChannels.filter((c) => c > 0);
2692
+ if (fgActiveChannels.length !== 1)
2693
+ return;
2694
+ if (fgActiveChannels[0] < 192)
2695
+ return;
2696
+ warnings.push({
2697
+ field: prefix,
2698
+ code: "COLOR_FLOW_SINGLE_ANCHOR_MISUSE",
2699
+ message: "color_flow 仅设置了单一极端纯色前景锚点(无有效底色锚点),实际效果是同色系亮暗环状流动,不是多色调色板流动。" + `若用户期望的是"单色波浪",请改用 mode='wave';若期望多色流动,请同时设置 background 作为第二锚点。`
2700
+ });
2701
+ }
2702
+ function extractChannels(value) {
2703
+ if (!value)
2704
+ return null;
2705
+ const r = isFiniteNumber(value.r) ? value.r : 0;
2706
+ const g = isFiniteNumber(value.g) ? value.g : 0;
2707
+ const b = isFiniteNumber(value.b) ? value.b : 0;
2708
+ return [r, g, b];
2709
+ }
2710
+ function isFiniteNumber(value) {
2711
+ return typeof value === "number" && Number.isFinite(value);
2712
+ }
2713
+ function isRecord(value) {
2714
+ return value !== null && typeof value === "object" && !Array.isArray(value);
2715
+ }
2716
+
2717
+ // ../phone-notifications/src/light-rules/names.ts
2718
+ var LIGHT_RULE_GATEWAY_METHODS = {
2719
+ list: "lightrules.list",
2720
+ create: "lightrules.create",
2721
+ update: "lightrules.update",
2722
+ delete: "lightrules.delete"
2723
+ };
2724
+ var LIGHT_RULE_GATEWAY_METHOD_LIST = [
2725
+ LIGHT_RULE_GATEWAY_METHODS.list,
2726
+ LIGHT_RULE_GATEWAY_METHODS.create,
2727
+ LIGHT_RULE_GATEWAY_METHODS.update,
2728
+ LIGHT_RULE_GATEWAY_METHODS.delete
2729
+ ];
2730
+ var LIGHT_RULE_TOOL_NAMES = {
2731
+ list: "lightrules_list",
2732
+ create: "lightrules_create",
2733
+ update: "lightrules_update",
2734
+ delete: "lightrules_delete"
2735
+ };
2736
+ var LIGHT_RULE_TOOL_NAME_LIST = [
2737
+ LIGHT_RULE_TOOL_NAMES.list,
2738
+ LIGHT_RULE_TOOL_NAMES.create,
2739
+ LIGHT_RULE_TOOL_NAMES.update,
2740
+ LIGHT_RULE_TOOL_NAMES.delete
2741
+ ];
2742
+
2743
+ // ../phone-notifications/src/light-rules/gateway.ts
2744
+ function resolveRuleIdentifier(params) {
2745
+ if (!params || typeof params !== "object")
2746
+ return;
2747
+ const raw = params;
2748
+ const candidates = [raw.name, raw.id, raw.ruleId, raw.ruleName];
2749
+ for (const candidate of candidates) {
2750
+ if (typeof candidate !== "string")
2751
+ continue;
2752
+ const normalized = candidate.trim().replace(/\.json$/i, "");
2753
+ if (normalized)
2754
+ return normalized;
2755
+ }
2756
+ return;
2757
+ }
2758
+ function registerLightRulesGateway(api, registry, logger, rememberBroadcast) {
2759
+ const registerGatewayMethodWithBroadcastCapture = (method, handler) => {
2760
+ api.registerGatewayMethod(method, (opts) => {
2761
+ rememberBroadcast?.(opts.context?.broadcast);
2762
+ return handler(opts);
2763
+ });
2764
+ };
2765
+ registerGatewayMethodWithBroadcastCapture(LIGHT_RULE_GATEWAY_METHODS.list, async ({ respond }) => {
2766
+ try {
2767
+ registry.reload();
2768
+ const rules = registry.list().map((rule) => ({
2769
+ ...rule,
2770
+ id: rule.name
2771
+ }));
2772
+ respond(true, { ok: true, rules });
2773
+ } catch (err) {
2774
+ logger.warn(`${LIGHT_RULE_GATEWAY_METHODS.list} failed: ${err?.message}`);
2775
+ respond(false, null, {
2776
+ code: "INTERNAL_ERROR",
2777
+ message: err?.message ?? "Unknown error"
2778
+ });
2779
+ }
2780
+ });
2781
+ registerGatewayMethodWithBroadcastCapture(LIGHT_RULE_GATEWAY_METHODS.create, async ({ params, respond }) => {
2782
+ const { name, title, description, segments, repeat, repeat_times } = params;
2783
+ const resolvedTitle = typeof title === "string" && title.trim() ? title.trim() : name;
2784
+ if (!name || typeof name !== "string") {
2785
+ respond(false, null, { code: "INVALID_PARAMS", message: "name is required" });
2786
+ return;
2787
+ }
2788
+ if (!description || typeof description !== "string") {
2789
+ respond(false, null, { code: "INVALID_PARAMS", message: "description is required" });
2790
+ return;
2791
+ }
2792
+ const validation = validateSegments(segments);
2793
+ if (!validation.valid) {
2794
+ respond(false, null, {
2795
+ code: "VALIDATION_FAILED",
2796
+ message: JSON.stringify(validation.errors)
2797
+ });
2798
+ return;
2799
+ }
2800
+ let repeatTimes;
2801
+ try {
2802
+ repeatTimes = normalizeRepeatTimes({ repeat, repeat_times });
2803
+ assertAncsRepeatTimes(repeatTimes);
2804
+ } catch (err) {
2805
+ respond(false, null, {
2806
+ code: "VALIDATION_FAILED",
2807
+ message: err?.message ?? "Unknown error"
2808
+ });
2809
+ return;
2810
+ }
2811
+ try {
2812
+ const result = await registry.create({
2813
+ name,
2814
+ title: resolvedTitle,
2815
+ description,
2816
+ segments: validation.segments,
2817
+ repeat_times: repeatTimes
2818
+ });
2819
+ logger.info(`Light rule created: ${name}`);
2820
+ respond(true, {
2821
+ ok: true,
2822
+ id: result.meta.name,
2823
+ name: result.meta.name,
2824
+ title: result.meta.title,
2825
+ rule: result.meta
2826
+ });
2827
+ } catch (err) {
2828
+ if (err instanceof LightRuleError) {
2829
+ respond(false, null, { code: err.code, message: err.message });
2830
+ } else {
2831
+ logger.warn(`${LIGHT_RULE_GATEWAY_METHODS.create} failed: ${err?.message}`);
2832
+ respond(false, null, {
2833
+ code: "INTERNAL_ERROR",
2834
+ message: err?.message ?? "Unknown error"
2835
+ });
2836
+ }
2837
+ }
2838
+ });
2839
+ registerGatewayMethodWithBroadcastCapture(LIGHT_RULE_GATEWAY_METHODS.update, async ({ params, respond }) => {
2840
+ const { title, description, segments, repeat, repeat_times, enabled } = params;
2841
+ const name = resolveRuleIdentifier(params);
2842
+ const resolvedTitle = typeof title === "string" ? title.trim() : undefined;
2843
+ if (!name) {
2844
+ respond(false, null, {
2845
+ code: "INVALID_PARAMS",
2846
+ message: "name is required (or provide id/ruleId/ruleName)"
2847
+ });
2848
+ return;
2849
+ }
2850
+ if (title !== undefined && !resolvedTitle) {
2851
+ respond(false, null, {
2852
+ code: "INVALID_PARAMS",
2853
+ message: "title must be a non-empty string"
2854
+ });
2855
+ return;
2856
+ }
2857
+ if (description !== undefined && typeof description !== "string") {
2858
+ respond(false, null, {
2859
+ code: "INVALID_PARAMS",
2860
+ message: "description must be a string"
2861
+ });
2862
+ return;
2863
+ }
2864
+ let validatedSegments;
2865
+ if (segments !== undefined) {
2866
+ const validation = validateSegments(segments);
2867
+ if (!validation.valid) {
2868
+ respond(false, null, {
2869
+ code: "VALIDATION_FAILED",
2870
+ message: JSON.stringify(validation.errors)
2871
+ });
2872
+ return;
2873
+ }
2874
+ validatedSegments = validation.segments;
2875
+ }
2876
+ let repeatTimes;
2877
+ if (repeat !== undefined || repeat_times !== undefined) {
2878
+ try {
2879
+ repeatTimes = normalizeRepeatTimes({ repeat, repeat_times });
2880
+ assertAncsRepeatTimes(repeatTimes);
2881
+ } catch (err) {
2882
+ respond(false, null, {
2883
+ code: "VALIDATION_FAILED",
2884
+ message: err?.message ?? "Unknown error"
2885
+ });
2886
+ return;
2887
+ }
2888
+ }
2889
+ try {
2890
+ const result = await registry.update({
2891
+ name,
2892
+ title: resolvedTitle,
2893
+ description,
2894
+ segments: validatedSegments,
2895
+ repeat_times: repeatTimes,
2896
+ enabled
2897
+ });
2898
+ logger.info(`Light rule updated: ${name}`);
2899
+ respond(true, {
2900
+ ok: true,
2901
+ id: result.meta.name,
2902
+ name: result.meta.name,
2903
+ title: result.meta.title,
2904
+ updated: true,
2905
+ rule: result.meta
2906
+ });
2907
+ } catch (err) {
2908
+ if (err instanceof LightRuleError) {
2909
+ respond(false, null, { code: err.code, message: err.message });
2910
+ } else {
2911
+ logger.warn(`${LIGHT_RULE_GATEWAY_METHODS.update} failed: ${err?.message}`);
2912
+ respond(false, null, {
2913
+ code: "INTERNAL_ERROR",
2914
+ message: err?.message ?? "Unknown error"
2915
+ });
2916
+ }
2917
+ }
2918
+ });
2919
+ registerGatewayMethodWithBroadcastCapture(LIGHT_RULE_GATEWAY_METHODS.delete, async ({ params, respond }) => {
2920
+ const name = resolveRuleIdentifier(params);
2921
+ if (!name) {
2922
+ respond(false, null, {
2923
+ code: "INVALID_PARAMS",
2924
+ message: "name is required (or provide id/ruleId/ruleName)"
2925
+ });
2926
+ return;
2927
+ }
2928
+ try {
2929
+ const result = await registry.delete(name);
2930
+ logger.info(`Light rule deleted: ${result.name}`);
2931
+ respond(true, {
2932
+ ok: true,
2933
+ id: result.name,
2934
+ name: result.name,
2935
+ deleted: true
2936
+ });
2937
+ } catch (err) {
2938
+ if (err instanceof LightRuleError) {
2939
+ respond(false, null, { code: err.code, message: err.message });
2940
+ } else {
2941
+ logger.warn(`${LIGHT_RULE_GATEWAY_METHODS.delete} failed: ${err?.message}`);
2942
+ respond(false, null, {
2943
+ code: "INTERNAL_ERROR",
2944
+ message: err?.message ?? "Unknown error"
2945
+ });
2946
+ }
2947
+ }
2948
+ });
2949
+ }
2950
+ // src/notification/query.ts
2951
+ var ISO_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d{1,3})?)?(Z|[+-]\d{2}:\d{2})$/;
2952
+ function parseIso(value, name) {
2953
+ if (!ISO_RE.test(value)) {
2954
+ throw new YoooclawError("YOOOCLAW_INVALID_ARGUMENT", `${name} 必须是 ISO 8601 时间,例如 2026-03-01T09:00:00+08:00`);
2955
+ }
2956
+ const ts = Date.parse(value);
2957
+ if (Number.isNaN(ts)) {
2958
+ throw new YoooclawError("YOOOCLAW_INVALID_ARGUMENT", `${name} 不是合法时间`);
2959
+ }
2960
+ return ts;
2961
+ }
2962
+ function parseLimit(raw, fallback) {
2963
+ if (raw === undefined)
2964
+ return fallback;
2965
+ if (!/^\d+$/.test(raw) || Number(raw) <= 0) {
2966
+ throw new YoooclawError("YOOOCLAW_INVALID_ARGUMENT", "--limit 必须是大于 0 的整数");
2967
+ }
2968
+ return Number(raw);
2969
+ }
2970
+ function buildQueryOptions(opts, defaultLimit = 100) {
2971
+ if (opts.conversationType && opts.conversationType !== "group" && opts.conversationType !== "private") {
2972
+ throw new YoooclawError("YOOOCLAW_INVALID_ARGUMENT", "--conversation-type 只能是 group 或 private");
2973
+ }
2974
+ const fromTs = opts.from ? parseIso(opts.from, "--from") : null;
2975
+ const toTs = opts.to ? parseIso(opts.to, "--to") : null;
2976
+ if (fromTs !== null && toTs !== null && fromTs > toTs) {
2977
+ throw new YoooclawError("YOOOCLAW_INVALID_ARGUMENT", "--from 不能晚于 --to");
2978
+ }
2979
+ return {
2980
+ from: opts.from,
2981
+ to: opts.to,
2982
+ app: opts.app,
2983
+ sender: opts.sender,
2984
+ conversationType: opts.conversationType,
2985
+ keyword: opts.keyword,
2986
+ limit: parseLimit(opts.limit, defaultLimit),
2987
+ fromTs,
2988
+ toTs,
2989
+ fromDateKey: opts.from ? opts.from.slice(0, 10) : null,
2990
+ toDateKey: opts.to ? opts.to.slice(0, 10) : null
2991
+ };
2992
+ }
2993
+ async function queryNotifications(paths, options) {
2994
+ if (!import_node_fs11.existsSync(paths.notifications))
2995
+ return [];
2996
+ return collectMatchingNotifications(paths.notifications, options);
2997
+ }
2998
+
2999
+ // src/commands/notification.ts
3000
+ var MAX_LIMIT = Number.MAX_SAFE_INTEGER;
3001
+ async function notificationSearch(ctx, _args, opts) {
3002
+ const options = buildQueryOptions(opts);
3003
+ return queryNotifications(ctx.paths, options);
3004
+ }
3005
+ function topCounts(items, pick, topN) {
3006
+ const counts = new Map;
3007
+ for (const item of items) {
3008
+ const key = pick(item);
3009
+ if (!key)
3010
+ continue;
3011
+ counts.set(key, (counts.get(key) ?? 0) + 1);
3012
+ }
3013
+ return [...counts.entries()].map(([key, count]) => ({ key, count })).sort((a, b) => b.count - a.count || a.key.localeCompare(b.key)).slice(0, topN);
3014
+ }
3015
+ async function notificationSummary(ctx, _args, opts) {
3016
+ const sample = opts.sample ? Number(opts.sample) : 30;
3017
+ const top = opts.top ? Number(opts.top) : 10;
3018
+ const options = buildQueryOptions({ ...opts, limit: String(MAX_LIMIT) }, MAX_LIMIT);
3019
+ const items = await queryNotifications(ctx.paths, options);
3020
+ return {
3021
+ ok: true,
3022
+ total: items.length,
3023
+ range: { from: opts.from ?? null, to: opts.to ?? null },
3024
+ topApps: topCounts(items, (n) => n.appDisplayName || n.appName, top),
3025
+ topSenders: topCounts(items, (n) => n.senderName || n.title, top),
3026
+ sample: items.slice(0, sample)
3027
+ };
3028
+ }
3029
+ var HOUR_KEY = (n) => String(new Date(n.timestamp).getHours()).padStart(2, "0");
3030
+ async function notificationStats(ctx, _args, opts) {
3031
+ const from = opts.from ?? daysAgo(7);
3032
+ const to = opts.to ?? today();
3033
+ const dim = opts.dim ?? "all";
3034
+ const allowed = ["date", "app", "sender", "hour", "all"];
3035
+ if (!allowed.includes(dim)) {
3036
+ throw new YoooclawError("YOOOCLAW_INVALID_ARGUMENT", `--dim 只能是 ${allowed.join("|")}`);
3037
+ }
3038
+ const options = {
3039
+ app: opts.app,
3040
+ limit: MAX_LIMIT,
3041
+ fromTs: null,
3042
+ toTs: null,
3043
+ fromDateKey: from,
3044
+ toDateKey: to
3045
+ };
3046
+ const items = await queryNotifications(ctx.paths, options);
3047
+ const byDate = topCounts(items, (n) => formatDate(new Date(n.timestamp)), MAX_LIMIT);
3048
+ const byApp = topCounts(items, (n) => n.appDisplayName || n.appName, MAX_LIMIT);
3049
+ const bySender = topCounts(items, (n) => n.senderName || n.title, MAX_LIMIT);
3050
+ const byHour = topCounts(items, HOUR_KEY, MAX_LIMIT);
3051
+ const dims = { date: byDate, app: byApp, sender: bySender, hour: byHour };
3052
+ return {
3053
+ ok: true,
3054
+ total: items.length,
3055
+ range: { from, to },
3056
+ dim,
3057
+ ...dim === "all" ? dims : { [dim]: dims[dim] }
3058
+ };
3059
+ }
3060
+ function notificationStoragePath(ctx) {
3061
+ return { ok: true, path: ctx.paths.notifications };
3062
+ }
3063
+ async function notificationToday(ctx) {
3064
+ const day = today();
3065
+ const options = buildQueryOptions({
3066
+ from: `${day}T00:00:00${tzOffset()}`,
3067
+ to: `${day}T23:59:59${tzOffset()}`,
3068
+ limit: String(MAX_LIMIT)
3069
+ }, MAX_LIMIT);
3070
+ return queryNotifications(ctx.paths, options);
3071
+ }
3072
+ async function notificationRecent(ctx) {
3073
+ const fromTs = Date.now() - 60 * 60 * 1000;
3074
+ const options = {
3075
+ limit: MAX_LIMIT,
3076
+ fromTs,
3077
+ toTs: null,
3078
+ fromDateKey: formatDate(new Date(fromTs)),
3079
+ toDateKey: null
3080
+ };
3081
+ return queryNotifications(ctx.paths, options);
3082
+ }
3083
+ function notificationUnread() {
3084
+ throw new YoooclawError("YOOOCLAW_NOT_IMPLEMENTED", "+unread 预留:需要先落地通知的已读状态模型");
3085
+ }
3086
+ function tzOffset() {
3087
+ const min = -new Date().getTimezoneOffset();
3088
+ const sign = min >= 0 ? "+" : "-";
3089
+ const abs = Math.abs(min);
3090
+ const hh = String(Math.floor(abs / 60)).padStart(2, "0");
3091
+ const mm = String(abs % 60).padStart(2, "0");
3092
+ return `${sign}${hh}:${mm}`;
3093
+ }
3094
+
3095
+ // src/notification/sync.ts
3096
+ var import_node_fs12 = require("node:fs");
3097
+ var import_node_path7 = require("node:path");
3098
+ var SYNC_FETCH_LIMIT = 300;
3099
+ function checkpointPath(dir) {
3100
+ return import_node_path7.join(dir, ".checkpoint.json");
3101
+ }
3102
+ function readCheckpoint(dir) {
3103
+ const p = checkpointPath(dir);
3104
+ if (!import_node_fs12.existsSync(p))
3105
+ return {};
3106
+ try {
3107
+ return JSON.parse(import_node_fs12.readFileSync(p, "utf-8"));
3108
+ } catch {
3109
+ return {};
3110
+ }
3111
+ }
3112
+ function writeCheckpoint(dir, data) {
3113
+ writeJsonFile(checkpointPath(dir), data);
3114
+ }
3115
+ function listDateKeys(dir) {
3116
+ if (!import_node_fs12.existsSync(dir))
3117
+ return [];
3118
+ const pattern = /^(\d{4}-\d{2}-\d{2})\.json$/;
3119
+ const keys = [];
3120
+ for (const entry of import_node_fs12.readdirSync(dir, { withFileTypes: true })) {
3121
+ if (!entry.isFile())
3122
+ continue;
3123
+ const m = pattern.exec(entry.name);
3124
+ if (m)
3125
+ keys.push(m[1]);
3126
+ }
3127
+ return keys.sort((a, b) => b.localeCompare(a));
3128
+ }
3129
+ function readDateFile(dir, dateKey) {
3130
+ const filePath = import_node_path7.join(dir, `${dateKey}.json`);
3131
+ if (!import_node_fs12.existsSync(filePath))
3132
+ return [];
3133
+ try {
3134
+ const parsed = JSON.parse(import_node_fs12.readFileSync(filePath, "utf-8"));
3135
+ return Array.isArray(parsed) ? parsed : [];
3136
+ } catch {
3137
+ return [];
3138
+ }
3139
+ }
3140
+ function parseIndexOption(raw, label) {
3141
+ const n = Number.parseInt(raw, 10);
3142
+ if (!Number.isInteger(n) || String(n) !== raw || n < 0) {
3143
+ throw new YoooclawError("YOOOCLAW_INVALID_ARGUMENT", `${label} 必须是非负整数`);
3144
+ }
3145
+ return n;
3146
+ }
3147
+ function scanSync(dir) {
3148
+ const checkpoint = readCheckpoint(dir);
3149
+ const pending = [];
3150
+ let totalPending = 0;
3151
+ for (const dateKey of listDateKeys(dir)) {
3152
+ const items = readDateFile(dir, dateKey);
3153
+ const lastIndex = checkpoint[dateKey]?.lastIndex ?? -1;
3154
+ const unprocessed = items.length - (lastIndex + 1);
3155
+ if (unprocessed > 0) {
3156
+ pending.push({ date: dateKey, count: unprocessed, startIndex: lastIndex + 1 });
3157
+ totalPending += unprocessed;
3158
+ }
3159
+ }
3160
+ return { ok: true, pending, totalPending };
3161
+ }
3162
+ function fetchSync(dir, date, maxEndIndexRaw) {
3163
+ const items = readDateFile(dir, date);
3164
+ if (items.length === 0) {
3165
+ throw new YoooclawError("YOOOCLAW_NOT_FOUND", `日期 ${date} 无通知数据`);
3166
+ }
3167
+ const checkpoint = readCheckpoint(dir);
3168
+ const lastIndex = checkpoint[date]?.lastIndex ?? -1;
3169
+ const startIndex = lastIndex + 1;
3170
+ let maxEndIndex = items.length - 1;
3171
+ if (maxEndIndexRaw) {
3172
+ maxEndIndex = Math.min(parseIndexOption(maxEndIndexRaw, "--max-end-index"), items.length - 1);
3173
+ }
3174
+ const snapshotEndExclusive = Math.max(startIndex, maxEndIndex + 1);
3175
+ const unprocessed = items.slice(startIndex, snapshotEndExclusive);
3176
+ const notifications = unprocessed.slice(0, SYNC_FETCH_LIMIT);
3177
+ const endIndex = notifications.length > 0 ? startIndex + notifications.length - 1 : lastIndex;
3178
+ const hasMore = unprocessed.length > notifications.length;
3179
+ return {
3180
+ ok: true,
3181
+ date,
3182
+ startIndex,
3183
+ endIndex,
3184
+ nextStartIndex: hasMore ? endIndex + 1 : null,
3185
+ limit: SYNC_FETCH_LIMIT,
3186
+ maxEndIndex,
3187
+ returned: notifications.length,
3188
+ totalUnprocessed: unprocessed.length,
3189
+ hasMore,
3190
+ notifications
3191
+ };
3192
+ }
3193
+ function commitSync(dir, date, endIndexRaw) {
3194
+ const items = readDateFile(dir, date);
3195
+ if (items.length === 0) {
3196
+ throw new YoooclawError("YOOOCLAW_NOT_FOUND", `日期 ${date} 无通知数据`);
3197
+ }
3198
+ const checkpoint = readCheckpoint(dir);
3199
+ const lastIndex = checkpoint[date]?.lastIndex ?? -1;
3200
+ let committedIndex;
3201
+ let commitMode;
3202
+ if (endIndexRaw) {
3203
+ committedIndex = parseIndexOption(endIndexRaw, "--end-index");
3204
+ if (committedIndex >= items.length) {
3205
+ throw new YoooclawError("YOOOCLAW_INVALID_ARGUMENT", "--end-index 超出当前日期通知文件范围");
3206
+ }
3207
+ if (committedIndex < lastIndex) {
3208
+ throw new YoooclawError("YOOOCLAW_INVALID_ARGUMENT", "--end-index 早于当前 checkpoint,不能回退消费进度");
3209
+ }
3210
+ if (committedIndex > lastIndex + SYNC_FETCH_LIMIT) {
3211
+ throw new YoooclawError("YOOOCLAW_INVALID_ARGUMENT", "--end-index 超出单批 fetch 上限,不能跳过未处理通知");
3212
+ }
3213
+ commitMode = "exact-end-index";
3214
+ } else {
3215
+ committedIndex = Math.min(items.length - 1, lastIndex + SYNC_FETCH_LIMIT);
3216
+ commitMode = "legacy-batch-limit";
3217
+ }
3218
+ const hasMore = committedIndex < items.length - 1;
3219
+ checkpoint[date] = { lastIndex: committedIndex };
3220
+ writeCheckpoint(dir, checkpoint);
3221
+ return {
3222
+ ok: true,
3223
+ date,
3224
+ committedIndex,
3225
+ commitMode,
3226
+ limit: SYNC_FETCH_LIMIT,
3227
+ hasMore,
3228
+ nextStartIndex: hasMore ? committedIndex + 1 : null
3229
+ };
3230
+ }
3231
+
3232
+ // src/commands/sync.ts
3233
+ function syncScan(ctx) {
3234
+ return scanSync(ctx.paths.notifications);
3235
+ }
3236
+ function syncFetch(ctx, _args, opts) {
3237
+ if (!opts.date) {
3238
+ throw new YoooclawError("YOOOCLAW_INVALID_ARGUMENT", "--date 必填(YYYY-MM-DD)");
3239
+ }
3240
+ return fetchSync(ctx.paths.notifications, opts.date, opts.maxEndIndex);
3241
+ }
3242
+ function syncCommit(ctx, _args, opts) {
3243
+ if (!opts.date) {
3244
+ throw new YoooclawError("YOOOCLAW_INVALID_ARGUMENT", "--date 必填(YYYY-MM-DD)");
3245
+ }
3246
+ return commitSync(ctx.paths.notifications, opts.date, opts.endIndex);
3247
+ }
3248
+
3249
+ // src/commands/recording.ts
3250
+ var import_node_fs13 = require("node:fs");
3251
+ var import_node_path8 = require("node:path");
3252
+ function readIndex(ctx) {
3253
+ if (!import_node_fs13.existsSync(ctx.paths.recordings))
3254
+ return [];
3255
+ return readRecordingIndex(ctx.paths.recordings);
3256
+ }
3257
+ function toListItem(r) {
3258
+ return {
3259
+ id: r.id,
3260
+ name: r.metadata.name,
3261
+ duration_sec: r.metadata.duration_sec,
3262
+ status: r.status,
3263
+ file_size_bytes: r.metadata.file_size_bytes,
3264
+ has_audio: Boolean(r.audioFile),
3265
+ has_transcript: Boolean(r.transcriptFile),
3266
+ created_at: r.metadata.created_at,
3267
+ updated_at: r.updatedAt,
3268
+ error: r.lastError ?? null
3269
+ };
3270
+ }
3271
+ function recordingList(ctx, _args, opts) {
3272
+ let recordings = readIndex(ctx);
3273
+ if (opts.status)
3274
+ recordings = recordings.filter((r) => r.status === opts.status);
3275
+ return { ok: true, total: recordings.length, recordings: recordings.map(toListItem) };
3276
+ }
3277
+ function recordingStatus(ctx, args) {
3278
+ const [id] = args;
3279
+ const entry = readIndex(ctx).find((r) => r.id === id);
3280
+ if (!entry) {
3281
+ throw new YoooclawError("YOOOCLAW_NOT_FOUND", `录音不存在:${id}`);
3282
+ }
3283
+ return {
3284
+ ok: true,
3285
+ recording: {
3286
+ id: entry.id,
3287
+ name: entry.metadata.name,
3288
+ duration_sec: entry.metadata.duration_sec,
3289
+ file_size_bytes: entry.metadata.file_size_bytes,
3290
+ status: entry.status,
3291
+ created_at: entry.metadata.created_at,
3292
+ location: entry.metadata.location ?? null,
3293
+ markers: entry.metadata.markers ?? [],
3294
+ audioFile: entry.audioFile ?? null,
3295
+ srtFile: entry.srtFile ?? null,
3296
+ transcriptDataFile: entry.transcriptDataFile ?? null,
3297
+ transcriptFile: entry.transcriptFile ?? null,
3298
+ summaryFile: entry.summaryFile ?? null,
3299
+ title: entry.title ?? null,
3300
+ error: entry.lastError ?? null,
3301
+ ingestedAt: entry.ingestedAt,
3302
+ updatedAt: entry.updatedAt
3303
+ }
3304
+ };
3305
+ }
3306
+ function recordingStoragePath(ctx) {
3307
+ return { ok: true, path: ctx.paths.recordings };
3308
+ }
3309
+ function recordingLatest(ctx) {
3310
+ const recordings = readIndex(ctx).sort((a, b) => Date.parse(b.metadata.created_at) - Date.parse(a.metadata.created_at));
3311
+ if (recordings.length === 0) {
3312
+ return { ok: true, recording: null };
3313
+ }
3314
+ return recordingStatus(ctx, [recordings[0].id]);
3315
+ }
3316
+ async function recordingSetupAsr(ctx, _args, opts) {
3317
+ let provider = opts.provider;
3318
+ let apiKey = opts.apiKey;
3319
+ if (!opts.nonInteractive && isInteractive()) {
3320
+ provider = await ask("ASR provider(volcengine / whisper-local 等)", provider ?? "volcengine");
3321
+ if (provider !== "whisper-local") {
3322
+ apiKey = await ask("ASR API key(留空跳过)", apiKey ?? "");
3323
+ }
3324
+ }
3325
+ if (!provider) {
3326
+ throw new YoooclawError("YOOOCLAW_INVALID_ARGUMENT", "缺少 --provider(非交互模式必填)");
3327
+ }
3328
+ const config = { provider };
3329
+ if (apiKey)
3330
+ config.apiKey = apiKey;
3331
+ ensureDir(ctx.paths.recordings);
3332
+ const path = import_node_path8.join(ctx.paths.recordings, "asr-config.json");
3333
+ writeJsonFile(path, config);
3334
+ return { ok: true, path, provider, keyConfigured: Boolean(apiKey) };
3335
+ }
3336
+
3337
+ // src/image/storage.ts
3338
+ var import_node_fs14 = require("node:fs");
3339
+ var import_node_path9 = require("node:path");
3340
+ function imagesIndexPath(paths) {
3341
+ return import_node_path9.join(paths.images, "index.json");
3342
+ }
3343
+ function readImageIndex(paths) {
3344
+ const indexPath = imagesIndexPath(paths);
3345
+ if (!import_node_fs14.existsSync(indexPath))
3346
+ return [];
3347
+ try {
3348
+ const raw = JSON.parse(import_node_fs14.readFileSync(indexPath, "utf-8"));
3349
+ return Array.isArray(raw?.images) ? raw.images : [];
3350
+ } catch {
3351
+ return [];
3352
+ }
3353
+ }
3354
+ function resolveImageFile(paths, relative) {
3355
+ return import_node_path9.isAbsolute(relative) ? relative : import_node_path9.join(paths.images, relative);
3356
+ }
3357
+
3358
+ // src/commands/image.ts
3359
+ function byCreatedDesc(a, b) {
3360
+ return Date.parse(b.metadata.created_at) - Date.parse(a.metadata.created_at);
3361
+ }
3362
+ function imageList(ctx, _args, opts) {
3363
+ let items = readImageIndex(ctx.paths);
3364
+ if (opts.status)
3365
+ items = items.filter((i) => i.status === opts.status);
3366
+ if (opts.app) {
3367
+ const app = opts.app;
3368
+ items = items.filter((i) => matchesNotificationAppFilter({ appName: i.metadata.source_app ?? "", title: "", content: "", timestamp: "" }, app));
3369
+ }
3370
+ if (opts.from) {
3371
+ const fromTs = Date.parse(opts.from);
3372
+ items = items.filter((i) => Date.parse(i.metadata.created_at) >= fromTs);
3373
+ }
3374
+ if (opts.to) {
3375
+ const toTs = Date.parse(opts.to);
3376
+ items = items.filter((i) => Date.parse(i.metadata.created_at) <= toTs);
3377
+ }
3378
+ items = items.sort(byCreatedDesc);
3379
+ const limit = opts.limit ? Number(opts.limit) : 100;
3380
+ return { ok: true, total: items.length, images: items.slice(0, limit) };
3381
+ }
3382
+ function imageStatus(ctx, args) {
3383
+ const [id] = args;
3384
+ const entry = readImageIndex(ctx.paths).find((i) => i.imageId === id);
3385
+ if (!entry)
3386
+ throw new YoooclawError("YOOOCLAW_NOT_FOUND", `图片不存在:${id}`);
3387
+ return { ok: true, image: entry };
3388
+ }
3389
+ function imagePath(ctx, args, opts) {
3390
+ const [id] = args;
3391
+ const entry = readImageIndex(ctx.paths).find((i) => i.imageId === id);
3392
+ if (!entry)
3393
+ throw new YoooclawError("YOOOCLAW_NOT_FOUND", `图片不存在:${id}`);
3394
+ const relative = opts.thumbnail ? entry.thumbnail : entry.localFile;
3395
+ if (entry.status !== "synced" || !relative) {
3396
+ throw new YoooclawError("YOOOCLAW_IMAGE_NOT_READY", `图片 ${id} 尚未下载完成`, { status: entry.status, lastError: entry.lastError ?? null });
3397
+ }
3398
+ return { ok: true, path: resolveImageFile(ctx.paths, relative) };
3399
+ }
3400
+ function imageStoragePath(ctx) {
3401
+ return { ok: true, path: ctx.paths.images };
3402
+ }
3403
+ function imageLatest(ctx) {
3404
+ const items = readImageIndex(ctx.paths).sort(byCreatedDesc);
3405
+ return { ok: true, image: items[0] ?? null };
3406
+ }
3407
+
3408
+ // src/log/reader.ts
3409
+ var import_node_fs15 = require("node:fs");
3410
+ var import_node_path10 = require("node:path");
3411
+ var LINE_RE = /^(\d{4}-\d{2}-\d{2})(T\S+)?\s+\[(\w+)\]\s+(.*)$/;
3412
+ function logFiles(daemonLog) {
3413
+ const dir = import_node_path10.dirname(daemonLog);
3414
+ const base = import_node_path10.basename(daemonLog);
3415
+ if (!import_node_fs15.existsSync(dir))
3416
+ return [];
3417
+ const rotated = import_node_fs15.readdirSync(dir).filter((f) => f.startsWith(`${base}.`) && /\.\d{4}-\d{2}-\d{2}$/.test(f)).sort((a, b) => b.localeCompare(a)).map((f) => import_node_path10.join(dir, f));
3418
+ const files = [];
3419
+ if (import_node_fs15.existsSync(daemonLog))
3420
+ files.push(daemonLog);
3421
+ files.push(...rotated);
3422
+ return files;
3423
+ }
3424
+ function parseLine(raw) {
3425
+ const m = LINE_RE.exec(raw);
3426
+ if (!m)
3427
+ return null;
3428
+ return {
3429
+ date: m[1],
3430
+ time: m[1] + (m[2] ?? ""),
3431
+ level: m[3].toLowerCase(),
3432
+ message: m[4],
3433
+ raw
3434
+ };
3435
+ }
3436
+ function searchLogs(daemonLog, query) {
3437
+ const keyword = query.keyword?.toLowerCase();
3438
+ const level = query.level?.toLowerCase();
3439
+ const results = [];
3440
+ for (const file of logFiles(daemonLog)) {
3441
+ const content = import_node_fs15.readFileSync(file, "utf-8");
3442
+ const lines = content.split(`
3443
+ `).filter(Boolean).reverse();
3444
+ for (const raw of lines) {
3445
+ if (results.length >= query.limit)
3446
+ return results;
3447
+ const parsed = parseLine(raw);
3448
+ const date = parsed?.date;
3449
+ if (query.from && date && date < query.from)
3450
+ continue;
3451
+ if (query.to && date && date > query.to)
3452
+ continue;
3453
+ if (level && parsed && parsed.level !== level)
3454
+ continue;
3455
+ if (keyword && !raw.toLowerCase().includes(keyword))
3456
+ continue;
3457
+ results.push(parsed ?? { date: "", time: "", level: "", message: raw, raw });
3458
+ }
3459
+ }
3460
+ return results;
3461
+ }
3462
+
3463
+ // src/commands/log.ts
3464
+ function logSearch(ctx, args, opts) {
3465
+ const [keyword] = args;
3466
+ const lines = searchLogs(ctx.paths.daemonLog, {
3467
+ keyword,
3468
+ level: opts.level,
3469
+ from: opts.from ?? daysAgo(7),
3470
+ to: opts.to ?? today(),
3471
+ limit: opts.limit ? Number(opts.limit) : 50
3472
+ });
3473
+ return { ok: true, keyword: keyword ?? null, total: lines.length, lines };
3474
+ }
3475
+ function logErrors(ctx) {
3476
+ const lines = searchLogs(ctx.paths.daemonLog, {
3477
+ level: "error",
3478
+ from: daysAgo(1),
3479
+ to: today(),
3480
+ limit: 50
3481
+ });
3482
+ return { ok: true, level: "error", total: lines.length, lines };
3483
+ }
3484
+
3485
+ // src/commands/migrate.ts
3486
+ var import_node_fs16 = require("node:fs");
3487
+ var import_node_os4 = require("node:os");
3488
+ var import_node_path11 = require("node:path");
3489
+ function countFiles(dir) {
3490
+ if (!import_node_fs16.existsSync(dir))
3491
+ return 0;
3492
+ try {
3493
+ return import_node_fs16.readdirSync(dir, { recursive: true }).length;
3494
+ } catch {
3495
+ return 0;
3496
+ }
3497
+ }
3498
+ function migrateFromOpenclaw(ctx, _args, opts) {
3499
+ const sourceRoot = opts.source ?? import_node_path11.join(import_node_os4.homedir(), ".openclaw");
3500
+ const pluginRoot = import_node_path11.join(sourceRoot, "plugins", "phone-notifications");
3501
+ const plans = [
3502
+ { name: "notifications", source: import_node_path11.join(pluginRoot, "notifications"), target: ctx.paths.notifications },
3503
+ { name: "recordings", source: import_node_path11.join(pluginRoot, "recordings"), target: ctx.paths.recordings },
3504
+ { name: "light-rules", source: import_node_path11.join(pluginRoot, "light-rules"), target: ctx.paths.lightRules },
3505
+ { name: "images", source: import_node_path11.join(pluginRoot, "images"), target: ctx.paths.images }
3506
+ ].map((p) => ({ ...p, exists: import_node_fs16.existsSync(p.source), fileCount: countFiles(p.source) }));
3507
+ const srcCreds = readJsonFile(import_node_path11.join(sourceRoot, "credentials.json"));
3508
+ const srcApiKey = typeof srcCreds?.[API_KEY_FIELD] === "string" ? srcCreds[API_KEY_FIELD] : undefined;
3509
+ const sharedPath = sharedCredentialsPath();
3510
+ const existingShared = readJsonFile(sharedPath);
3511
+ const apiKeyAction = !srcApiKey ? "none" : existingShared?.[API_KEY_FIELD] ? "skip-existing" : "copy";
3512
+ const backupDir = `${ctx.paths.dir}.bak-${Date.now()}`;
3513
+ const willBackup = import_node_fs16.existsSync(ctx.paths.dir) && import_node_fs16.readdirSync(ctx.paths.dir).length > 0;
3514
+ if (opts.dryRun) {
3515
+ return {
3516
+ ok: true,
3517
+ dryRun: true,
3518
+ source: sourceRoot,
3519
+ profile: ctx.profile,
3520
+ backup: willBackup ? backupDir : null,
3521
+ subdirs: plans,
3522
+ apiKey: { action: apiKeyAction },
3523
+ hint: "迁移前请先停止 openclaw 客户端,避免插件还在写 notifications 导致数据竞争"
3524
+ };
3525
+ }
3526
+ if (willBackup) {
3527
+ import_node_fs16.cpSync(ctx.paths.dir, backupDir, { recursive: true });
3528
+ }
3529
+ ensureDir(ctx.paths.dir);
3530
+ const migrated = [];
3531
+ for (const plan of plans) {
3532
+ if (!plan.exists)
3533
+ continue;
3534
+ import_node_fs16.cpSync(plan.source, plan.target, { recursive: true });
3535
+ migrated.push(plan.name);
3536
+ }
3537
+ let apiKeyResult = apiKeyAction;
3538
+ if (apiKeyAction === "copy" && srcApiKey) {
3539
+ const data = existingShared ?? {};
3540
+ data[API_KEY_FIELD] = srcApiKey;
3541
+ writeJsonFile(sharedPath, data, SECRET_FILE_MODE);
3542
+ apiKeyResult = "copied";
3543
+ }
3544
+ return {
3545
+ ok: true,
3546
+ source: sourceRoot,
3547
+ profile: ctx.profile,
3548
+ backup: willBackup ? backupDir : null,
3549
+ migrated,
3550
+ apiKey: { action: apiKeyResult }
3551
+ };
3552
+ }
3553
+
3554
+ // src/commands/update.ts
3555
+ var PACKAGE = "@yoooclaw/cli";
3556
+ var REGISTRY = "https://registry.npmjs.org";
3557
+ function compareSemver(a, b) {
3558
+ const pa = a.split("-")[0].split(".").map(Number);
3559
+ const pb = b.split("-")[0].split(".").map(Number);
3560
+ for (let i = 0;i < 3; i += 1) {
3561
+ const d = (pa[i] ?? 0) - (pb[i] ?? 0);
3562
+ if (d !== 0)
3563
+ return d > 0 ? 1 : -1;
3564
+ }
3565
+ return 0;
3566
+ }
3567
+ async function updateSelf(_ctx, _args, opts) {
3568
+ const tag = opts.beta ? "beta" : "latest";
3569
+ const controller = new AbortController;
3570
+ const timer = setTimeout(() => controller.abort(), 8000);
3571
+ let latest;
3572
+ try {
3573
+ const res = await fetch(`${REGISTRY}/${encodeURIComponent(PACKAGE)}`, {
3574
+ signal: controller.signal,
3575
+ headers: { Accept: "application/vnd.npm.install-v1+json" }
3576
+ });
3577
+ if (!res.ok) {
3578
+ throw new YoooclawError("YOOOCLAW_NETWORK_ERROR", `npm registry 返回 ${res.status}`);
3579
+ }
3580
+ const body = await res.json();
3581
+ const resolved = body["dist-tags"]?.[tag];
3582
+ if (!resolved) {
3583
+ throw new YoooclawError("YOOOCLAW_NOT_FOUND", `npm dist-tag \`${tag}\` 不存在`);
3584
+ }
3585
+ latest = resolved;
3586
+ } catch (err) {
3587
+ if (err instanceof YoooclawError)
3588
+ throw err;
3589
+ throw new YoooclawError("YOOOCLAW_NETWORK_ERROR", `查询 npm registry 失败:${err.message}`);
3590
+ } finally {
3591
+ clearTimeout(timer);
3592
+ }
3593
+ const updateAvailable = compareSemver(latest, CLI_VERSION) > 0;
3594
+ return {
3595
+ ok: true,
3596
+ package: PACKAGE,
3597
+ channel: tag,
3598
+ current: CLI_VERSION,
3599
+ latest,
3600
+ updateAvailable,
3601
+ command: updateAvailable ? `npm update -g ${PACKAGE}` : null,
3602
+ hint: updateAvailable ? "发现新版本;CLI 不做自动更新,请手动执行上面的命令" : "已是最新版本"
3603
+ };
3604
+ }
3605
+
3606
+ // src/commands/doctor.ts
3607
+ var import_node_fs17 = require("node:fs");
3608
+ var MIN_NODE = [22, 12, 0];
3609
+ function checkNode() {
3610
+ const parts = process.versions.node.split(".").map(Number);
3611
+ let ok = true;
3612
+ for (let i = 0;i < 3; i += 1) {
3613
+ if ((parts[i] ?? 0) > MIN_NODE[i])
3614
+ break;
3615
+ if ((parts[i] ?? 0) < MIN_NODE[i]) {
3616
+ ok = false;
3617
+ break;
3618
+ }
3619
+ }
3620
+ return {
3621
+ name: "node-version",
3622
+ status: ok ? "ok" : "fail",
3623
+ detail: `Node ${process.versions.node}(要求 >= ${MIN_NODE.join(".")})`
3624
+ };
3625
+ }
3626
+ function checkDir(name, dir, fix) {
3627
+ if (!import_node_fs17.existsSync(dir)) {
3628
+ if (fix) {
3629
+ ensureDir(dir);
3630
+ return { name, status: "ok", detail: `已创建 ${dir}` };
3631
+ }
3632
+ return { name, status: "warn", detail: `目录不存在:${dir}(--fix 可创建)` };
3633
+ }
3634
+ try {
3635
+ import_node_fs17.accessSync(dir, import_node_fs17.constants.R_OK | import_node_fs17.constants.W_OK);
3636
+ } catch {
3637
+ return { name, status: "fail", detail: `目录不可读写:${dir}` };
3638
+ }
3639
+ const mode = import_node_fs17.statSync(dir).mode & 511;
3640
+ const tooOpen = (mode & 63) !== 0;
3641
+ return {
3642
+ name,
3643
+ status: tooOpen ? "warn" : "ok",
3644
+ detail: `${dir}(mode ${mode.toString(8)}${tooOpen ? ",建议收紧到 700" : ""})`
3645
+ };
3646
+ }
3647
+ function doctor(ctx, _args, opts) {
3648
+ const fix = Boolean(opts.fix);
3649
+ const checks = [checkNode(), checkDir("root-dir", rootDir(), fix)];
3650
+ if (configExists(ctx.paths)) {
3651
+ try {
3652
+ loadConfig(ctx.paths);
3653
+ checks.push({ name: "profile-config", status: "ok", detail: `${ctx.paths.config} 可解析` });
3654
+ } catch (err) {
3655
+ checks.push({ name: "profile-config", status: "fail", detail: err.message });
3656
+ }
3657
+ } else {
3658
+ checks.push({
3659
+ name: "profile-config",
3660
+ status: "warn",
3661
+ detail: `profile \`${ctx.profile}\` 未初始化(yoooclaw config init)`
3662
+ });
3663
+ }
3664
+ const apiKey = resolveApiKey();
3665
+ checks.push({
3666
+ name: "api-key",
3667
+ status: apiKey.value ? "ok" : "warn",
3668
+ detail: apiKey.value ? `来源 ${apiKey.source}` : "未配置(yoooclaw auth set-api-key)"
3669
+ });
3670
+ if (configExists(ctx.paths)) {
3671
+ const token = resolveGatewayToken(loadConfig(ctx.paths));
3672
+ checks.push({
3673
+ name: "gateway-token",
3674
+ status: token.value ? "ok" : "warn",
3675
+ detail: token.value ? `来源 ${token.source}` : "未设置(yoooclaw auth token-rotate)"
3676
+ });
3677
+ }
3678
+ checks.push({
3679
+ name: "keychain",
3680
+ status: keychainAvailable() ? "ok" : "skip",
3681
+ detail: keychainAvailable() ? "可用" : "当前平台无可用 keychain,凭据将落文件"
3682
+ });
3683
+ const state = daemonState(ctx.paths);
3684
+ checks.push({
3685
+ name: "daemon",
3686
+ status: state.running ? "ok" : state.stale ? "warn" : "skip",
3687
+ detail: state.running ? `运行中(pid ${state.lock?.pid})` : state.stale ? "锁文件存在但进程已死(陈旧锁)" : "未运行"
3688
+ });
3689
+ const failed = checks.filter((c) => c.status === "fail").length;
3690
+ const warned = checks.filter((c) => c.status === "warn").length;
3691
+ return {
3692
+ ok: failed === 0,
3693
+ profile: ctx.profile,
3694
+ summary: { total: checks.length, failed, warned },
3695
+ checks,
3696
+ note: "网络类自检(relay / OSS 可达性)请用 yoooclaw gateway test / tunnel +test"
3697
+ };
3698
+ }
3699
+
3700
+ // src/commands/daemon.ts
3701
+ var import_node_child_process2 = require("node:child_process");
3702
+ var import_node_fs21 = require("node:fs");
3703
+ var import_promises3 = require("node:timers/promises");
3704
+
3705
+ // src/daemon/main.ts
3706
+ var import_node_http = require("node:http");
3707
+
3708
+ // src/daemon/logger.ts
3709
+ var import_node_fs18 = require("node:fs");
3710
+ var import_node_path12 = require("node:path");
3711
+ var LEVEL_ORDER = {
3712
+ error: 0,
3713
+ warn: 1,
3714
+ info: 2,
3715
+ debug: 3,
3716
+ trace: 4
3717
+ };
3718
+ function isoLocal(d) {
3719
+ const pad = (n, w = 2) => String(n).padStart(w, "0");
3720
+ const off = -d.getTimezoneOffset();
3721
+ const sign = off >= 0 ? "+" : "-";
3722
+ const abs = Math.abs(off);
3723
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` + `T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}.${pad(d.getMilliseconds(), 3)}` + `${sign}${pad(Math.floor(abs / 60))}:${pad(abs % 60)}`;
3724
+ }
3725
+ function dateKey(d) {
3726
+ const pad = (n) => String(n).padStart(2, "0");
3727
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
3728
+ }
3729
+
3730
+ class DaemonLogger {
3731
+ logFile;
3732
+ level;
3733
+ alsoStderr;
3734
+ currentDay;
3735
+ constructor(logFile, level = "info", alsoStderr = false) {
3736
+ this.logFile = logFile;
3737
+ this.level = level;
3738
+ this.alsoStderr = alsoStderr;
3739
+ ensureDir(import_node_path12.dirname(logFile));
3740
+ this.currentDay = dateKey(new Date);
3741
+ this.rotateIfNeeded(new Date);
3742
+ }
3743
+ enabled(level) {
3744
+ return (LEVEL_ORDER[level] ?? 2) <= (LEVEL_ORDER[this.level] ?? 2);
3745
+ }
3746
+ rotateIfNeeded(now) {
3747
+ if (!import_node_fs18.existsSync(this.logFile))
3748
+ return;
3749
+ let fileDay;
3750
+ try {
3751
+ fileDay = dateKey(import_node_fs18.statSync(this.logFile).mtime);
3752
+ } catch {
3753
+ return;
3754
+ }
3755
+ const today2 = dateKey(now);
3756
+ if (fileDay !== today2) {
3757
+ try {
3758
+ import_node_fs18.renameSync(this.logFile, `${this.logFile}.${fileDay}`);
3759
+ } catch {}
3760
+ }
3761
+ }
3762
+ write(level, msg) {
3763
+ if (!this.enabled(level))
3764
+ return;
3765
+ const now = new Date;
3766
+ if (dateKey(now) !== this.currentDay) {
3767
+ this.rotateIfNeeded(now);
3768
+ this.currentDay = dateKey(now);
3769
+ }
3770
+ const line = `${isoLocal(now)} [${level.toUpperCase()}] ${msg}
3771
+ `;
3772
+ try {
3773
+ import_node_fs18.appendFileSync(this.logFile, line);
3774
+ } catch {}
3775
+ if (this.alsoStderr)
3776
+ process.stderr.write(line);
3777
+ }
3778
+ debug(msg) {
3779
+ this.write("debug", msg);
3780
+ }
3781
+ info(msg) {
3782
+ this.write("info", msg);
3783
+ }
3784
+ warn(msg) {
3785
+ this.write("warn", msg);
3786
+ }
3787
+ error(msg) {
3788
+ this.write("error", msg);
3789
+ }
3790
+ }
3791
+
3792
+ // src/daemon/runtime.ts
3793
+ class StandaloneRuntime {
3794
+ logger;
3795
+ pluginConfig;
3796
+ stateDir;
3797
+ httpRoutes = new Map;
3798
+ gatewayMethods = new Map;
3799
+ services = [];
3800
+ constructor(logger, pluginConfig, stateDir) {
3801
+ this.logger = logger;
3802
+ this.pluginConfig = pluginConfig;
3803
+ this.stateDir = stateDir;
3804
+ }
3805
+ runtime = {
3806
+ state: {
3807
+ resolveStateDir: () => this.stateDir
3808
+ }
3809
+ };
3810
+ registerHttpRoute(spec) {
3811
+ this.httpRoutes.set(spec.path, spec);
3812
+ }
3813
+ registerGatewayMethod(method, handler) {
3814
+ this.gatewayMethods.set(method, handler);
3815
+ }
3816
+ registerService(name) {
3817
+ this.services.push(typeof name === "string" ? name : "service");
3818
+ }
3819
+ registerCli() {}
3820
+ registerTool() {}
3821
+ on() {}
3822
+ callGateway(method, params) {
3823
+ const handler = this.gatewayMethods.get(method);
3824
+ if (!handler) {
3825
+ return Promise.resolve({
3826
+ ok: false,
3827
+ error: { code: "METHOD_NOT_FOUND", message: `未注册的 gateway 方法:${method}` }
3828
+ });
3829
+ }
3830
+ return new Promise((resolve) => {
3831
+ let settled = false;
3832
+ const respond = (ok, data, error) => {
3833
+ if (settled)
3834
+ return;
3835
+ settled = true;
3836
+ resolve({ ok, data, error });
3837
+ };
3838
+ Promise.resolve(handler({ params, respond })).catch((err) => {
3839
+ if (!settled) {
3840
+ settled = true;
3841
+ resolve({
3842
+ ok: false,
3843
+ error: { code: "INTERNAL_ERROR", message: err.message }
3844
+ });
3845
+ }
3846
+ });
3847
+ });
3848
+ }
3849
+ }
3850
+
3851
+ // src/image/channel.ts
3852
+ var import_node_fs19 = require("node:fs");
3853
+ var import_node_stream = require("node:stream");
3854
+ var import_node_path13 = require("node:path");
3855
+ function readAll(paths) {
3856
+ const p = imagesIndexPath(paths);
3857
+ if (!import_node_fs19.existsSync(p))
3858
+ return [];
3859
+ try {
3860
+ const raw = JSON.parse(import_node_fs19.readFileSync(p, "utf-8"));
3861
+ return Array.isArray(raw?.images) ? raw.images : [];
3862
+ } catch {
3863
+ return [];
3864
+ }
3865
+ }
3866
+ function writeAll(paths, images) {
3867
+ ensureDir(paths.images);
3868
+ writeJsonFile(imagesIndexPath(paths), { images });
3869
+ }
3870
+ function upsert(paths, entry) {
3871
+ const images = readAll(paths);
3872
+ const idx = images.findIndex((i) => i.imageId === entry.imageId);
3873
+ if (idx >= 0)
3874
+ images[idx] = entry;
3875
+ else
3876
+ images.push(entry);
3877
+ writeAll(paths, images);
3878
+ }
3879
+ function extFromMime(mime) {
3880
+ if (!mime)
3881
+ return "jpg";
3882
+ if (mime.includes("png"))
3883
+ return "png";
3884
+ if (mime.includes("webp"))
3885
+ return "webp";
3886
+ if (mime.includes("gif"))
3887
+ return "gif";
3888
+ return "jpg";
3889
+ }
3890
+ function ingestImage(paths, payload, opts) {
3891
+ const { imageId, image } = payload;
3892
+ if (!imageId || !image?.oss_image_url || !image?.created_at) {
3893
+ throw new Error("imageId / image.oss_image_url / image.created_at 必填");
3894
+ }
3895
+ const existing = readAll(paths).find((i) => i.imageId === imageId);
3896
+ if (existing && existing.metadata.oss_image_url === image.oss_image_url && existing.status === "synced") {
3897
+ return { ok: true, imageId, status: "synced", deduped: true };
3898
+ }
3899
+ const entry = {
3900
+ imageId,
3901
+ metadata: image,
3902
+ localFile: null,
3903
+ thumbnail: null,
3904
+ status: "syncing",
3905
+ lastError: null,
3906
+ syncedAt: null
3907
+ };
3908
+ upsert(paths, entry);
3909
+ downloadInBackground(paths, entry, opts).catch((err) => {
3910
+ opts.logger.warn(`image[${imageId}] 后台下载异常:${err.message}`);
3911
+ });
3912
+ return { ok: true, imageId, status: "syncing" };
3913
+ }
3914
+ async function downloadInBackground(paths, entry, opts) {
3915
+ const filesDir = import_node_path13.join(paths.images, "files");
3916
+ ensureDir(filesDir);
3917
+ const relative = `files/${entry.imageId}.${extFromMime(entry.metadata.mime_type)}`;
3918
+ const dest = import_node_path13.join(paths.images, relative);
3919
+ try {
3920
+ const res = await fetch(entry.metadata.oss_image_url);
3921
+ if (!res.ok || !res.body) {
3922
+ throw new Error(`OSS 返回 ${res.status}`);
3923
+ }
3924
+ const declared = Number(res.headers.get("content-length") ?? "0");
3925
+ if (declared && declared > opts.maxBytes) {
3926
+ throw new Error(`图片超过上限 ${opts.maxBytes} 字节`);
3927
+ }
3928
+ let written = 0;
3929
+ const fileStream = import_node_fs19.createWriteStream(dest);
3930
+ const reader = import_node_stream.Readable.fromWeb(res.body);
3931
+ for await (const chunk of reader) {
3932
+ written += chunk.length;
3933
+ if (written > opts.maxBytes) {
3934
+ fileStream.destroy();
3935
+ throw new Error(`图片超过上限 ${opts.maxBytes} 字节`);
3936
+ }
3937
+ fileStream.write(chunk);
3938
+ }
3939
+ await new Promise((resolve, reject) => {
3940
+ fileStream.end((err) => err ? reject(err) : resolve());
3941
+ });
3942
+ upsert(paths, {
3943
+ ...entry,
3944
+ localFile: relative,
3945
+ status: "synced",
3946
+ lastError: null,
3947
+ syncedAt: new Date().toISOString()
3948
+ });
3949
+ opts.logger.info(`image[${entry.imageId}] 下载完成 (${written} bytes)`);
3950
+ } catch (err) {
3951
+ upsert(paths, {
3952
+ ...entry,
3953
+ status: "sync_failed",
3954
+ lastError: err.message
3955
+ });
3956
+ opts.logger.warn(`image[${entry.imageId}] 下载失败:${err.message}`);
3957
+ }
3958
+ }
3959
+
3960
+ // src/monitor/store.ts
3961
+ var import_node_fs20 = require("node:fs");
3962
+ var import_node_path14 = require("node:path");
3963
+ function storePath(paths) {
3964
+ return import_node_path14.join(paths.state, "monitors.json");
3965
+ }
3966
+ function listMonitors(paths) {
3967
+ const p = storePath(paths);
3968
+ if (!import_node_fs20.existsSync(p))
3969
+ return [];
3970
+ try {
3971
+ const raw = JSON.parse(import_node_fs20.readFileSync(p, "utf-8"));
3972
+ return Array.isArray(raw?.monitors) ? raw.monitors : [];
3973
+ } catch {
3974
+ return [];
3975
+ }
3976
+ }
3977
+ function save(paths, monitors) {
3978
+ ensureDir(paths.state);
3979
+ writeJsonFile(storePath(paths), { monitors });
3980
+ }
3981
+ function createMonitor(paths, input) {
3982
+ if (!input.name || !input.description || !input.schedule || input.matchRules === undefined) {
3983
+ throw new Error("name / description / matchRules / schedule 均必填");
3984
+ }
3985
+ const monitors = listMonitors(paths);
3986
+ if (monitors.some((m) => m.name === input.name)) {
3987
+ throw new Error(`监控任务已存在:${input.name}`);
3988
+ }
3989
+ const now = new Date().toISOString();
3990
+ const task = {
3991
+ name: input.name,
3992
+ description: input.description,
3993
+ matchRules: input.matchRules,
3994
+ schedule: input.schedule,
3995
+ enabled: true,
3996
+ createdAt: now,
3997
+ updatedAt: now,
3998
+ lastRunAt: null,
3999
+ lastResult: null
4000
+ };
4001
+ monitors.push(task);
4002
+ save(paths, monitors);
4003
+ return task;
4004
+ }
4005
+ function deleteMonitor(paths, name) {
4006
+ const monitors = listMonitors(paths);
4007
+ const next = monitors.filter((m) => m.name !== name);
4008
+ if (next.length === monitors.length)
4009
+ return false;
4010
+ save(paths, next);
4011
+ return true;
4012
+ }
4013
+ function setMonitorEnabled(paths, name, enabled) {
4014
+ const monitors = listMonitors(paths);
4015
+ const task = monitors.find((m) => m.name === name);
4016
+ if (!task)
4017
+ return false;
4018
+ task.enabled = enabled;
4019
+ task.updatedAt = new Date().toISOString();
4020
+ save(paths, monitors);
4021
+ return true;
4022
+ }
4023
+
4024
+ // src/daemon/main.ts
4025
+ var PROTOCOL_VERSION = 1;
4026
+ var CAPABILITIES = ["notifications", "recordings", "images", "lightrules"];
4027
+ async function readBody2(req) {
4028
+ const chunks = [];
4029
+ for await (const chunk of req)
4030
+ chunks.push(chunk);
4031
+ return Buffer.concat(chunks).toString("utf-8");
4032
+ }
4033
+ function sendJson(res, status, body) {
4034
+ res.writeHead(status, { "Content-Type": "application/json" });
4035
+ res.end(JSON.stringify(body));
4036
+ }
4037
+ async function runDaemonForeground(ctx, opts) {
4038
+ const state = daemonState(ctx.paths);
4039
+ if (state.running) {
4040
+ throw new YoooclawError("YOOOCLAW_DAEMON_ALREADY_RUNNING", `daemon 已在运行(pid ${state.lock?.pid})`);
4041
+ }
4042
+ const config = loadConfig(ctx.paths);
4043
+ const bind = opts.bind ?? config.daemon.bind;
4044
+ const port = opts.port ?? config.daemon.port;
4045
+ const logLevel = opts.logLevel ?? config.daemon.logLevel;
4046
+ const token = resolveGatewayToken(config).value;
4047
+ const loopback = bind === "127.0.0.1" || bind === "::1" || bind === "localhost";
4048
+ if (!loopback && !token) {
4049
+ throw new YoooclawError("YOOOCLAW_UNAUTHORIZED", `绑定 ${bind} 需要先设置 gateway token`, { hint: "运行 yoooclaw auth token-rotate 或改回 127.0.0.1" });
4050
+ }
4051
+ ensureDir(ctx.paths.dir);
4052
+ const logger = new DaemonLogger(ctx.paths.daemonLog, logLevel, false);
4053
+ const runtimeState = {
4054
+ startedAt: new Date().toISOString(),
4055
+ lastIngestAt: null,
4056
+ ingestCount: 0
4057
+ };
4058
+ const pluginConfig = {
4059
+ retentionDays: config.notification.retentionDays ?? undefined,
4060
+ ignoredApps: config.notification.ignoredApps
4061
+ };
4062
+ const runtime = new StandaloneRuntime(logger, pluginConfig, ctx.paths.dir);
4063
+ const storage = new NotificationStorage(ctx.paths.notifications, pluginConfig, logger);
4064
+ await storage.init();
4065
+ const lightRuleRegistry = new LightRuleRegistry({ workspaceDir: ctx.paths.dir });
4066
+ const ignored = new Set(config.notification.ignoredApps ?? []);
4067
+ registerNotificationInterfaces({
4068
+ api: runtime,
4069
+ logger,
4070
+ getStorage: () => storage,
4071
+ filterNotifications: (items) => items.filter((n) => !ignored.has(n.app)),
4072
+ registerGatewayMethod: (m, h) => runtime.registerGatewayMethod(m, h),
4073
+ onAfterIngest: (inserted) => {
4074
+ runtimeState.lastIngestAt = new Date().toISOString();
4075
+ runtimeState.ingestCount += inserted.length;
4076
+ }
4077
+ });
4078
+ registerLightRulesGateway(runtime, lightRuleRegistry, logger);
4079
+ const server = import_node_http.createServer((req, res) => {
4080
+ handleRequest(req, res).catch((err) => {
4081
+ logger.error(`请求处理异常:${err.message}`);
4082
+ if (!res.headersSent)
4083
+ sendJson(res, 500, { ok: false, error: { code: "INTERNAL_ERROR", message: err.message } });
4084
+ });
4085
+ });
4086
+ function authorized(req) {
4087
+ if (!token)
4088
+ return true;
4089
+ const header = req.headers["authorization"];
4090
+ return header === `Bearer ${token}`;
4091
+ }
4092
+ async function handleRequest(req, res) {
4093
+ const url = new URL(req.url ?? "/", "http://localhost");
4094
+ const path = url.pathname;
4095
+ const method = req.method ?? "GET";
4096
+ if (path === "/health" && method === "GET") {
4097
+ sendJson(res, 200, {
4098
+ server: "yoooclaw",
4099
+ version: CLI_VERSION,
4100
+ protocol: PROTOCOL_VERSION,
4101
+ capabilities: CAPABILITIES
4102
+ });
4103
+ return;
4104
+ }
4105
+ if (!authorized(req)) {
4106
+ sendJson(res, 401, { ok: false, error: { code: "YOOOCLAW_UNAUTHORIZED", message: "token 不一致或缺失" } });
4107
+ return;
4108
+ }
4109
+ if (path === "/daemon/status" && method === "GET") {
4110
+ sendJson(res, 200, {
4111
+ ok: true,
4112
+ server: "yoooclaw",
4113
+ version: CLI_VERSION,
4114
+ pid: process.pid,
4115
+ profile: ctx.profile,
4116
+ bind,
4117
+ port,
4118
+ startedAt: runtimeState.startedAt,
4119
+ lastIngestAt: runtimeState.lastIngestAt,
4120
+ ingestCount: runtimeState.ingestCount,
4121
+ lightRules: lightRuleRegistry.list().length,
4122
+ relay: { mode: "standalone-http", connected: false, url: config.relay.url, enabled: config.relay.enabled },
4123
+ memoryRssBytes: process.memoryUsage().rss
4124
+ });
4125
+ return;
4126
+ }
4127
+ if (path === "/daemon/stop" && method === "POST") {
4128
+ sendJson(res, 200, { ok: true, stopping: true });
4129
+ logger.info("收到 /daemon/stop,准备优雅退出");
4130
+ setTimeout(() => void shutdown("stop-endpoint"), 50);
4131
+ return;
4132
+ }
4133
+ if (path === "/tunnel/status" && method === "GET") {
4134
+ sendJson(res, 200, {
4135
+ ok: true,
4136
+ mode: "standalone-http",
4137
+ connected: false,
4138
+ relayUrl: config.relay.url,
4139
+ enabled: config.relay.enabled,
4140
+ note: "独立 daemon 当前走直连 HTTP 接收推送;yoooclaw 托管 Relay 为后续迭代。可用 cloudflared / tailscale serve 反代"
4141
+ });
4142
+ return;
4143
+ }
4144
+ if (path === "/tunnel/reconnect" && method === "POST") {
4145
+ sendJson(res, 200, { ok: true, mode: "standalone-http", reconnected: false, note: "standalone HTTP 模式无 relay 连接可重连" });
4146
+ return;
4147
+ }
4148
+ if (path === "/tunnel/test" && method === "POST") {
4149
+ const echo = await selfEchoCheck(`http://127.0.0.1:${port}`, token);
4150
+ sendJson(res, 200, { ok: echo.ok, mode: "standalone-http", loopback: echo });
4151
+ return;
4152
+ }
4153
+ if (path === "/light/send" && method === "POST") {
4154
+ const body = await parseJson(req, res);
4155
+ if (body === undefined)
4156
+ return;
4157
+ await handleLightSend(body, res);
4158
+ return;
4159
+ }
4160
+ if (path === "/images" && method === "POST") {
4161
+ const body = await parseJson(req, res);
4162
+ if (body === undefined)
4163
+ return;
4164
+ try {
4165
+ const result = ingestImage(ctx.paths, body, {
4166
+ maxBytes: config.image.maxBytes,
4167
+ logger
4168
+ });
4169
+ sendJson(res, 200, result);
4170
+ } catch (err) {
4171
+ sendJson(res, 400, { ok: false, error: { code: "INVALID_PARAMS", message: err.message } });
4172
+ }
4173
+ return;
4174
+ }
4175
+ if (path === "/monitors" && method === "GET") {
4176
+ sendJson(res, 200, { ok: true, monitors: listMonitors(ctx.paths) });
4177
+ return;
4178
+ }
4179
+ if (path === "/monitors" && method === "POST") {
4180
+ const body = await parseJson(req, res);
4181
+ if (body === undefined)
4182
+ return;
4183
+ try {
4184
+ sendJson(res, 200, { ok: true, monitor: createMonitor(ctx.paths, body) });
4185
+ } catch (err) {
4186
+ sendJson(res, 400, { ok: false, error: { code: "INVALID_PARAMS", message: err.message } });
4187
+ }
4188
+ return;
4189
+ }
4190
+ if (path.startsWith("/monitors/") && method === "DELETE") {
4191
+ const name = decodeURIComponent(path.slice("/monitors/".length));
4192
+ sendJson(res, 200, { ok: true, deleted: deleteMonitor(ctx.paths, name) });
4193
+ return;
4194
+ }
4195
+ if (path.startsWith("/monitors/") && method === "POST") {
4196
+ const [, , name, action] = path.split("/");
4197
+ const enabled = action === "enable";
4198
+ sendJson(res, 200, { ok: setMonitorEnabled(ctx.paths, decodeURIComponent(name), enabled), name, enabled });
4199
+ return;
4200
+ }
4201
+ if (path.startsWith("/gateway/") && method === "POST") {
4202
+ const gwMethod = path.slice("/gateway/".length);
4203
+ const body = await parseJson(req, res);
4204
+ if (body === undefined)
4205
+ return;
4206
+ const result = await runtime.callGateway(gwMethod, body);
4207
+ sendJson(res, result.ok ? 200 : 400, result);
4208
+ return;
4209
+ }
4210
+ const route = runtime.httpRoutes.get(path);
4211
+ if (route) {
4212
+ await route.handler(req, res);
4213
+ return;
4214
+ }
4215
+ sendJson(res, 404, { ok: false, error: { code: "YOOOCLAW_NOT_FOUND", message: `未知路径:${path}` } });
4216
+ }
4217
+ async function parseJson(req, res) {
4218
+ try {
4219
+ const raw = await readBody2(req);
4220
+ return raw ? JSON.parse(raw) : {};
4221
+ } catch {
4222
+ sendJson(res, 400, { ok: false, error: { code: "INVALID_PARAMS", message: "请求体不是合法 JSON" } });
4223
+ return;
4224
+ }
4225
+ }
4226
+ async function handleLightSend(body, res) {
4227
+ const input = body;
4228
+ if (!input.segments && !input.preset) {
4229
+ sendJson(res, 400, { ok: false, error: { code: "INVALID_PARAMS", message: "需要 --segments 或 --preset" } });
4230
+ return;
4231
+ }
4232
+ if (input.segments) {
4233
+ const validation = validateSegments(input.segments);
4234
+ if (!validation.valid) {
4235
+ sendJson(res, 400, { ok: false, error: { code: "VALIDATION_FAILED", message: JSON.stringify(validation.errors) } });
4236
+ return;
4237
+ }
4238
+ }
4239
+ logger.info(`/light/send 已接收(preset=${input.preset ?? "-"});standalone 无连接设备,未实际投递`);
4240
+ sendJson(res, 200, {
4241
+ ok: true,
4242
+ accepted: true,
4243
+ delivered: false,
4244
+ reason: "standalone daemon 暂无连接的灯效设备会话(需手机端在线 / relay)"
4245
+ });
4246
+ }
4247
+ let serverRef = null;
4248
+ let shuttingDown = false;
4249
+ async function shutdown(reason) {
4250
+ if (shuttingDown)
4251
+ return;
4252
+ shuttingDown = true;
4253
+ logger.info(`daemon 退出(${reason})`);
4254
+ try {
4255
+ await storage.close();
4256
+ } catch {}
4257
+ serverRef?.close();
4258
+ removeLock(ctx.paths);
4259
+ process.exit(0);
4260
+ }
4261
+ const listenOnce = () => new Promise((resolve, reject) => {
4262
+ const onError = (err) => {
4263
+ server.removeListener("listening", onListening);
4264
+ reject(err);
4265
+ };
4266
+ const onListening = () => {
4267
+ server.removeListener("error", onError);
4268
+ resolve();
4269
+ };
4270
+ server.once("error", onError);
4271
+ server.once("listening", onListening);
4272
+ server.listen(port, bind);
4273
+ });
4274
+ for (let attempt = 0;; attempt += 1) {
4275
+ try {
4276
+ await listenOnce();
4277
+ break;
4278
+ } catch (err) {
4279
+ if (err.code === "EADDRINUSE" && attempt < 10) {
4280
+ await new Promise((r) => setTimeout(r, 200));
4281
+ continue;
4282
+ }
4283
+ throw err;
4284
+ }
4285
+ }
4286
+ serverRef = server;
4287
+ writeLock(ctx.paths, {
4288
+ pid: process.pid,
4289
+ startedAt: runtimeState.startedAt,
4290
+ bind,
4291
+ port,
4292
+ logLevel: String(logLevel)
4293
+ });
4294
+ logger.info(`yoooclaw daemon 启动:${bind}:${port}(profile=${ctx.profile}, pid=${process.pid})`);
4295
+ process.on("SIGTERM", () => void shutdown("SIGTERM"));
4296
+ process.on("SIGINT", () => void shutdown("SIGINT"));
4297
+ await new Promise(() => {});
4298
+ }
4299
+ async function selfEchoCheck(baseUrl, token) {
4300
+ try {
4301
+ const res = await fetch(`${baseUrl}/notifications`, {
4302
+ method: "POST",
4303
+ headers: {
4304
+ "Content-Type": "application/json",
4305
+ ...token ? { Authorization: `Bearer ${token}` } : {}
4306
+ },
4307
+ body: JSON.stringify({
4308
+ notifications: [
4309
+ { id: `echo_${Date.now()}`, app: "yoooclaw.selftest", title: "tunnel +test", body: "echo", timestamp: new Date().toISOString() }
4310
+ ]
4311
+ })
4312
+ });
4313
+ return { ok: res.ok, status: res.status };
4314
+ } catch (err) {
4315
+ return { ok: false, error: err.message };
4316
+ }
4317
+ }
4318
+
4319
+ // src/commands/daemon.ts
4320
+ function startOpts(opts) {
4321
+ return {
4322
+ bind: opts.bind,
4323
+ port: opts.port ? Number(opts.port) : undefined,
4324
+ logLevel: opts.logLevel
4325
+ };
4326
+ }
4327
+ async function daemonRunForeground(ctx, _args, opts) {
4328
+ await runDaemonForeground(ctx, startOpts(opts));
4329
+ return { ok: true };
4330
+ }
4331
+ async function daemonStart(ctx, _args, opts) {
4332
+ requireConfig(ctx.paths);
4333
+ const state = daemonState(ctx.paths);
4334
+ if (state.running) {
4335
+ throw new YoooclawError("YOOOCLAW_DAEMON_ALREADY_RUNNING", `daemon 已在运行(pid ${state.lock?.pid})`, { pid: state.lock?.pid });
4336
+ }
4337
+ if (state.stale)
4338
+ removeLock(ctx.paths);
4339
+ if (opts.detach === false) {
4340
+ await runDaemonForeground(ctx, startOpts(opts));
4341
+ return { ok: true };
4342
+ }
4343
+ const binEntry = process.argv[1];
4344
+ const args = [binEntry, "daemon", "run-foreground", "--profile", ctx.profile];
4345
+ if (opts.bind)
4346
+ args.push("--bind", opts.bind);
4347
+ if (opts.port)
4348
+ args.push("--port", opts.port);
4349
+ if (opts.logLevel)
4350
+ args.push("--log-level", opts.logLevel);
4351
+ const child = import_node_child_process2.spawn(process.execPath, args, {
4352
+ detached: true,
4353
+ stdio: "ignore",
4354
+ windowsHide: true,
4355
+ env: process.env
4356
+ });
4357
+ child.unref();
4358
+ for (let i = 0;i < 30; i += 1) {
4359
+ await import_promises3.setTimeout(100);
4360
+ const s = daemonState(ctx.paths);
4361
+ if (s.running) {
4362
+ return { ok: true, pid: s.lock?.pid, bind: s.lock?.bind, port: s.lock?.port, detached: true };
4363
+ }
4364
+ }
4365
+ throw new YoooclawError("YOOOCLAW_UNKNOWN", "daemon 启动超时(3s 内未写出 lock)", { hint: "查看 yoooclaw daemon logs 排查" });
4366
+ }
4367
+ async function daemonStop(ctx) {
4368
+ const lock = readLock(ctx.paths);
4369
+ if (!lock || !isProcessAlive(lock.pid)) {
4370
+ removeLock(ctx.paths);
4371
+ throw new YoooclawError("YOOOCLAW_DAEMON_NOT_RUNNING", "daemon 未运行");
4372
+ }
4373
+ try {
4374
+ process.kill(lock.pid, "SIGTERM");
4375
+ } catch {}
4376
+ for (let i = 0;i < 100; i += 1) {
4377
+ await import_promises3.setTimeout(100);
4378
+ if (!isProcessAlive(lock.pid)) {
4379
+ removeLock(ctx.paths);
4380
+ return { ok: true, stopped: lock.pid, signal: "SIGTERM" };
4381
+ }
4382
+ }
4383
+ try {
4384
+ process.kill(lock.pid, "SIGKILL");
4385
+ } catch {}
4386
+ removeLock(ctx.paths);
4387
+ return { ok: true, stopped: lock.pid, signal: "SIGKILL" };
4388
+ }
4389
+ async function daemonRestart(ctx, args, opts) {
4390
+ const lock = readLock(ctx.paths);
4391
+ const inheritedOpts = {
4392
+ ...opts,
4393
+ bind: opts.bind ?? lock?.bind,
4394
+ port: opts.port ?? (lock?.port ? String(lock.port) : undefined),
4395
+ logLevel: opts.logLevel ?? lock?.logLevel
4396
+ };
4397
+ if (lock && isProcessAlive(lock.pid)) {
4398
+ await daemonStop(ctx);
4399
+ }
4400
+ return daemonStart(ctx, args, inheritedOpts);
4401
+ }
4402
+ async function daemonStatus(ctx) {
4403
+ const state = daemonState(ctx.paths);
4404
+ if (!state.running) {
4405
+ throw new YoooclawError("YOOOCLAW_DAEMON_NOT_RUNNING", "daemon 未运行", { stale: state.stale, hint: "yoooclaw daemon start" });
4406
+ }
4407
+ const client = new DaemonClient(ctx.paths);
4408
+ const res = await client.get("/daemon/status");
4409
+ return res.body;
4410
+ }
4411
+ function tailLines(file, n) {
4412
+ if (!import_node_fs21.existsSync(file))
4413
+ return [];
4414
+ const content = import_node_fs21.readFileSync(file, "utf-8").split(`
4415
+ `).filter(Boolean);
4416
+ return content.slice(-n);
4417
+ }
4418
+ async function daemonLogs(ctx, _args, opts) {
4419
+ const n = opts.lines ? Number(opts.lines) : 100;
4420
+ const file = ctx.paths.daemonLog;
4421
+ let lines = tailLines(file, n);
4422
+ if (opts.level) {
4423
+ const want = `[${opts.level.toUpperCase()}]`;
4424
+ lines = lines.filter((l) => l.includes(want));
4425
+ }
4426
+ if (!opts.follow) {
4427
+ return { ok: true, file, total: lines.length, lines };
4428
+ }
4429
+ for (const l of lines)
4430
+ process.stdout.write(l + `
4431
+ `);
4432
+ let size = import_node_fs21.existsSync(file) ? import_node_fs21.statSync(file).size : 0;
4433
+ await new Promise(() => {
4434
+ import_node_fs21.watchFile(file, { interval: 500 }, () => {
4435
+ if (!import_node_fs21.existsSync(file))
4436
+ return;
4437
+ const cur = import_node_fs21.statSync(file).size;
4438
+ if (cur > size) {
4439
+ const chunk = import_node_fs21.readFileSync(file, "utf-8").slice(-(cur - size));
4440
+ size = cur;
4441
+ process.stdout.write(chunk);
4442
+ } else if (cur < size) {
4443
+ size = cur;
4444
+ }
4445
+ });
4446
+ });
4447
+ return { ok: true };
4448
+ }
4449
+
4450
+ // src/commands/daemon-services.ts
4451
+ var import_node_fs22 = require("node:fs");
4452
+ function client(ctx) {
4453
+ assertDaemonRunning(ctx.paths);
4454
+ return new DaemonClient(ctx.paths);
4455
+ }
4456
+ function parseJsonArg(raw, label) {
4457
+ if (raw === undefined)
4458
+ return;
4459
+ try {
4460
+ return JSON.parse(raw);
4461
+ } catch {
4462
+ throw new YoooclawError("YOOOCLAW_INVALID_ARGUMENT", `${label} 不是合法 JSON`);
4463
+ }
4464
+ }
4465
+ async function lightSend(ctx, _args, opts) {
4466
+ const body = {};
4467
+ if (opts.segments)
4468
+ body.segments = parseJsonArg(opts.segments, "--segments");
4469
+ if (opts.preset)
4470
+ body.preset = opts.preset;
4471
+ if (opts.repeat)
4472
+ body.repeat = true;
4473
+ if (opts.repeatTimes !== undefined)
4474
+ body.repeat_times = Number(opts.repeatTimes);
4475
+ const res = await client(ctx).post("/light/send", body);
4476
+ return res.body;
4477
+ }
4478
+ async function lightBlink(ctx) {
4479
+ const res = await client(ctx).post("/light/send", { preset: "blink" });
4480
+ return res.body;
4481
+ }
4482
+ async function listRules(c) {
4483
+ const res = await c.post("/gateway/lightrules.list", {});
4484
+ return res.body?.data?.rules ?? [];
4485
+ }
4486
+ async function lightruleList(ctx) {
4487
+ return { ok: true, rules: await listRules(client(ctx)) };
4488
+ }
4489
+ async function lightruleShow(ctx, args) {
4490
+ const [id] = args;
4491
+ const rule = (await listRules(client(ctx))).find((r) => r.id === id || r.name === id);
4492
+ if (!rule)
4493
+ throw new YoooclawError("YOOOCLAW_NOT_FOUND", `规则不存在:${id}`);
4494
+ return { ok: true, rule };
4495
+ }
4496
+ async function buildRuleParams(opts, name) {
4497
+ if (opts.fromFile) {
4498
+ const raw = opts.fromFile === "-" ? await readStdin() : import_node_fs22.readFileSync(opts.fromFile, "utf-8");
4499
+ return JSON.parse(raw);
4500
+ }
4501
+ const params = {};
4502
+ if (name)
4503
+ params.name = name;
4504
+ if (opts.name)
4505
+ params.name = opts.name;
4506
+ if (opts.intent)
4507
+ params.description = opts.intent;
4508
+ const action = parseJsonArg(opts.lightAction, "--light-action");
4509
+ if (action !== undefined) {
4510
+ params.segments = Array.isArray(action) ? action : action.segments ?? action;
4511
+ }
4512
+ const match = parseJsonArg(opts.matchRules, "--match-rules");
4513
+ if (match !== undefined)
4514
+ params.matchRules = match;
4515
+ return params;
4516
+ }
4517
+ async function lightruleCreate(ctx, _args, opts) {
4518
+ const c = client(ctx);
4519
+ const params = await buildRuleParams(opts);
4520
+ const res = await c.post("/gateway/lightrules.create", params);
4521
+ if (!res.body || res.body.ok === false) {
4522
+ throw new YoooclawError("YOOOCLAW_INVALID_ARGUMENT", JSON.stringify(res.body?.error ?? res.body));
4523
+ }
4524
+ return res.body.data ?? res.body;
4525
+ }
4526
+ async function lightruleUpdate(ctx, args, opts) {
4527
+ const [id] = args;
4528
+ const c = client(ctx);
4529
+ const params = await buildRuleParams(opts, id);
4530
+ params.name = id;
4531
+ const res = await c.post("/gateway/lightrules.update", params);
4532
+ return res.body.data ?? res.body;
4533
+ }
4534
+ async function lightruleDelete(ctx, args, opts) {
4535
+ const [id] = args;
4536
+ if (!opts.yes && !await confirm(`确认删除规则 \`${id}\`?`, false)) {
4537
+ throw new YoooclawError("YOOOCLAW_CONFIRMATION_REQUIRED", "已取消");
4538
+ }
4539
+ const res = await client(ctx).post("/gateway/lightrules.delete", { name: id });
4540
+ return res.body.data ?? res.body;
4541
+ }
4542
+ async function lightruleEnable(ctx, args) {
4543
+ const [id] = args;
4544
+ const res = await client(ctx).post("/gateway/lightrules.update", { name: id, enabled: true });
4545
+ return res.body.data ?? res.body;
4546
+ }
4547
+ async function lightruleDisable(ctx, args) {
4548
+ const [id] = args;
4549
+ const res = await client(ctx).post("/gateway/lightrules.update", { name: id, enabled: false });
4550
+ return res.body.data ?? res.body;
4551
+ }
4552
+ async function toggleAll(ctx, enabled) {
4553
+ const c = client(ctx);
4554
+ const rules = await listRules(c);
4555
+ const results = [];
4556
+ for (const rule of rules) {
4557
+ const res = await c.post("/gateway/lightrules.update", { name: rule.name, enabled });
4558
+ results.push({ name: rule.name, ok: res.body?.ok !== false });
4559
+ }
4560
+ return { ok: true, enabled, count: results.length, results };
4561
+ }
4562
+ var lightruleOn = (ctx) => toggleAll(ctx, true);
4563
+ var lightruleOff = (ctx) => toggleAll(ctx, false);
4564
+ async function tunnelStatus(ctx) {
4565
+ return (await client(ctx).get("/tunnel/status")).body;
4566
+ }
4567
+ async function tunnelReconnect(ctx) {
4568
+ return (await client(ctx).post("/tunnel/reconnect")).body;
4569
+ }
4570
+ async function tunnelTest(ctx) {
4571
+ return (await client(ctx).post("/tunnel/test")).body;
4572
+ }
4573
+ async function monitorList(ctx) {
4574
+ return (await client(ctx).get("/monitors")).body;
4575
+ }
4576
+ async function monitorShow(ctx, args) {
4577
+ const [name] = args;
4578
+ const res = await client(ctx).get("/monitors");
4579
+ const monitor = res.body?.monitors?.find((m) => m.name === name);
4580
+ if (!monitor)
4581
+ throw new YoooclawError("YOOOCLAW_NOT_FOUND", `监控任务不存在:${name}`);
4582
+ return { ok: true, monitor };
4583
+ }
4584
+ async function monitorCreate(ctx, args, opts) {
4585
+ const [name] = args;
4586
+ if (!opts.description || !opts.matchRules || !opts.schedule) {
4587
+ throw new YoooclawError("YOOOCLAW_INVALID_ARGUMENT", "--description / --match-rules / --schedule 均必填");
4588
+ }
4589
+ const res = await client(ctx).post("/monitors", {
4590
+ name,
4591
+ description: opts.description,
4592
+ matchRules: parseJsonArg(opts.matchRules, "--match-rules"),
4593
+ schedule: opts.schedule
4594
+ });
4595
+ return res.body;
4596
+ }
4597
+ async function monitorDelete(ctx, args, opts) {
4598
+ const [name] = args;
4599
+ if (!opts.yes && !await confirm(`确认删除监控任务 \`${name}\`?`, false)) {
4600
+ throw new YoooclawError("YOOOCLAW_CONFIRMATION_REQUIRED", "已取消");
4601
+ }
4602
+ return (await client(ctx).request("DELETE", `/monitors/${encodeURIComponent(name)}`)).body;
4603
+ }
4604
+ async function monitorEnable(ctx, args) {
4605
+ const [name] = args;
4606
+ return (await client(ctx).post(`/monitors/${encodeURIComponent(name)}/enable`)).body;
4607
+ }
4608
+ async function monitorDisable(ctx, args) {
4609
+ const [name] = args;
4610
+ return (await client(ctx).post(`/monitors/${encodeURIComponent(name)}/disable`)).body;
4611
+ }
4612
+ async function gatewayTest(ctx, _args, opts) {
4613
+ const c = client(ctx);
4614
+ if (opts.viaRelay) {
4615
+ return { ok: true, viaRelay: true, result: (await c.post("/tunnel/test")).body };
4616
+ }
4617
+ const probe = {
4618
+ notifications: [
4619
+ { id: `gwtest_${Date.now()}`, app: "yoooclaw.gatewaytest", title: "gateway test", body: "probe", timestamp: new Date().toISOString() }
4620
+ ]
4621
+ };
4622
+ const res = await c.post("/notifications", probe);
4623
+ return {
4624
+ ok: res.status >= 200 && res.status < 300,
4625
+ fromPhoneIp: opts.fromPhoneIp ?? null,
4626
+ status: res.status,
4627
+ response: res.body
4628
+ };
4629
+ }
4630
+ async function apiRaw(ctx, args, opts) {
4631
+ const [method, path] = args;
4632
+ let data;
4633
+ if (opts.data) {
4634
+ let raw;
4635
+ if (opts.data === "-")
4636
+ raw = await readStdin();
4637
+ else if (opts.data.startsWith("@"))
4638
+ raw = import_node_fs22.readFileSync(opts.data.slice(1), "utf-8");
4639
+ else
4640
+ raw = opts.data;
4641
+ try {
4642
+ data = JSON.parse(raw);
4643
+ } catch {
4644
+ data = raw;
4645
+ }
4646
+ }
4647
+ const headers = {};
4648
+ const headerList = Array.isArray(opts.header) ? opts.header : opts.header ? [opts.header] : [];
4649
+ for (const h of headerList) {
4650
+ const idx = h.indexOf(":");
4651
+ if (idx > 0)
4652
+ headers[h.slice(0, idx).trim()] = h.slice(idx + 1).trim();
4653
+ }
4654
+ const res = await client(ctx).request(method.toUpperCase(), path, data, headers);
4655
+ return { ok: res.status >= 200 && res.status < 300, status: res.status, body: res.body };
4656
+ }
4657
+
4658
+ // src/commands/registry.ts
4659
+ var HANDLERS = {
4660
+ "config init": configInit,
4661
+ "config show": configShow,
4662
+ "config set": configSet,
4663
+ "config unset": configUnset,
4664
+ "profile list": profileList,
4665
+ "profile use": profileUse,
4666
+ "profile create": profileCreate,
4667
+ "profile delete": profileDelete,
4668
+ "auth set-api-key": authSetApiKey,
4669
+ "auth status": authStatus,
4670
+ "auth token-rotate": authTokenRotate,
4671
+ "auth check": authCheck,
4672
+ "notification search": notificationSearch,
4673
+ "notification summary": notificationSummary,
4674
+ "notification stats": notificationStats,
4675
+ "notification storage-path": notificationStoragePath,
4676
+ "notification +today": notificationToday,
4677
+ "notification +recent": notificationRecent,
4678
+ "notification +unread": notificationUnread,
4679
+ "sync scan": syncScan,
4680
+ "sync fetch": syncFetch,
4681
+ "sync commit": syncCommit,
4682
+ "recording list": recordingList,
4683
+ "recording status": recordingStatus,
4684
+ "recording storage-path": recordingStoragePath,
4685
+ "recording setup-asr": recordingSetupAsr,
4686
+ "recording +latest": recordingLatest,
4687
+ "image list": imageList,
4688
+ "image status": imageStatus,
4689
+ "image path": imagePath,
4690
+ "image storage-path": imageStoragePath,
4691
+ "image +latest": imageLatest,
4692
+ log: logSearch,
4693
+ "log +errors": logErrors,
4694
+ "migrate from-openclaw": migrateFromOpenclaw,
4695
+ "update self": updateSelf,
4696
+ doctor,
4697
+ "daemon start": daemonStart,
4698
+ "daemon stop": daemonStop,
4699
+ "daemon restart": daemonRestart,
4700
+ "daemon status": daemonStatus,
4701
+ "daemon logs": daemonLogs,
4702
+ "daemon run-foreground": daemonRunForeground,
4703
+ "light send": lightSend,
4704
+ "light +blink": lightBlink,
4705
+ "lightrule list": lightruleList,
4706
+ "lightrule show": lightruleShow,
4707
+ "lightrule create": lightruleCreate,
4708
+ "lightrule update": lightruleUpdate,
4709
+ "lightrule delete": lightruleDelete,
4710
+ "lightrule enable": lightruleEnable,
4711
+ "lightrule disable": lightruleDisable,
4712
+ "lightrule +on": lightruleOn,
4713
+ "lightrule +off": lightruleOff,
4714
+ "tunnel status": tunnelStatus,
4715
+ "tunnel reconnect": tunnelReconnect,
4716
+ "tunnel +test": tunnelTest,
4717
+ "monitor list": monitorList,
4718
+ "monitor show": monitorShow,
4719
+ "monitor create": monitorCreate,
4720
+ "monitor delete": monitorDelete,
4721
+ "monitor enable": monitorEnable,
4722
+ "monitor disable": monitorDisable,
4723
+ "gateway test": gatewayTest,
4724
+ api: apiRaw
4725
+ };
4726
+
4727
+ // src/program.ts
4728
+ function wrapAction(handler) {
4729
+ return async (...rawArgs) => {
4730
+ const command = rawArgs.at(-1);
4731
+ const opts = rawArgs.at(-2);
4732
+ const positionals = rawArgs.slice(0, -2);
4733
+ const globals = command.optsWithGlobals();
4734
+ let ctx;
4735
+ try {
4736
+ ctx = buildContext(globals);
4737
+ const result = await handler(ctx, positionals, opts);
4738
+ renderResult(result, { format: ctx.format });
4739
+ } catch (err) {
4740
+ const format = ctx?.format ?? "json";
4741
+ renderError(err, { format });
4742
+ process.exitCode = err instanceof YoooclawError ? err.exitCode : 1;
4743
+ }
4744
+ };
4745
+ }
4746
+ function resolveHandler(path) {
4747
+ const handler = HANDLERS[path];
4748
+ if (handler)
4749
+ return handler;
4750
+ return () => {
4751
+ throw notImplemented(path);
4752
+ };
4753
+ }
4754
+ function applyOptions(cmd, options) {
4755
+ for (const opt of options ?? []) {
4756
+ if (opt.default !== undefined) {
4757
+ cmd.option(opt.flags, opt.summary, opt.default);
4758
+ } else {
4759
+ cmd.option(opt.flags, opt.summary);
4760
+ }
4761
+ }
4762
+ }
4763
+ function attachService(program, spec) {
4764
+ const service = program.command(spec.name).description(spec.summary);
4765
+ if (!spec.subcommands || spec.subcommands.length === 0) {
4766
+ if (spec.args) {
4767
+ service.arguments(spec.args);
4768
+ }
4769
+ applyOptions(service, spec.options);
4770
+ service.action(wrapAction(resolveHandler(spec.name)));
4771
+ } else {
4772
+ for (const sub of spec.subcommands) {
4773
+ const subCmd = service.command(sub.name).description(sub.summary);
4774
+ applyOptions(subCmd, sub.options);
4775
+ subCmd.action(wrapAction(resolveHandler(handlerKey(spec.name, sub.name))));
4776
+ }
4777
+ }
4778
+ for (const shortcut of spec.shortcuts ?? []) {
4779
+ const shortcutCmd = service.command(shortcut.name).description(shortcut.summary);
4780
+ applyOptions(shortcutCmd, shortcut.options);
4781
+ shortcutCmd.action(wrapAction(resolveHandler(handlerKey(spec.name, shortcut.name))));
4782
+ }
4783
+ }
4784
+ function handlerKey(service, subName) {
4785
+ const bare = subName.split(/\s+/)[0];
4786
+ return `${service} ${bare}`;
474
4787
  }
475
4788
  function buildProgram() {
476
4789
  const program = new import_commander.Command;
@@ -490,5 +4803,5 @@ async function run(argv = process.argv) {
490
4803
  // src/bin.ts
491
4804
  run(process.argv);
492
4805
 
493
- //# debugId=885B6E71DB5ADDF164756E2164756E21
4806
+ //# debugId=9E8D45DFD5AB967964756E2164756E21
494
4807
  //# sourceMappingURL=bin.cjs.map