mingdao-harness 0.1.54

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +246 -0
  3. package/assets/tokenizer-data.json.gz +0 -0
  4. package/docs/ARCHITECTURE.md +108 -0
  5. package/docs/CONFIG.md +257 -0
  6. package/docs/DESKTOP-EVALUATION.md +48 -0
  7. package/docs/PROVIDERS.md +98 -0
  8. package/docs/QA-REPORT.md +333 -0
  9. package/install.bat +8 -0
  10. package/install.ps1 +57 -0
  11. package/install.sh +154 -0
  12. package/package.json +61 -0
  13. package/skills/api-design/SKILL.md +21 -0
  14. package/skills/code-review/SKILL.md +31 -0
  15. package/skills/debugging/SKILL.md +21 -0
  16. package/skills/docker/SKILL.md +27 -0
  17. package/skills/docx/SKILL.md +28 -0
  18. package/skills/frontend-design/SKILL.md +31 -0
  19. package/skills/git-commit/SKILL.md +32 -0
  20. package/skills/pdf/SKILL.md +30 -0
  21. package/skills/pptx/SKILL.md +24 -0
  22. package/skills/refactoring/SKILL.md +24 -0
  23. package/skills/release-checklist/SKILL.md +20 -0
  24. package/skills/testing/SKILL.md +30 -0
  25. package/skills/webapp-testing/SKILL.md +27 -0
  26. package/skills/xlsx/SKILL.md +28 -0
  27. package/src/agent.js +472 -0
  28. package/src/audit.js +68 -0
  29. package/src/autostart.js +74 -0
  30. package/src/batch.js +182 -0
  31. package/src/cachestats.js +215 -0
  32. package/src/cli.js +1099 -0
  33. package/src/commands/key.js +75 -0
  34. package/src/commands/schedule.js +176 -0
  35. package/src/commands/skill.js +168 -0
  36. package/src/commands/sync.js +222 -0
  37. package/src/commands/update.js +157 -0
  38. package/src/commands/workspace.js +109 -0
  39. package/src/compact.js +112 -0
  40. package/src/config.js +134 -0
  41. package/src/context.js +86 -0
  42. package/src/cost-guard.js +58 -0
  43. package/src/credentials.js +69 -0
  44. package/src/hooks.js +123 -0
  45. package/src/index.js +42 -0
  46. package/src/mcp-presets.js +78 -0
  47. package/src/mcp.js +284 -0
  48. package/src/memory.js +242 -0
  49. package/src/model-discovery.js +153 -0
  50. package/src/models.js +186 -0
  51. package/src/notify.js +38 -0
  52. package/src/permissions.js +83 -0
  53. package/src/pricing.js +156 -0
  54. package/src/prompts.js +58 -0
  55. package/src/providers/index.js +135 -0
  56. package/src/providers/openai-compatible.js +171 -0
  57. package/src/routing.js +126 -0
  58. package/src/schedule.js +434 -0
  59. package/src/session-index.js +122 -0
  60. package/src/session.js +131 -0
  61. package/src/skill-lib.js +350 -0
  62. package/src/skill-registry.js +165 -0
  63. package/src/skills.js +135 -0
  64. package/src/sync-server.js +564 -0
  65. package/src/sync.js +479 -0
  66. package/src/tasks.js +126 -0
  67. package/src/titles.js +75 -0
  68. package/src/tokenizer.js +287 -0
  69. package/src/tools/bash.js +165 -0
  70. package/src/tools/fs-tools.js +346 -0
  71. package/src/tools/index.js +287 -0
  72. package/src/ui.js +643 -0
  73. package/src/update.js +222 -0
  74. package/src/web/attachments.js +49 -0
  75. package/src/web/index.html +993 -0
  76. package/src/web/server.js +1173 -0
  77. package/src/web/web-io.js +107 -0
  78. package/src/workspace.js +157 -0
package/src/routing.js ADDED
@@ -0,0 +1,126 @@
1
+ // 自动模型路由:规划类任务 → planner(如 deepseek-v4-pro),执行类 → executor(如 deepseek-v4-flash)。
2
+ // 两级判定:
3
+ // 1. 启发式:长文本 + 规划关键词 → planner;极短指令 → executor(零成本)
4
+ // 2. 分类器:用 executor 模型做一次极简分类(成本约几十 token;结果按文本哈希 LRU 缓存)
5
+ // 会话粘滞(评估 P2-1/B7):执行类会话连续保持 executor,不再逐轮分类,也避免 pro⇄flash
6
+ // 抖动反复破坏上下文缓存前缀(除非出现生成类关键词——启发式已优先处理)。
7
+ // 子代理(task)固定走 executor(子任务以执行/调研为主,便宜又够用)。
8
+
9
+ import crypto from 'node:crypto';
10
+ import { modelPreset } from './models.js';
11
+
12
+ export function routingConfig(cfg) {
13
+ const r = cfg?.routing;
14
+ if (!r || r.enabled === false) return null;
15
+ const planner = r.planner || 'deepseek-v4-pro';
16
+ const executor = r.executor || 'deepseek-v4-flash';
17
+ if (!planner || !executor || planner === executor) return null;
18
+ return { planner, executor };
19
+ }
20
+
21
+ const PLAN_HINTS =
22
+ /设计|架构|重构|审查|规划|分析|方案|评估|优化|排查|疑难|设计模式|选型|技术债|roadmap|review|design|refactor|plan|architecture|方案设计|评审/;
23
+
24
+ // 生成类任务(游戏/网页/文档等)需要大输出(planner 32K vs executor 8K),即使短句也路由 planner
25
+ const GENERATION_HINTS =
26
+ /(生成|制作|编写|创建|写|开发|实现|做).{0,30}(游戏|网页|页面|网站|应用|程序|文档|报告|简历|PPT|演示|完整|详细|小工具)|网页版|游戏/;
27
+
28
+ export function heuristicRoute(text, rc) {
29
+ const s = String(text ?? '');
30
+ if (GENERATION_HINTS.test(s)) return rc.planner; // 生成类:大输出优先
31
+ if (s.length >= 40 && PLAN_HINTS.test(s)) return rc.planner;
32
+ if (s.length <= 60) return rc.executor;
33
+ return null; // 需要分类器
34
+ }
35
+
36
+ // 分类结果缓存:文本 sha256 → 结论(同一问题反复问/粘贴同一报错不再重复分类)
37
+ const ROUTE_CACHE_MAX = 100;
38
+ const routeCache = new Map();
39
+
40
+ export async function routeTask({ cfg, provider, currentModel, text, sticky = null }) {
41
+ const rc = routingConfig(cfg);
42
+ if (!rc) return { model: currentModel, reason: null };
43
+ // 当前模型已在路由池外(用户手动指定):不干预
44
+ if (currentModel !== rc.planner && currentModel !== rc.executor) {
45
+ return { model: currentModel, reason: null };
46
+ }
47
+
48
+ const quick = heuristicRoute(text, rc);
49
+ if (quick) {
50
+ return quick === currentModel
51
+ ? { model: currentModel, reason: null }
52
+ : { model: quick, reason: quick === rc.planner ? '规划类任务' : '执行类任务' };
53
+ }
54
+
55
+ // 会话粘滞:执行类会话不再逐轮分类(生成类关键词已在启发式拦截)
56
+ if (sticky === rc.executor) {
57
+ return rc.executor === currentModel
58
+ ? { model: currentModel, reason: null }
59
+ : { model: rc.executor, reason: '会话粘滞:执行类' };
60
+ }
61
+
62
+ // 分类缓存
63
+ const key = crypto.createHash('sha256').update(String(text).slice(0, 4000)).digest('hex');
64
+ const cached = routeCache.get(key);
65
+ if (cached) {
66
+ const m = cached === 'plan' ? rc.planner : rc.executor;
67
+ return m === currentModel ? { model: currentModel, reason: null } : { model: m, reason: cached === 'plan' ? '分类器判定:规划类(缓存)' : '分类器判定:执行类(缓存)' };
68
+ }
69
+
70
+ // 分类器:用 executor 分类(最便宜)。executor 与当前模型可能分属不同服务商,
71
+ // 需按 executor 解析其 provider(评估 P2-2:硬编码模型名在自定义网关上会 404 静默失败)
72
+ const { resolveProviderConfig, createProvider } = await import('./providers/index.js');
73
+ let classifierProvider = provider;
74
+ try {
75
+ const curPc = resolveProviderConfig(cfg, currentModel);
76
+ const execPc = resolveProviderConfig(cfg, rc.executor);
77
+ if (curPc.name !== execPc.name) classifierProvider = await createProvider(cfg, rc.executor);
78
+ } catch {}
79
+ try {
80
+ const res = await classifierProvider.chat({
81
+ model: rc.executor,
82
+ messages: [
83
+ {
84
+ role: 'system',
85
+ content:
86
+ '你是任务分类器。判断用户请求属于哪类:plan(需要设计、规划、分析、审查、多步推理,或需要生成大段代码/文档/页面等长输出)或 execute(直接执行、简单问答、小改动)。只输出 JSON:{"verdict":"plan"} 或 {"verdict":"execute"}。',
87
+ },
88
+ { role: 'user', content: String(text).slice(0, 4000) },
89
+ ],
90
+ tools: [],
91
+ temperature: 0,
92
+ maxTokens: 20, // 结构化输出(评估 4.2-4):80→20
93
+ responseFormat: { type: 'json_object' },
94
+ });
95
+ let verdict = null;
96
+ try {
97
+ const j = JSON.parse(String(res.text || '').trim());
98
+ if (j?.verdict === 'plan' || j?.verdict === 'execute') verdict = j.verdict;
99
+ } catch {}
100
+ if (!verdict) {
101
+ // 正文为空/非 JSON 时用推理内容兜底(flash 可能先输出 reasoning 再输出正文)
102
+ const t = String(res.text || res.reasoning || '').trim().toLowerCase();
103
+ if (t.includes('plan')) verdict = 'plan';
104
+ else if (t.includes('exec')) verdict = 'execute';
105
+ }
106
+ if (verdict) {
107
+ if (routeCache.size >= ROUTE_CACHE_MAX) {
108
+ const first = routeCache.keys().next().value;
109
+ routeCache.delete(first);
110
+ }
111
+ routeCache.set(key, verdict);
112
+ const m = verdict === 'plan' ? rc.planner : rc.executor;
113
+ return m === currentModel ? { model: currentModel, reason: null } : { model: m, reason: verdict === 'plan' ? '分类器判定:规划类' : '分类器判定:执行类' };
114
+ }
115
+ } catch {
116
+ // 分类失败回退执行模型
117
+ }
118
+ return rc.executor === currentModel ? { model: currentModel, reason: null } : { model: rc.executor, reason: '回退执行模型' };
119
+ }
120
+
121
+ // 子代理模型选择:路由开启时固定 executor
122
+ export function subagentModel(cfg, currentModel) {
123
+ const rc = routingConfig(cfg);
124
+ if (!rc) return currentModel;
125
+ return modelPreset(rc.executor) ? rc.executor : currentModel;
126
+ }
@@ -0,0 +1,434 @@
1
+ // 任务队列与调度:定时任务(一次性/周期)与依赖编排(after/链式)。
2
+ // 架构(评估 P3-5 单守护进程调度器):一个 schedule-daemon 进程监督全部调度任务——
3
+ // 守护进程内以协程运行每任务的 runSleeper 等待/执行逻辑,到期启动 worker(复用 mingdao run
4
+ // 的独立进程机制);状态落盘 <home>/schedule/<id>.json,daemon.pid 防重复。
5
+ // 旧式逐任务 sleeper(schedule-worker)保留为 daemon 启动失败时的兜底。
6
+ // 命令族:mingdao schedule add/list/remove/pause/resume/chain;tasks/run 触发时自动 reconcile 补挂到期任务(重启自愈)。
7
+
8
+ import fs from 'node:fs';
9
+ import path from 'node:path';
10
+ import { spawn } from 'node:child_process';
11
+ import { fileURLToPath } from 'node:url';
12
+ import { startTask, readTask, killTask } from './tasks.js';
13
+
14
+ function isRunningTask(home, taskId) {
15
+ const t = readTask(home, taskId);
16
+ return Boolean(t && t.status === 'running');
17
+ }
18
+
19
+ const CLI_PATH = fileURLToPath(new URL('./cli.js', import.meta.url));
20
+
21
+ import { isPeakHour, deferToOffpeak } from './pricing.js';
22
+
23
+ export function scheduleDir(home) {
24
+ return path.join(home, 'schedule');
25
+ }
26
+
27
+ export function parseInterval(s) {
28
+ const m = /^(\d+)(s|m|h|d)$/.exec(String(s).trim());
29
+ if (!m) return null;
30
+ const n = Number(m[1]);
31
+ const unit = { s: 1000, m: 60000, h: 3600000, d: 86400000 }[m[2]];
32
+ if (!(n > 0)) return null;
33
+ return n * unit;
34
+ }
35
+
36
+ // 解析 --at:'YYYY-MM-DD HH:MM' 或 'HH:MM'(今天;已过则明天)
37
+ export function parseAt(s) {
38
+ const t = String(s).trim();
39
+ const full = /^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2})$/.exec(t);
40
+ if (full) {
41
+ const d = new Date(Number(full[1]), Number(full[2]) - 1, Number(full[3]), Number(full[4]), Number(full[5]));
42
+ if (!isNaN(d.getTime())) return d.getTime();
43
+ return null;
44
+ }
45
+ const hm = /^(\d{2}):(\d{2})$/.exec(t);
46
+ if (hm) {
47
+ const d = new Date();
48
+ d.setHours(Number(hm[1]), Number(hm[2]), 0, 0);
49
+ if (d.getTime() <= Date.now()) d.setDate(d.getDate() + 1);
50
+ return d.getTime();
51
+ }
52
+ return null;
53
+ }
54
+
55
+ export function listSchedules(home) {
56
+ const dir = scheduleDir(home);
57
+ let files;
58
+ try {
59
+ files = fs.readdirSync(dir).filter((f) => f.endsWith('.json'));
60
+ } catch {
61
+ return [];
62
+ }
63
+ const out = [];
64
+ for (const f of files) {
65
+ try {
66
+ const j = JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8'));
67
+ if (j && j.id) out.push(j);
68
+ } catch {}
69
+ }
70
+ return out.sort((a, b) => (a.nextRunAt || 0) - (b.nextRunAt || 0));
71
+ }
72
+
73
+ function isValidScheduleId(id) {
74
+ return typeof id === 'string' && /^[a-z0-9]+$/.test(id) && id.length >= 2 && id.length <= 40;
75
+ }
76
+
77
+ export function readSchedule(home, id) {
78
+ if (!isValidScheduleId(id)) return null; // 防 id 路径穿越
79
+ try {
80
+ return JSON.parse(fs.readFileSync(path.join(scheduleDir(home), id + '.json'), 'utf8'));
81
+ } catch {
82
+ return null;
83
+ }
84
+ }
85
+
86
+ export function writeSchedule(home, job) {
87
+ if (!isValidScheduleId(job?.id)) return null;
88
+ fs.mkdirSync(scheduleDir(home), { recursive: true });
89
+ const target = path.join(scheduleDir(home), job.id + '.json');
90
+ const tmp = target + '.tmp';
91
+ fs.writeFileSync(tmp, JSON.stringify(job, null, 2) + '\n');
92
+ fs.renameSync(tmp, target);
93
+ return job;
94
+ }
95
+
96
+ // 新建调度任务;after: 依赖的任务 ID(全部成功后才启动,任一失败则跳过)
97
+ export function addSchedule(home, question, { at, every, after, permission, model, cwd, anchor, offpeak }) {
98
+ const id = 'sc' + Date.now().toString(36) + Math.random().toString(36).slice(2, 5);
99
+ const interval = every != null ? parseInterval(every) : null;
100
+ const afterList = Array.isArray(after) ? after.filter(Boolean).map(String) : after ? String(after).split(',').map((x) => x.trim()).filter(Boolean) : [];
101
+ const hasAfter = afterList.length > 0 || (after !== undefined && after !== null);
102
+ if (at == null && interval == null && !hasAfter && afterList.length === 0 && after === undefined) return { error: '需要 --at、--every 或 --after 之一' };
103
+ if (at != null && parseAt(at) == null) return { error: `无法解析时间 "${at}"(格式:YYYY-MM-DD HH:MM 或 HH:MM)` };
104
+ if (every != null && interval == null) return { error: `无法解析周期 "${every}"(格式:<数字>s|m|h|d)` };
105
+ let nextRunAt = null;
106
+ if (at != null) nextRunAt = parseAt(at);
107
+ else if (interval != null) {
108
+ if (anchor) {
109
+ const a = parseAt(anchor);
110
+ if (a == null) return { error: `无法解析每日锚点 "${anchor}"(格式 HH:MM)` };
111
+ nextRunAt = a;
112
+ while (nextRunAt <= Date.now()) nextRunAt += interval;
113
+ } else {
114
+ nextRunAt = Date.now() + interval;
115
+ }
116
+ } else {
117
+ // 仅依赖或空依赖(链头):立即触发,由 sleeper 按依赖/即时逻辑处理
118
+ nextRunAt = Date.now();
119
+ }
120
+ const job = {
121
+ id,
122
+ status: 'pending',
123
+ kind: interval != null ? 'every' : at != null ? 'once' : 'after',
124
+ question: String(question).slice(0, 300),
125
+ interval: interval || null,
126
+ anchor: anchor || null,
127
+ after: afterList,
128
+ permission: permission || null,
129
+ model: model || null,
130
+ cwd: cwd || process.cwd(),
131
+ nextRunAt,
132
+ lastRunAt: null,
133
+ lastTaskId: null,
134
+ runs: 0,
135
+ pid: null,
136
+ createdAt: Date.now(),
137
+ note: offpeak ? '避峰:高峰时段自动顺延到 14:00 后执行' : '',
138
+ offpeak: Boolean(offpeak),
139
+ };
140
+ writeSchedule(home, job);
141
+ if (!daemonAlive(home)) spawnDaemon(home); // 单守护进程监督(评估 P3-5)
142
+ return { id, job };
143
+ }
144
+
145
+ export function removeSchedule(home, id) {
146
+ const job = readSchedule(home, id);
147
+ if (!job) return false;
148
+ if (job.pid) {
149
+ try {
150
+ process.kill(job.pid, 'SIGTERM');
151
+ } catch {}
152
+ }
153
+ // 正在跑的 worker 同步停止,避免成孤儿继续执行
154
+ if (job.lastTaskId && isRunningTask(home, job.lastTaskId)) killTask(home, job.lastTaskId);
155
+ try {
156
+ fs.unlinkSync(path.join(scheduleDir(home), id + '.json'));
157
+ } catch {}
158
+ return true;
159
+ }
160
+
161
+ export function pauseSchedule(home, id) {
162
+ const job = readSchedule(home, id);
163
+ if (!job) return false;
164
+ if (job.pid) {
165
+ try {
166
+ process.kill(job.pid, 'SIGTERM');
167
+ } catch {}
168
+ }
169
+ if (job.lastTaskId && isRunningTask(home, job.lastTaskId)) killTask(home, job.lastTaskId);
170
+ writeSchedule(home, { ...job, status: 'paused', pid: null, lastTaskId: null });
171
+ return true;
172
+ }
173
+
174
+ export function resumeSchedule(home, id) {
175
+ const job = readSchedule(home, id);
176
+ if (!job || job.status !== 'paused') return false;
177
+ let next = job.nextRunAt;
178
+ if (job.kind === 'every') {
179
+ next = job.anchor ? nextAnchorAfter(job.anchor, job.interval) || Date.now() + job.interval : Date.now() + job.interval;
180
+ } else if (job.kind === 'once') {
181
+ if (next && next <= Date.now()) next = Date.now() + 30000; // 已过期的一次性任务恢复后 30s 执行
182
+ } else next = Date.now();
183
+ const nextJob = { ...job, status: 'pending', nextRunAt: next };
184
+ writeSchedule(home, nextJob);
185
+ // 审计修复:守护进程在时只更新状态交给 daemon 接管;否则旧式 sleeper 兜底(避免双跑)
186
+ if (process.env.MINGDAO_NO_DAEMON === '1' || !daemonAlive(home)) spawnSleeper(home, nextJob);
187
+ return true;
188
+ }
189
+
190
+ // 每日锚点:锚点时刻(HH:MM)对齐到 now 之后的最近一次
191
+ function nextAnchorAfter(anchor, interval) {
192
+ const a = parseAt(anchor);
193
+ if (a == null) return null;
194
+ let n = a;
195
+ while (n <= Date.now()) n += interval;
196
+ return n;
197
+ }
198
+
199
+ // 链式编排:A→B→C,后者依赖前者成功
200
+ export function chainSchedules(home, questions, opts = {}) {
201
+ const ids = [];
202
+ let prev = null;
203
+ for (const q of questions) {
204
+ const after = prev ? [prev] : [];
205
+ const r = addSchedule(home, q, { ...opts, after });
206
+ if (r.error) return r;
207
+ ids.push(r.id);
208
+ prev = r.id;
209
+ }
210
+ return { ids };
211
+ }
212
+
213
+ export function sleeperAlive(pid) {
214
+ if (!pid) return false;
215
+ try {
216
+ process.kill(pid, 0);
217
+ return true;
218
+ } catch {
219
+ return false;
220
+ }
221
+ }
222
+
223
+ function spawnSleeper(home, job) {
224
+ const child = spawn(process.execPath, [CLI_PATH, 'schedule-worker', job.id], {
225
+ detached: true,
226
+ stdio: 'ignore',
227
+ env: { ...process.env, MINGDAO_HOME: home },
228
+ });
229
+ writeSchedule(home, { ...job, pid: child.pid });
230
+ child.unref();
231
+ return child.pid;
232
+ }
233
+
234
+ // —— 单守护进程调度器(评估 P3-5):一进程监督全部任务 ——
235
+ export function daemonPidFile(home) {
236
+ return path.join(scheduleDir(home), 'daemon.pid');
237
+ }
238
+ // pid 文件内容 "pid nonce":校验进程存在 + 命令行含同一 nonce,防止陈旧 PID 被无关进程复用而误判(审计 P2-7)
239
+ export function daemonAlive(home) {
240
+ try {
241
+ const [pidStr, nonce] = fs.readFileSync(daemonPidFile(home), 'utf8').trim().split(/\s+/);
242
+ const pid = Number(pidStr);
243
+ if (!pid || !nonce) return false;
244
+ process.kill(pid, 0);
245
+ try {
246
+ const cmdline = fs.readFileSync(`/proc/${pid}/cmdline`, 'utf8');
247
+ if (!cmdline.includes(nonce)) return false;
248
+ } catch {
249
+ // 非 Linux 读不到 /proc:仅校验进程存活(尽力而为)
250
+ }
251
+ return true;
252
+ } catch {
253
+ return false;
254
+ }
255
+ }
256
+ export function stopDaemon(home) {
257
+ try {
258
+ const pid = Number(fs.readFileSync(daemonPidFile(home), 'utf8'));
259
+ if (pid) {
260
+ try {
261
+ process.kill(pid, 'SIGTERM');
262
+ } catch {}
263
+ }
264
+ } catch {}
265
+ try {
266
+ fs.rmSync(daemonPidFile(home), { force: true });
267
+ } catch {}
268
+ return true;
269
+ }
270
+ export function spawnDaemon(home) {
271
+ if (daemonAlive(home)) return true;
272
+ const nonce = Math.random().toString(36).slice(2, 10);
273
+ const child = spawn(process.execPath, [CLI_PATH, 'schedule-daemon', nonce], {
274
+ detached: true,
275
+ stdio: 'ignore',
276
+ env: { ...process.env, MINGDAO_HOME: home },
277
+ });
278
+ try {
279
+ fs.writeFileSync(daemonPidFile(home), `${child.pid} ${nonce}`);
280
+ } catch {}
281
+ child.unref();
282
+ return true;
283
+ }
284
+
285
+ // 重启自愈:有非终态任务且 daemon 不在 → 拉起单守护(失败才回退旧式逐任务 sleeper)
286
+ export function reconcileSchedules(home) {
287
+ const jobs = listSchedules(home);
288
+ const hasPending = jobs.some((j) => j.status === 'pending' || j.status === 'running');
289
+ if (!hasPending) return;
290
+ if (daemonAlive(home) || process.env.MINGDAO_NO_DAEMON === '1') {
291
+ if (process.env.MINGDAO_NO_DAEMON === '1') {
292
+ // 兜底路径(旧式)
293
+ for (const job of jobs) {
294
+ if (job.status !== 'pending' && job.status !== 'running') continue;
295
+ if (sleeperAlive(job.pid)) continue;
296
+ if (job.lastTaskId && isRunningTask(home, job.lastTaskId)) continue;
297
+ if (job.kind === 'after' || (job.nextRunAt && job.nextRunAt <= Date.now() + 5000)) spawnSleeper(home, job);
298
+ }
299
+ }
300
+ return;
301
+ }
302
+ spawnDaemon(home);
303
+ }
304
+ // —— sleeper 主循环(schedule-worker 进程内运行)——
305
+ export async function runSleeper(home, id) {
306
+ const job = readSchedule(home, id);
307
+ if (!job) return;
308
+
309
+ const wait = (ms) => new Promise((r) => setTimeout(r, ms));
310
+
311
+ // 依赖检查:支持两种依赖——调度任务 id(chain 编排)或后台任务 id(mingdao run 输出)
312
+ async function depsSatisfied(deps) {
313
+ for (const dep of deps) {
314
+ const sch = readSchedule(home, dep);
315
+ if (sch) {
316
+ if (sch.status === 'done') continue;
317
+ if (sch.status === 'failed' || sch.status === 'skipped' || sch.status === 'paused') return 'failed';
318
+ return false; // pending / running
319
+ }
320
+ const t = readTask(home, dep);
321
+ if (!t) return 'failed'; // 依赖任务不存在(拼错/已删):判失败并报错,避免永续轮询
322
+ if (t.status === 'running') return false;
323
+ if (t.status !== 'done') return 'failed';
324
+ }
325
+ return true;
326
+ }
327
+
328
+ const runOnce = async () => {
329
+ // 避峰(评估 A2/Kimi P-1):高峰时段(北京工作日 9:00–14:00)顺延到 14:00 执行,输入价省 50%
330
+ if (job.offpeak && isPeakHour(new Date())) {
331
+ const defer = deferToOffpeak(new Date());
332
+ const curN = readSchedule(home, id);
333
+ if (curN) writeSchedule(home, { ...curN, note: `避峰等待至 ${defer.toISOString().slice(11, 16)}Z+8` });
334
+ await wait(defer.getTime() - Date.now() + 2000);
335
+ }
336
+ if (job.after?.length) {
337
+ const st = await depsSatisfied(job.after);
338
+ if (st === false) return 'pending';
339
+ if (st === 'failed') return 'skipped';
340
+ }
341
+ const task = startTask(home, job.question, {
342
+ permission: job.permission || undefined,
343
+ model: job.model || undefined,
344
+ cwd: job.cwd || process.cwd(),
345
+ });
346
+ // 轮询 worker 状态直至结束(最长 2 小时);超时清理 worker 防孤儿(审计 P2-8)
347
+ let t = readTask(home, task.id);
348
+ const deadline = Date.now() + 2 * 3600000;
349
+ while (t && t.status === 'running' && Date.now() < deadline) {
350
+ await wait(3000);
351
+ t = readTask(home, task.id);
352
+ }
353
+ if (t && t.status === 'running') {
354
+ killTask(home, task.id);
355
+ t = { ...t, status: 'timedout' };
356
+ }
357
+ const cur0 = readSchedule(home, id);
358
+ if (!cur0) return 'failed'; // 任务已被删除:停止后续写入
359
+ const history = [...(cur0?.history || [])];
360
+ history.push({
361
+ taskId: task.id,
362
+ status: t?.status || 'unknown',
363
+ at: Date.now(),
364
+ durationMs: t?.durationMs ?? null,
365
+ text: (t?.text || t?.error || '').slice(0, 200),
366
+ });
367
+ if (history.length > 50) history.shift();
368
+ writeSchedule(home, {
369
+ ...cur0,
370
+ lastRunAt: Date.now(),
371
+ lastTaskId: task.id,
372
+ runs: (cur0?.runs || 0) + 1,
373
+ history,
374
+ });
375
+ return t?.status === 'done' ? 'done' : t?.status === 'timedout' ? 'timedout' : 'failed';
376
+ };
377
+
378
+ for (;;) {
379
+ const cur = readSchedule(home, id);
380
+ if (!cur || cur.status === 'paused') return;
381
+ const now = Date.now();
382
+ if (cur.kind === 'every') {
383
+ if (!cur.nextRunAt || cur.nextRunAt > now) {
384
+ await wait(Math.min(Math.max((cur.nextRunAt || now) - now, 1000), 60000));
385
+ continue;
386
+ }
387
+ writeSchedule(home, { ...cur, status: 'running' });
388
+ await runOnce();
389
+ const cur2 = readSchedule(home, id);
390
+ if (!cur2) return;
391
+ // 下次 = 完成时间 + 周期(不追赶错过的档期);有每日锚点时对齐锚点,避免逐日漂移
392
+ const next = cur2.anchor ? nextAnchorAfter(cur2.anchor, cur2.interval) || Date.now() + cur2.interval : Date.now() + cur2.interval;
393
+ writeSchedule(home, { ...cur2, status: 'pending', nextRunAt: next });
394
+ } else if (cur.kind === 'once') {
395
+ if (cur.nextRunAt && cur.nextRunAt > now) {
396
+ await wait(Math.min(cur.nextRunAt - now, 60000));
397
+ continue;
398
+ }
399
+ writeSchedule(home, { ...cur, status: 'running' });
400
+ const result = await runOnce();
401
+ const cur2 = readSchedule(home, id);
402
+ if (cur2) writeSchedule(home, { ...cur2, status: result });
403
+ return;
404
+ } else {
405
+ // after:轮询依赖,满足即执行一次后结束;任一依赖失败则跳过
406
+ const st = await depsSatisfied(cur.after || []);
407
+ if (st === false) {
408
+ await wait(3000);
409
+ continue;
410
+ }
411
+ if (st === 'failed') {
412
+ writeSchedule(home, { ...cur, status: 'skipped' });
413
+ return;
414
+ }
415
+ writeSchedule(home, { ...cur, status: 'running' });
416
+ const result = await runOnce();
417
+ const cur2 = readSchedule(home, id);
418
+ if (cur2) writeSchedule(home, { ...cur2, status: result });
419
+ return;
420
+ }
421
+ }
422
+ }
423
+
424
+ export function formatScheduleRow(j) {
425
+ const mark = j.status === 'pending' ? '⏳' : j.status === 'running' ? '▶' : j.status === 'paused' ? '⏸' : j.status === 'done' ? '✓' : '✖';
426
+ const when =
427
+ j.kind === 'every'
428
+ ? `每 ${j.interval / (j.interval >= 86400000 ? 86400000 : j.interval >= 3600000 ? 3600000 : j.interval >= 60000 ? 60000 : 1000)}${j.interval >= 86400000 ? 'd' : j.interval >= 3600000 ? 'h' : j.interval >= 60000 ? 'm' : 's'}`
429
+ : j.kind === 'once'
430
+ ? new Date(j.nextRunAt || 0).toLocaleString()
431
+ : `依赖 ${j.after.join(',')}`;
432
+ const last = j.lastRunAt ? new Date(j.lastRunAt).toLocaleTimeString() : '';
433
+ return `${mark} ${j.id} ${j.status.padEnd(7)} ${when.padEnd(20)} 已运行 ${j.runs} 次 ${last} ${j.question}`;
434
+ }
@@ -0,0 +1,122 @@
1
+ // 会话检索索引(P3-2):词表倒排 + 增量同步,替代每次全量扫描全部会话文件。
2
+ // 索引文件 <mingdao-home>/sessions-index.json:
3
+ // { files: { "<会话名>": { mtime, size, terms: {词: 次数} } } }
4
+ // 同步策略:查询前对每个会话文件做 mtime/size 对比——未变化直接用缓存词表(O(词表)),
5
+ // 变化/新增才重新分词(增量);已删除文件自动清理;>8MB 超大文件跳过并移除条目。
6
+ // 分词:英文/数字/路径按词(小写),中文按 bigram + 单字,查询串同口径分词后按 AND 匹配。
7
+
8
+ import fs from 'node:fs';
9
+ import path from 'node:path';
10
+ import { mingdaoHome, ensureHome } from './config.js';
11
+
12
+ const INDEX_MAX_FILE = 8 * 1024 * 1024;
13
+
14
+ export function sessionIndexFile() {
15
+ return path.join(mingdaoHome(), 'sessions-index.json');
16
+ }
17
+
18
+ export function loadSessionIndex() {
19
+ try {
20
+ const j = JSON.parse(fs.readFileSync(sessionIndexFile(), 'utf8'));
21
+ return j && typeof j === 'object' && j.files && typeof j.files === 'object' ? j : { files: {} };
22
+ } catch {
23
+ return { files: {} };
24
+ }
25
+ }
26
+
27
+ export function saveSessionIndex(idx) {
28
+ try {
29
+ ensureHome();
30
+ const target = sessionIndexFile();
31
+ const tmp = target + '.tmp';
32
+ fs.writeFileSync(tmp, JSON.stringify(idx) + '\n');
33
+ fs.renameSync(tmp, target);
34
+ } catch {}
35
+ }
36
+
37
+ // 分词:ASCII 词(小写)+ 中文 bigram + 中文单字;返回 Map<词, 次数>
38
+ export function tokenize(text) {
39
+ const terms = new Map();
40
+ const add = (t) => {
41
+ if (t) terms.set(t, (terms.get(t) || 0) + 1);
42
+ };
43
+ const s = String(text ?? '');
44
+ for (const m of s.matchAll(/[A-Za-z0-9_./-]{2,}/g)) {
45
+ add(m[0].toLowerCase());
46
+ // 审计 B6:'.'/'/' 分隔的子段也成词("abc.def" 同时可被 "def" 命中)
47
+ for (const sub of m[0].split(/[./]/)) {
48
+ if (sub.length >= 2) add(sub.toLowerCase());
49
+ }
50
+ }
51
+ const cjk = s.replace(/[^\u4e00-\u9fff\u3040-\u30ff\uac00-\ud7af]/g, ' ');
52
+ for (const run of cjk.split(/\s+/)) {
53
+ if (!run) continue;
54
+ if (run.length === 1) {
55
+ add(run);
56
+ continue;
57
+ }
58
+ for (let i = 0; i + 1 < run.length; i++) add(run.slice(i, i + 2));
59
+ for (const ch of run) add(ch);
60
+ }
61
+ return terms;
62
+ }
63
+
64
+ // 从会话文件提取可检索文本(user/assistant 内容拼接)
65
+ export function extractSessionText(file) {
66
+ const parts = [];
67
+ for (const line of fs.readFileSync(file, 'utf8').split('\n')) {
68
+ if (!line.trim()) continue;
69
+ let m;
70
+ try {
71
+ m = JSON.parse(line);
72
+ } catch {
73
+ continue;
74
+ }
75
+ if ((m.role === 'user' || m.role === 'assistant') && typeof m.content === 'string' && m.content) {
76
+ parts.push(m.content);
77
+ }
78
+ }
79
+ return parts.join('\n');
80
+ }
81
+
82
+ // 增量同步索引(对比 mtime/size,只重算变化的文件;清理已删除条目)
83
+ export function syncSessionIndex(home, sessions) {
84
+ const idx = loadSessionIndex();
85
+ const dir = path.join(home, 'sessions');
86
+ let changed = false;
87
+ for (const s of sessions) {
88
+ let st;
89
+ try {
90
+ st = fs.statSync(s.file);
91
+ } catch {
92
+ delete idx.files[s.name];
93
+ changed = true;
94
+ continue;
95
+ }
96
+ const cached = idx.files[s.name];
97
+ if (cached && cached.mtime === st.mtimeMs && cached.size === st.size) continue; // 未变化:直接用缓存
98
+ changed = true;
99
+ if (st.size > INDEX_MAX_FILE) {
100
+ delete idx.files[s.name]; // 超大文件不索引
101
+ continue;
102
+ }
103
+ try {
104
+ idx.files[s.name] = {
105
+ mtime: st.mtimeMs,
106
+ size: st.size,
107
+ terms: Object.fromEntries(tokenize(extractSessionText(s.file))),
108
+ };
109
+ } catch {
110
+ delete idx.files[s.name];
111
+ }
112
+ }
113
+ // 清理已删除的会话条目(防索引无限膨胀)
114
+ for (const name of Object.keys(idx.files)) {
115
+ if (!fs.existsSync(path.join(dir, name))) {
116
+ delete idx.files[name];
117
+ changed = true;
118
+ }
119
+ }
120
+ if (changed) saveSessionIndex(idx);
121
+ return idx;
122
+ }