dsh-plugin-t-expert 0.2.16 → 0.3.24
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/README.md +100 -219
- package/THIRD-PARTY-NOTICES +18 -1
- package/lib/client.js +1590 -187
- package/lib/i18n.js +30 -0
- package/lib/index.js +194 -3
- package/lib/remote-schemas.js +82 -0
- package/lib/remote.js +182 -1
- package/lib/schedule.js +436 -0
- package/lib/skill.js +21 -5
- package/package.json +4 -2
- package/vendor/third-party-licenses/README.md +1 -1
- package/vendor/third-party-licenses/croner.LICENSE +22 -0
- package/skills/t-expert-manager/SKILL.md +0 -119
- package/skills/t-expert-manager/references/ops-reference.md +0 -100
package/lib/schedule.js
ADDED
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* T专家 定时事项引擎:按 cron 表达式把提示词交给一个**全新的** agent 会话执行。
|
|
4
|
+
*
|
|
5
|
+
* 为什么自建而不是直接依赖 `@weibaohui/dsh-tasks`:用户明确要求两个插件可以同时安装、
|
|
6
|
+
* 同时可用。所以这里与它**零共享**——
|
|
7
|
+
* · 数据不落它的 `dsh_tasks` 存储域,而是 T专家 自己的数据目录(`<root 同级>/schedule.json`);
|
|
8
|
+
* · 服务名 `tTeamSchedule`、错误码前缀 `tTeam/schedule-*`、消息来源 `plugin: dsh-plugin-t-expert`
|
|
9
|
+
* 全部独立,两个插件的任务互不可见、互不干扰。
|
|
10
|
+
*
|
|
11
|
+
* 三件事在这里闭环:
|
|
12
|
+
* 1. 持久化:一个 JSON 文件,原子写(与 `teams.json` 同级、同风格);
|
|
13
|
+
* 2. 调度:每个启用项一个 croner job,宿主启动时全量重建,卸载时全部 stop;
|
|
14
|
+
* 3. 执行:新建 agent 会话 → 挂部署默认 preset(不挂就没有工具)→ 提交提示词。
|
|
15
|
+
*
|
|
16
|
+
* 开关语义(用户口径):`setArmed(false)` = 定时事项功能整体停用——**停止全部定时触发**
|
|
17
|
+
* 且拒绝手动执行;任务与执行记录完整保留,重新 armed 后自动恢复调度。
|
|
18
|
+
*/
|
|
19
|
+
import { randomUUID } from "node:crypto";
|
|
20
|
+
import { readFileSync } from "node:fs";
|
|
21
|
+
import { Cron } from "croner";
|
|
22
|
+
import { writeFileAtomic } from "./catalog.js";
|
|
23
|
+
|
|
24
|
+
/** 定时事项服务名(remote 侧的服务访问器用它)。 */
|
|
25
|
+
export const SCHEDULE_SERVICE = "tTeamSchedule";
|
|
26
|
+
/** 数据文件名(落在 T专家 数据根下,与 teams.json 同级)。 */
|
|
27
|
+
export const SCHEDULE_FILE_NAME = "schedule.json";
|
|
28
|
+
/** 每条事项保留的执行记录条数(最旧的先丢)。 */
|
|
29
|
+
export const MAX_RUNS = 20;
|
|
30
|
+
/** 事项条数上限(防止一次误操作把设置文件写成天文数字)。 */
|
|
31
|
+
export const MAX_ITEMS = 200;
|
|
32
|
+
/** 标题 / 提示词的字符上限。 */
|
|
33
|
+
export const MAX_TITLE_CHARS = 200;
|
|
34
|
+
export const MAX_PROMPT_CHARS = 20000;
|
|
35
|
+
/** 写进会话消息来源的插件名(与 dsh-tasks 区分开,两个插件的任务是两回事)。 */
|
|
36
|
+
export const PLUGIN_ID = "dsh-plugin-t-expert";
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* cron 表达式校验:构造一个 `paused` 的探测 job(只解析、不真的挂定时器),
|
|
40
|
+
* 随即 stop 掉,保证探测本身不会让事件循环多活一秒。
|
|
41
|
+
* @param {unknown} expression - 待校验的表达式。
|
|
42
|
+
* @param {(key: string, params?: Record<string, unknown>) => string} t - host 侧文案函数。
|
|
43
|
+
*/
|
|
44
|
+
export function validateCron(expression, t) {
|
|
45
|
+
const text = typeof expression === "string" ? expression.trim() : "";
|
|
46
|
+
if (text === "") throw new Error(t("schedule.cronEmpty"));
|
|
47
|
+
try {
|
|
48
|
+
const probe = new Cron(text, { paused: true }, () => undefined);
|
|
49
|
+
probe.stop();
|
|
50
|
+
} catch (error) {
|
|
51
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
52
|
+
throw new Error(t("schedule.invalidCron", { detail }));
|
|
53
|
+
}
|
|
54
|
+
return text;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** 该表达式接下来的若干次触发时间(ISO 串),供面板在保存前预览。 */
|
|
58
|
+
export function previewRuns(expression, t, count = 5) {
|
|
59
|
+
const text = validateCron(expression, t);
|
|
60
|
+
const probe = new Cron(text, { paused: true });
|
|
61
|
+
try {
|
|
62
|
+
return probe.nextRuns(count).map((date) => date.toISOString());
|
|
63
|
+
} finally {
|
|
64
|
+
probe.stop();
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** 短本地时间戳(`MM-DD HH:mm`):同一个事项的多次运行在侧栏里要能区分开。 */
|
|
69
|
+
export function runStamp(iso) {
|
|
70
|
+
const date = new Date(iso);
|
|
71
|
+
const pad = (value) => String(value).padStart(2, "0");
|
|
72
|
+
return `${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** 追加一条执行记录(超过上限丢最旧的),并刷新最近一次执行摘要字段。 */
|
|
76
|
+
export function withRun(item, entry) {
|
|
77
|
+
const runs = [...(Array.isArray(item.runs) ? item.runs : []), entry].slice(-MAX_RUNS);
|
|
78
|
+
const next = { ...item, runs, lastRunAt: entry.at };
|
|
79
|
+
// 成功时**删掉**失败字段而不是置 undefined:线上(remote 严格 schema)与 JSON 都不需要
|
|
80
|
+
// 一个"值为 undefined 的键",留着只会让序列化前后形状不一致。
|
|
81
|
+
if (entry.ok === true) delete next.lastRunError;
|
|
82
|
+
else next.lastRunError = entry.error || "failed";
|
|
83
|
+
return next;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** 校验一条事项的入参(面板提交的形状);返回规范化后的字段。 */
|
|
87
|
+
export function normalizeInput(input, t) {
|
|
88
|
+
if (input === null || typeof input !== "object") throw new Error(t("schedule.inputInvalid"));
|
|
89
|
+
const raw = /** @type {Record<string, unknown>} */ (input);
|
|
90
|
+
const title = typeof raw.title === "string" ? raw.title.trim() : "";
|
|
91
|
+
const prompt = typeof raw.prompt === "string" ? raw.prompt : "";
|
|
92
|
+
if (title === "") throw new Error(t("schedule.titleEmpty"));
|
|
93
|
+
if (title.length > MAX_TITLE_CHARS) throw new Error(t("schedule.titleTooLong", { limit: MAX_TITLE_CHARS }));
|
|
94
|
+
if (prompt.trim() === "") throw new Error(t("schedule.promptEmpty"));
|
|
95
|
+
if (prompt.length > MAX_PROMPT_CHARS) throw new Error(t("schedule.promptTooLong", { limit: MAX_PROMPT_CHARS }));
|
|
96
|
+
const cron = validateCron(raw.cron, t);
|
|
97
|
+
const enabled = raw.enabled !== false;
|
|
98
|
+
const workspaceId = typeof raw.workspaceId === "string" && raw.workspaceId !== "" ? raw.workspaceId : undefined;
|
|
99
|
+
return { title, prompt, cron, enabled, ...(workspaceId === undefined ? {} : { workspaceId }) };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* 创建定时事项引擎。
|
|
104
|
+
*
|
|
105
|
+
* @param {object} options
|
|
106
|
+
* @param {any} options.ctx - 插件上下文(用 `ctx.get(...)` 懒查可选服务)。
|
|
107
|
+
* @param {string} options.file - 数据文件绝对路径。
|
|
108
|
+
* @param {any} options.logger - cordis logger(可缺省)。
|
|
109
|
+
* @param {(key: string, params?: Record<string, unknown>) => string} options.t - host 文案函数。
|
|
110
|
+
* @param {string} options.defaultCwd - 不绑定工作区时的会话工作目录。
|
|
111
|
+
* @param {boolean} [options.armed] - 初始是否启用调度(= 界面开关的当前值)。
|
|
112
|
+
* @param {(text: string) => Promise<string>} [options.expandPrompt] - 提交前的提示词改写
|
|
113
|
+
* (提示词里挑中的 `@专家名` 由它落成召唤指令)。缺省就是原文。
|
|
114
|
+
* @param {(path: string, title?: string) => Promise<{ id: string, title: string, path: string }>} [options.ensureWorkspace]
|
|
115
|
+
* - 准备定时任务的工作区(默认「定时任务」工作区,或面板里填的自定义目录)。缺省则面板该项不可用。
|
|
116
|
+
*/
|
|
117
|
+
export function createScheduleEngine(options) {
|
|
118
|
+
const { ctx, file, logger, t, defaultCwd } = options;
|
|
119
|
+
/** 工作区准备(默认「定时任务」工作区 / 自定义目录),由 host 注入。 */
|
|
120
|
+
const ensureWorkspace = typeof options.ensureWorkspace === "function" ? options.ensureWorkspace : undefined;
|
|
121
|
+
/** 提交前的提示词改写:没有注入就原样提交(定时任务本身不依赖它)。 */
|
|
122
|
+
const expandPrompt = typeof options.expandPrompt === "function"
|
|
123
|
+
? options.expandPrompt
|
|
124
|
+
: async (text) => text;
|
|
125
|
+
/** @type {Map<string, any>} id → 事项 */
|
|
126
|
+
const items = new Map();
|
|
127
|
+
/** @type {Map<string, any>} id → croner job */
|
|
128
|
+
const jobs = new Map();
|
|
129
|
+
/** 正在执行中的事项 id:同一事项不并发跑两次。 */
|
|
130
|
+
const running = new Set();
|
|
131
|
+
let armed = options.armed !== false;
|
|
132
|
+
/** 写盘串行链:并发变更不能交错写同一个文件。 */
|
|
133
|
+
let writeChain = Promise.resolve();
|
|
134
|
+
|
|
135
|
+
/** 读盘(同步,apply 期就要拿到数据)。读不动时**出声**并按空列表继续。 */
|
|
136
|
+
function load() {
|
|
137
|
+
let text;
|
|
138
|
+
try {
|
|
139
|
+
text = readFileSync(file, "utf8");
|
|
140
|
+
} catch (error) {
|
|
141
|
+
const code = /** @type {any} */ (error)?.code;
|
|
142
|
+
if (code !== "ENOENT") {
|
|
143
|
+
logger?.warn?.(`[t-team] 定时事项数据文件读取失败(按空列表继续):${file}:${error instanceof Error ? error.message : String(error)}`);
|
|
144
|
+
}
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
try {
|
|
148
|
+
const parsed = JSON.parse(text);
|
|
149
|
+
const list = Array.isArray(parsed?.items) ? parsed.items : [];
|
|
150
|
+
for (const entry of list) {
|
|
151
|
+
if (entry === null || typeof entry !== "object") continue;
|
|
152
|
+
if (typeof entry.id !== "string" || typeof entry.cron !== "string") continue;
|
|
153
|
+
items.set(entry.id, {
|
|
154
|
+
id: entry.id,
|
|
155
|
+
title: typeof entry.title === "string" ? entry.title : "",
|
|
156
|
+
prompt: typeof entry.prompt === "string" ? entry.prompt : "",
|
|
157
|
+
cron: entry.cron,
|
|
158
|
+
enabled: entry.enabled !== false,
|
|
159
|
+
...(typeof entry.workspaceId === "string" && entry.workspaceId !== "" ? { workspaceId: entry.workspaceId } : {}),
|
|
160
|
+
createdAt: typeof entry.createdAt === "string" ? entry.createdAt : new Date().toISOString(),
|
|
161
|
+
updatedAt: typeof entry.updatedAt === "string" ? entry.updatedAt : new Date().toISOString(),
|
|
162
|
+
...(typeof entry.lastRunAt === "string" ? { lastRunAt: entry.lastRunAt } : {}),
|
|
163
|
+
...(typeof entry.lastRunError === "string" ? { lastRunError: entry.lastRunError } : {}),
|
|
164
|
+
runs: Array.isArray(entry.runs) ? entry.runs.slice(-MAX_RUNS) : [],
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
} catch (error) {
|
|
168
|
+
logger?.warn?.(`[t-team] 定时事项数据文件解析失败(按空列表继续):${file}:${error instanceof Error ? error.message : String(error)}`);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** 落盘:串行 + 原子写(临时文件 + rename),失败只告警不打断内存态。 */
|
|
173
|
+
function persist() {
|
|
174
|
+
const payload = `${JSON.stringify({ version: 1, items: [...items.values()] }, null, 2)}\n`;
|
|
175
|
+
writeChain = writeChain
|
|
176
|
+
.then(() => writeFileAtomic(file, payload))
|
|
177
|
+
.catch((error) => {
|
|
178
|
+
logger?.warn?.(`[t-team] 定时事项写盘失败:${file}:${error instanceof Error ? error.message : String(error)}`);
|
|
179
|
+
});
|
|
180
|
+
return writeChain;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** 停掉一个 job(幂等)。 */
|
|
184
|
+
function stopJob(id) {
|
|
185
|
+
const job = jobs.get(id);
|
|
186
|
+
if (job === undefined) return;
|
|
187
|
+
try {
|
|
188
|
+
job.stop();
|
|
189
|
+
} catch {
|
|
190
|
+
/* 已经停掉的 job 再 stop 无所谓 */
|
|
191
|
+
}
|
|
192
|
+
jobs.delete(id);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** 按当前 armed 状态与 enabled 标记给一条事项挂/摘定时器。 */
|
|
196
|
+
function scheduleOne(item) {
|
|
197
|
+
stopJob(item.id);
|
|
198
|
+
if (!armed || item.enabled !== true) return;
|
|
199
|
+
try {
|
|
200
|
+
// `unref: true`:不让定时器把宿主进程的事件循环钉住(进程想退出就能退出)。
|
|
201
|
+
// 它是 croner 的**构造选项**,不是 job 上的方法(写成 job.unref() 会静默无效)。
|
|
202
|
+
const job = new Cron(item.cron, { unref: true }, () => {
|
|
203
|
+
void runScheduled(item.id);
|
|
204
|
+
});
|
|
205
|
+
jobs.set(item.id, job);
|
|
206
|
+
} catch (error) {
|
|
207
|
+
// 表达式在写入时已校验过,这里失败只可能是运行期异常(例如 croner 版本差异):
|
|
208
|
+
// 记一条 warn 并保持该事项不调度,别让一个坏 job 拖垮整张表。
|
|
209
|
+
logger?.warn?.(`[t-team] 定时事项「${item.title}」调度失败(该事项不会自动触发):${error instanceof Error ? error.message : String(error)}`);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** 全量重建调度表(启动、开关切换、批量变更后调用)。 */
|
|
214
|
+
function rescheduleAll() {
|
|
215
|
+
for (const id of [...jobs.keys()]) stopJob(id);
|
|
216
|
+
if (!armed) return;
|
|
217
|
+
for (const item of items.values()) scheduleOne(item);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** 定时触发的入口:跑完更新记录并落盘。 */
|
|
221
|
+
async function runScheduled(id) {
|
|
222
|
+
const current = items.get(id);
|
|
223
|
+
if (current === undefined || !armed) return;
|
|
224
|
+
if (running.has(id)) return; // 上一轮还没跑完,这一轮跳过(不排队、不叠加)
|
|
225
|
+
const updated = await execute(current);
|
|
226
|
+
items.set(id, updated);
|
|
227
|
+
await persist();
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* 执行一条事项:新建会话 + 提交提示词,把结果记进 runs。
|
|
232
|
+
* 失败**不抛**(定时路径没人接异常),而是把原因写进 `lastRunError`。
|
|
233
|
+
*/
|
|
234
|
+
async function execute(item) {
|
|
235
|
+
const startedAt = new Date().toISOString();
|
|
236
|
+
running.add(item.id);
|
|
237
|
+
try {
|
|
238
|
+
const agents = ctx.get("agents");
|
|
239
|
+
if (agents === undefined || typeof agents.create !== "function") {
|
|
240
|
+
throw new Error(t("schedule.agentsMissing"));
|
|
241
|
+
}
|
|
242
|
+
const workspace = item.workspaceId === undefined
|
|
243
|
+
? undefined
|
|
244
|
+
: ctx.get("workspaceRegistry")?.get?.(item.workspaceId);
|
|
245
|
+
if (item.workspaceId !== undefined && workspace === undefined) {
|
|
246
|
+
throw new Error(t("schedule.workspaceMissing", { id: item.workspaceId }));
|
|
247
|
+
}
|
|
248
|
+
const selection = ctx.get("agentDefaultModel")?.currentSelection?.() ?? {};
|
|
249
|
+
const sessionId = `session-${randomUUID()}`;
|
|
250
|
+
const handle = await agents.create({
|
|
251
|
+
sessionId,
|
|
252
|
+
meta: { cwd: workspace ? workspace.path : defaultCwd },
|
|
253
|
+
...(typeof selection.provider === "string" && typeof selection.model === "string"
|
|
254
|
+
? { agentOptions: { provider: selection.provider, model: selection.model } }
|
|
255
|
+
: {}),
|
|
256
|
+
// 没有 preset 挂载的新会话**没有任何工具**(模型只能空口回答),所以这里按 web 网关
|
|
257
|
+
// 建会话的同款做法解析并挂上部署的默认 preset。
|
|
258
|
+
setup: async (agentCtx) => {
|
|
259
|
+
const presets = ctx.get("agentPresets");
|
|
260
|
+
if (!presets || typeof presets.resolve !== "function" || typeof presets.mount !== "function") return;
|
|
261
|
+
const resolved = await presets.resolve(undefined);
|
|
262
|
+
if (resolved && resolved.id) await presets.mount(agentCtx, resolved.id);
|
|
263
|
+
},
|
|
264
|
+
});
|
|
265
|
+
if (workspace !== undefined && typeof workspace.attachSession === "function") {
|
|
266
|
+
await workspace.attachSession(sessionId);
|
|
267
|
+
}
|
|
268
|
+
// 标题钉成「事项标题 · 时间」:同一事项的多次运行在侧栏里能一一区分。
|
|
269
|
+
// 以 `user` 来源直接写日志,避免自动标题生成把它覆盖掉(失败不致命)。
|
|
270
|
+
try {
|
|
271
|
+
handle.agent.session.append("session/title", {
|
|
272
|
+
title: `${item.title} · ${runStamp(startedAt)}`,
|
|
273
|
+
messageSeqs: [],
|
|
274
|
+
source: { kind: "user" },
|
|
275
|
+
});
|
|
276
|
+
} catch {
|
|
277
|
+
/* 标题没钉上也照样执行 */
|
|
278
|
+
}
|
|
279
|
+
// 提示词里可能带 `@专家名`(面板里挑中的那位):交给注入的改写器落成召唤指令。
|
|
280
|
+
// 改写失败不致命 —— 退回原文提交,任务照跑,只在日志里留一句可查的线索。
|
|
281
|
+
let promptText = item.prompt;
|
|
282
|
+
try {
|
|
283
|
+
promptText = await expandPrompt(item.prompt);
|
|
284
|
+
} catch (error) {
|
|
285
|
+
logger?.warn?.(`[t-team] 定时任务「${item.title}」的提示词改写失败,按原文提交:${error instanceof Error ? error.message : String(error)}`);
|
|
286
|
+
}
|
|
287
|
+
handle.agent.followup({
|
|
288
|
+
id: randomUUID(),
|
|
289
|
+
role: "user",
|
|
290
|
+
content: [{ type: "text", text: promptText }],
|
|
291
|
+
source: { kind: "plugin", plugin: PLUGIN_ID },
|
|
292
|
+
});
|
|
293
|
+
return withRun(item, { at: startedAt, ok: true, sessionId });
|
|
294
|
+
} catch (error) {
|
|
295
|
+
return withRun(item, {
|
|
296
|
+
at: startedAt,
|
|
297
|
+
ok: false,
|
|
298
|
+
error: error instanceof Error ? error.message : String(error),
|
|
299
|
+
});
|
|
300
|
+
} finally {
|
|
301
|
+
running.delete(item.id);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
load();
|
|
306
|
+
// 读盘之后必须**立刻**按当前开关挂上定时器:少了这一句,插件每次启动都是"任务在、但永远不触发",
|
|
307
|
+
// 直到用户碰一下开关或增删一条才恢复 —— 那种"看着正常、其实没在跑"的失效最难发现,所以这里
|
|
308
|
+
// 也有对应自检(verify [20c] 的「重启后 enabled 的任务自动进入调度」)。
|
|
309
|
+
rescheduleAll();
|
|
310
|
+
|
|
311
|
+
/** 面板要的全部数据:事项 + 开关状态 + 可选工作区(一次调用拿全,省一次往返)。 */
|
|
312
|
+
function snapshot() {
|
|
313
|
+
const registry = ctx.get("workspaceRegistry") ?? ctx.get("workspace");
|
|
314
|
+
const list = typeof registry?.list === "function" ? registry.list() : [];
|
|
315
|
+
return {
|
|
316
|
+
items: [...items.values()],
|
|
317
|
+
armed,
|
|
318
|
+
file,
|
|
319
|
+
running: [...running],
|
|
320
|
+
capabilities: {
|
|
321
|
+
agents: typeof ctx.get("agents")?.create === "function",
|
|
322
|
+
presets: typeof ctx.get("agentPresets")?.mount === "function",
|
|
323
|
+
},
|
|
324
|
+
workspaces: list.map((workspace) => ({
|
|
325
|
+
id: String(workspace.id ?? ""),
|
|
326
|
+
title: String(workspace.title ?? workspace.path ?? ""),
|
|
327
|
+
// 目录:面板把它显示在表单里,用户能看清这条任务落在哪个文件夹。
|
|
328
|
+
path: typeof workspace.path === "string" ? workspace.path : "",
|
|
329
|
+
})).filter((workspace) => workspace.id !== ""),
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** 新建一条事项。 */
|
|
334
|
+
async function create(input) {
|
|
335
|
+
if (items.size >= MAX_ITEMS) throw new Error(t("schedule.tooManyItems", { limit: MAX_ITEMS }));
|
|
336
|
+
const fields = normalizeInput(input, t);
|
|
337
|
+
const now = new Date().toISOString();
|
|
338
|
+
const item = {
|
|
339
|
+
id: `item-${randomUUID()}`,
|
|
340
|
+
...fields,
|
|
341
|
+
createdAt: now,
|
|
342
|
+
updatedAt: now,
|
|
343
|
+
runs: [],
|
|
344
|
+
};
|
|
345
|
+
items.set(item.id, item);
|
|
346
|
+
scheduleOne(item);
|
|
347
|
+
await persist();
|
|
348
|
+
return item;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** 改一条事项(只覆盖传进来的字段)。 */
|
|
352
|
+
async function update(id, patch) {
|
|
353
|
+
const current = items.get(id);
|
|
354
|
+
if (current === undefined) throw new Error(t("schedule.itemMissing", { id }));
|
|
355
|
+
const source = patch === null || typeof patch !== "object" ? {} : patch;
|
|
356
|
+
const merged = {
|
|
357
|
+
title: source.title === undefined ? current.title : source.title,
|
|
358
|
+
prompt: source.prompt === undefined ? current.prompt : source.prompt,
|
|
359
|
+
cron: source.cron === undefined ? current.cron : source.cron,
|
|
360
|
+
enabled: source.enabled === undefined ? current.enabled : source.enabled,
|
|
361
|
+
workspaceId: source.workspaceId === undefined ? current.workspaceId : source.workspaceId,
|
|
362
|
+
};
|
|
363
|
+
const fields = normalizeInput(merged, t);
|
|
364
|
+
const next = { ...current, ...fields, updatedAt: new Date().toISOString() };
|
|
365
|
+
// 展开旧记录会把旧的 workspaceId 一起带过来:用户把绑定改回「不绑定」时
|
|
366
|
+
// fields 里没有这个键,必须显式删掉,否则界面上看着解绑了、执行时还挂在旧工作区。
|
|
367
|
+
if (fields.workspaceId === undefined) delete next.workspaceId;
|
|
368
|
+
items.set(id, next);
|
|
369
|
+
scheduleOne(next);
|
|
370
|
+
await persist();
|
|
371
|
+
return next;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/** 删一条事项(顺带摘掉它的定时器)。 */
|
|
375
|
+
async function remove(id) {
|
|
376
|
+
if (!items.has(id)) throw new Error(t("schedule.itemMissing", { id }));
|
|
377
|
+
stopJob(id);
|
|
378
|
+
items.delete(id);
|
|
379
|
+
await persist();
|
|
380
|
+
return { removed: true };
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/** 立即执行一条事项(面板上的「立即执行」)。 */
|
|
384
|
+
async function runNow(id) {
|
|
385
|
+
if (!armed) throw new Error(t("schedule.disabled"));
|
|
386
|
+
const current = items.get(id);
|
|
387
|
+
if (current === undefined) throw new Error(t("schedule.itemMissing", { id }));
|
|
388
|
+
if (running.has(id)) return current; // 正在跑就原样返回,面板保持「执行中…」
|
|
389
|
+
const updated = await execute(current);
|
|
390
|
+
items.set(id, updated);
|
|
391
|
+
await persist();
|
|
392
|
+
return updated;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* 开关联动:关掉 = 停止全部定时触发并拒绝手动执行;打开 = 按 stored enabled 全量重建。
|
|
397
|
+
* 只改运行态,不写数据文件(开关本身是界面偏好,存在宿主设置里)。
|
|
398
|
+
*/
|
|
399
|
+
function setArmed(next) {
|
|
400
|
+
const value = next !== false;
|
|
401
|
+
if (value === armed) return { armed };
|
|
402
|
+
armed = value;
|
|
403
|
+
rescheduleAll();
|
|
404
|
+
return { armed };
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/** 卸载:停掉全部定时器(数据留着,下次启动重建)。 */
|
|
408
|
+
function dispose() {
|
|
409
|
+
for (const id of [...jobs.keys()]) stopJob(id);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
return {
|
|
413
|
+
file,
|
|
414
|
+
snapshot,
|
|
415
|
+
list: () => [...items.values()],
|
|
416
|
+
create,
|
|
417
|
+
update,
|
|
418
|
+
remove,
|
|
419
|
+
runNow,
|
|
420
|
+
preview: (cron) => ({ fires: previewRuns(cron, t) }),
|
|
421
|
+
/** 给面板用:确保定时任务的默认工作区(或自定义目录)存在,返回 { id, title, path }。 */
|
|
422
|
+
ensureWorkspace: (path, title) => {
|
|
423
|
+
if (ensureWorkspace === undefined) {
|
|
424
|
+
return Promise.reject(new Error("宿主没有提供工作区准备能力(ensureWorkspace)。"));
|
|
425
|
+
}
|
|
426
|
+
return ensureWorkspace(path, title);
|
|
427
|
+
},
|
|
428
|
+
setArmed,
|
|
429
|
+
isArmed: () => armed,
|
|
430
|
+
/** 当前真正挂着定时器的事项 id(自检与诊断用:能看出"任务在、但没排定"这种失效)。 */
|
|
431
|
+
scheduledIds: () => [...jobs.keys()],
|
|
432
|
+
dispose,
|
|
433
|
+
/** 开关变化后重建调度(settings 回调里用)。 */
|
|
434
|
+
rescheduleAll,
|
|
435
|
+
};
|
|
436
|
+
}
|
package/lib/skill.js
CHANGED
|
@@ -33,10 +33,20 @@ export const SKILLS_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."
|
|
|
33
33
|
* 随包发布的 skill 清单(目录名 = 宿主路由用的 skill 名)。
|
|
34
34
|
*
|
|
35
35
|
* 顺序即注册顺序。缺文件的条目会被跳过并逐条告警,而不是让整段注册失败 ——
|
|
36
|
-
* 一个 skill
|
|
36
|
+
* 一个 skill 文件缺失不该带走另外两个(见 {@link LOCAL_ONLY_SKILL_NAMES} 的例外)。
|
|
37
37
|
*/
|
|
38
38
|
export const BUNDLED_SKILL_NAMES = ["t-expert-manager", "dsh-harness-project", "dsh-harness-languages"];
|
|
39
39
|
|
|
40
|
+
/**
|
|
41
|
+
* **只在本机布局里存在**的 skill:不进 npm 包(见 `package.json` 的 `files`)。
|
|
42
|
+
*
|
|
43
|
+
* 为什么:`t-expert-manager` 的正文整篇都是 `tz.sh` 运维流程,而 `ops/`、`tools/`、`tz.sh`
|
|
44
|
+
* 都不随包发布 —— 从 npm 装的人本来就不该有它,拿了也是一份跑不动的手册。所以它缺席是**预期**,
|
|
45
|
+
* 不能按"缺文件"告警(否则每次启动一条"未注册",看着像装坏了)。本机从源码装(`tz.sh install`
|
|
46
|
+
* 会把整个 `skills/` 复制进运行副本)时它照常在,运维流程一字不少。
|
|
47
|
+
*/
|
|
48
|
+
export const LOCAL_ONLY_SKILL_NAMES = ["t-expert-manager"];
|
|
49
|
+
|
|
40
50
|
/** 只有运维 skill 的正文带 `{{TZ}}` / `{{OPS_ROOT}}` 占位符,其余按原样注册。 */
|
|
41
51
|
const PLACEHOLDER_SKILL = "t-expert-manager";
|
|
42
52
|
|
|
@@ -161,17 +171,23 @@ function loadBundledSkill(name, config = {}) {
|
|
|
161
171
|
/**
|
|
162
172
|
* 逐个构建随包 skill,并**记下**哪些条目没建成以及为什么。
|
|
163
173
|
*
|
|
164
|
-
* 注册方靠 `missing` 逐条告警 ——
|
|
174
|
+
* 注册方靠 `missing` 逐条告警 —— 缺一个文件不该静默、也不该带走另外两个;
|
|
175
|
+
* 唯一的例外是 {@link LOCAL_ONLY_SKILL_NAMES}:它们不随 npm 包发布,缺席是预期。
|
|
165
176
|
* @param config - 插件配置。
|
|
166
|
-
* @returns `{ skills, missing }
|
|
177
|
+
* @returns `{ skills, missing }`;`skills` 顺序与 {@link BUNDLED_SKILL_NAMES} 一致。
|
|
167
178
|
*/
|
|
168
179
|
function collectBundledSkills(config = {}) {
|
|
169
180
|
const skills = [];
|
|
170
181
|
const missing = [];
|
|
171
182
|
for (const name of BUNDLED_SKILL_NAMES) {
|
|
172
183
|
const loaded = loadBundledSkill(name, config);
|
|
173
|
-
if (loaded.skill
|
|
174
|
-
|
|
184
|
+
if (loaded.skill !== undefined) {
|
|
185
|
+
skills.push(loaded.skill);
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
// 本机专有的 skill(npm 包里本来就没有):缺席是预期,既不告警也不进 missing。
|
|
189
|
+
if (LOCAL_ONLY_SKILL_NAMES.includes(name)) continue;
|
|
190
|
+
missing.push({ name, reason: loaded.reason });
|
|
175
191
|
}
|
|
176
192
|
return { skills, missing };
|
|
177
193
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-plugin-t-expert",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.24",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "T Expert — a 316-expert, 22-division roster with full Chinese translations, as a standalone DeepSeek Harness plugin, with a built-in multi-agent team engine (T Team).",
|
|
6
6
|
"license": "MIT",
|
|
@@ -42,7 +42,8 @@
|
|
|
42
42
|
"files": [
|
|
43
43
|
"lib",
|
|
44
44
|
"data",
|
|
45
|
-
"skills",
|
|
45
|
+
"skills/dsh-harness-project",
|
|
46
|
+
"skills/dsh-harness-languages",
|
|
46
47
|
"cordis.patch.yml",
|
|
47
48
|
"README.md",
|
|
48
49
|
"LICENSE",
|
|
@@ -172,6 +173,7 @@
|
|
|
172
173
|
"typescript": "^5.9.3"
|
|
173
174
|
},
|
|
174
175
|
"dependencies": {
|
|
176
|
+
"croner": "^9.1.0",
|
|
175
177
|
"zod": "^4.4.3"
|
|
176
178
|
}
|
|
177
179
|
}
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
| 文件 | 覆盖的随包内容 | 来源 |
|
|
7
7
|
| --- | --- | --- |
|
|
8
|
-
| `agency-agents.LICENSE` | `data/experts/`(22 分区 / 316 位;其中
|
|
8
|
+
| `agency-agents.LICENSE` | `data/experts/`(22 分区 / 316 位;其中 314 位来自第三方 —— 279 位为上游仓库逐字节镜像、35 位来自名册来源包快照;余 2 位为本仓自建,不由本许可覆盖) | The Agency / AgentLand 名册快照,MIT |
|
|
9
9
|
| `agency-agents-zh.LICENSE` | `data/zh/` 中的中文名字、简介与人格正文 | `agency-agents-zh` 中文翻译与本地化资产,MIT |
|
|
10
10
|
|
|
11
11
|
两份文本都是从实际用于生成快照的来源包内**逐字节复制**的,未经改写:
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2015-2021 Hexagon <github.com/Hexagon>
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
22
|
+
|
|
@@ -1,119 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: t-expert-manager
|
|
3
|
-
description: T专家 运维台 —— 316 位专家 / 22 分区的名册增删、名册一致性校验、统计与中文覆盖、小队(/t)成员编辑、装机到 DSH Desktop、发布到 npm。Use when the user asks to add/remove/validate/count T专家 experts, 新增专家 / 删除专家 / 校验名册 / 名册统计 / 改小队 / 重装插件 / 发布插件.
|
|
4
|
-
---
|
|
5
|
-
|
|
6
|
-
# T专家 运维(expert-manager)
|
|
7
|
-
|
|
8
|
-
运维入口只有一个:`bash "{{TZ}}"`。本机运维台根目录:`{{OPS_ROOT}}`。
|
|
9
|
-
|
|
10
|
-
这套流程解决的是「你是原作者,要维护自己的名册和插件」的场景;只想**用**专家或拉小队,不要走这里
|
|
11
|
-
(用 `list_t_experts` / `summon_t_expert`,小队用 `/t`)。
|
|
12
|
-
|
|
13
|
-
## 铁律
|
|
14
|
-
|
|
15
|
-
1. **只用 tz.sh**。不要手改 `data/experts/`、`plugin/`、`~/.t-team/`,也不要手改 `package.json` 的版本号 ——
|
|
16
|
-
这些动作脚本都代劳了,绕开脚本换来的就是"源码加了、运行时没加"这类半成品状态。
|
|
17
|
-
2. **`data/experts/` 是真源**。增删由 `add-expert.py`(tz.sh 代跑)执行,它**同时写**源码与运行时
|
|
18
|
-
`~/.t-team/experts/`,两边永远一致。
|
|
19
|
-
3. **统计必须递归**。有专家落在嵌套子目录(如 `game-development/unreal-engine/…`),
|
|
20
|
-
只看分类目录的第一层会漏掉它们。位数一律以 `tz.sh status` 的递归结果为准 ——
|
|
21
|
-
正文里不写死位数,因为每加一位专家它就会过期。
|
|
22
|
-
4. **数据更新不用重装、不用重启**(宿主按 mtime 指纹自动重载名册);**改插件代码**才需要
|
|
23
|
-
`tz.sh build` → `tz.sh install` → 重启 DSH Desktop。
|
|
24
|
-
5. **小队改完分两条路,只有设置页那条会热重载**:
|
|
25
|
-
- **设置页「队伍」保存**(推荐):`squads.save()` 编译成功后**就地热重载引擎配置 → 立即生效、不用重启**。
|
|
26
|
-
- **`tz.sh squads`(= 菜单 11)**:只是 CLI 直跑编译器(`python3 data/team-profiles.py`)把 `teams.json`
|
|
27
|
-
编译成引擎配置,**不经过插件、不会热重载**——运行中的引擎仍用旧 profiles,**必须重启 DSH**
|
|
28
|
-
(或再到设置页保存一次)才生效。所以别对用户说「跑完 tz.sh squads 就已经生效」。
|
|
29
|
-
两条路的失败语义一致:编译失败会**回滚**,盘上仍是旧配置(按旧小队建队)。
|
|
30
|
-
只有**改插件代码**才需要重启(见第 4 条)。
|
|
31
|
-
6. **`data/zh/` 已冻结**:不要新增或"补译"中文。新专家没有中文名/简介会回退英文,这是预期行为。
|
|
32
|
-
7. **破坏性动作先确认**:删除专家、卸载插件、发布 npm 之前,把影响讲给用户听,得到明确同意再执行。
|
|
33
|
-
|
|
34
|
-
## 先看状态
|
|
35
|
-
|
|
36
|
-
```bash
|
|
37
|
-
bash "{{TZ}}" status
|
|
38
|
-
```
|
|
39
|
-
|
|
40
|
-
一眼给出:名册位数 / 分类数 / 中文覆盖 / 插件版本 vs DSH 里装的版本 / 仓库是否干净。
|
|
41
|
-
|
|
42
|
-
## 工作流
|
|
43
|
-
|
|
44
|
-
### A. 新增专家
|
|
45
|
-
|
|
46
|
-
1. 先确认 slug 不冲突(`bash "{{TZ}}" roster` 看现有分类与位数)。
|
|
47
|
-
2. **先 dry-run**:
|
|
48
|
-
```bash
|
|
49
|
-
bash "{{TZ}}" experts add --category <分区> --slug <slug> --file </绝对路径/xxx.md> --dry-run
|
|
50
|
-
```
|
|
51
|
-
3. 确认路径无误后去掉 `--dry-run` 正式写入。源文件缺 frontmatter 时补 `--name` / `--description`;
|
|
52
|
-
目标已存在要覆盖时加 `--force`。
|
|
53
|
-
4. 收尾校验:`bash "{{TZ}}" experts check`,必须报"三处一致"。
|
|
54
|
-
|
|
55
|
-
### B. 删除专家
|
|
56
|
-
|
|
57
|
-
先 `--dry-run` 看清要删的两条路径,再执行:
|
|
58
|
-
|
|
59
|
-
```bash
|
|
60
|
-
bash "{{TZ}}" experts remove --category <分区> --slug <slug> --yes
|
|
61
|
-
```
|
|
62
|
-
|
|
63
|
-
源码与运行时会被一并删除(`--yes` 跳过交互确认,所以**先跟用户确认过**再用)。
|
|
64
|
-
|
|
65
|
-
### C. 校验名册
|
|
66
|
-
|
|
67
|
-
```bash
|
|
68
|
-
bash "{{TZ}}" experts check # 源码 ↔ 运行时 ↔ 清单,逐文件 sha256;退出码 1 = 有漂移
|
|
69
|
-
bash "{{TZ}}" verify # check + 插件自检(verify.mjs),改过代码后必跑
|
|
70
|
-
```
|
|
71
|
-
|
|
72
|
-
### D. 统计与中文覆盖
|
|
73
|
-
|
|
74
|
-
```bash
|
|
75
|
-
bash "{{TZ}}" experts stats
|
|
76
|
-
```
|
|
77
|
-
|
|
78
|
-
汇报时给"分类数 / 专家数 / 中文覆盖",不要把整张分类表贴给用户。
|
|
79
|
-
|
|
80
|
-
### E. 修改小队(`/t`)
|
|
81
|
-
|
|
82
|
-
1. 小队定义在 `data/teams.json`(人工维护):成员写专家 slug 字符串,或 `{slug, role}` 对象。
|
|
83
|
-
2. 用编辑器改完后编译成引擎配置:
|
|
84
|
-
```bash
|
|
85
|
-
bash "{{TZ}}" squads
|
|
86
|
-
```
|
|
87
|
-
3. 明确告诉用户:**`tz.sh squads` 只是 CLI 直跑编译器**——它把 `teams.json` 编译成引擎配置,
|
|
88
|
-
**不会让运行中的插件重载**。所以:**要让新小队真正生效,需重启 DSH Desktop**(或再到设置页「队伍」保存一次,
|
|
89
|
-
那条路才会就地热重载、立即生效)。若 `/t` 里仍看不到新小队,先确认编译没报错(编译失败会回滚,盘上仍是旧配置)。
|
|
90
|
-
4. 想免重启就直接走设置页:设置 → T专家 → 队伍 → 改完保存(`squads.save()` 编译成功后触发引擎热重载,立即生效)。
|
|
91
|
-
|
|
92
|
-
### F. 装机(只在改过插件代码时需要)
|
|
93
|
-
|
|
94
|
-
```bash
|
|
95
|
-
bash "{{TZ}}" verify # 先自检
|
|
96
|
-
bash "{{TZ}}" build # 构建 client bundle
|
|
97
|
-
bash "{{TZ}}" install # 装到 profile(默认 desktop);可先 --dry-run
|
|
98
|
-
```
|
|
99
|
-
|
|
100
|
-
装机后仍需**重启 DSH Desktop**。装错/装坏时 `bash "{{TZ}}" install-restore` 可还原被接管的依赖。
|
|
101
|
-
|
|
102
|
-
### G. 发布到 npm
|
|
103
|
-
|
|
104
|
-
```bash
|
|
105
|
-
bash "{{TZ}}" publish-dry # 预演:只跑预检与打包预览
|
|
106
|
-
bash "{{TZ}}" publish # 默认 patch;也可 --minor / --major / --version X.Y.Z
|
|
107
|
-
```
|
|
108
|
-
|
|
109
|
-
**发布前必须**:(a) 得到用户明确同意;(b) `experts check` 与 `verify` 全绿;(c) 工作区没有未提交的意外改动。
|
|
110
|
-
|
|
111
|
-
## 汇报约定
|
|
112
|
-
|
|
113
|
-
- 给"执行的命令 + 结尾结论",不要贴整篇日志。
|
|
114
|
-
- 失败就贴出错那一段(含退出码)并说明下一步;不要静默重试或换个命令重来。
|
|
115
|
-
- 名册位数永远按递归结果报(见铁律 3)。
|
|
116
|
-
|
|
117
|
-
## 参考
|
|
118
|
-
|
|
119
|
-
- `references/ops-reference.md` —— 完整子命令 / 菜单号 / 参数表,以及"哪份文件是真源"的地图。
|