@dcrays/scheduled-task 0.1.6-beta.5 → 0.1.6-beta.8
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/package.json +6 -6
- package/plugin/index.d.ts +4 -1
- package/plugin/index.js +154 -14
- package/src/manager.d.ts +5 -1
- package/src/manager.js +89 -1
- package/src/protocol.d.ts +3 -3
- package/src/protocol.js +27 -24
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dcrays/scheduled-task",
|
|
3
|
-
"version": "0.1.6-beta.
|
|
3
|
+
"version": "0.1.6-beta.8",
|
|
4
4
|
"description": "Persistent client-facing cron automation service for DeepSeek Harness",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "plugin/index.js",
|
|
@@ -34,11 +34,11 @@
|
|
|
34
34
|
},
|
|
35
35
|
"peerDependencies": {
|
|
36
36
|
"@deepseek-ai/cordis": "^4.0.2",
|
|
37
|
-
"@deepseek-ai/dsh-agent": "
|
|
38
|
-
"@deepseek-ai/dsh-home-paths": "0.1.
|
|
39
|
-
"@deepseek-ai/dsh-host-webserver": "
|
|
40
|
-
"@deepseek-ai/dsh-llm": "
|
|
41
|
-
"@deepseek-ai/dsh-tools": "
|
|
37
|
+
"@deepseek-ai/dsh-agent": "0.1.5-rc.2",
|
|
38
|
+
"@deepseek-ai/dsh-home-paths": "0.1.5-rc.2",
|
|
39
|
+
"@deepseek-ai/dsh-host-webserver": "0.1.5-rc.2",
|
|
40
|
+
"@deepseek-ai/dsh-llm": "0.1.5-rc.2",
|
|
41
|
+
"@deepseek-ai/dsh-tools": "0.1.5-rc.2",
|
|
42
42
|
"@deepseek-ai/schemastery": "^3.18.1"
|
|
43
43
|
},
|
|
44
44
|
"publishConfig": {
|
package/plugin/index.d.ts
CHANGED
|
@@ -54,7 +54,10 @@ export declare class CronAutomationService extends Service {
|
|
|
54
54
|
private migrateLegacyCronDatabase;
|
|
55
55
|
private importOpenClawJobs;
|
|
56
56
|
stop(): Promise<void>;
|
|
57
|
-
create(agentId: string, envelope: CronCreateEnvelope
|
|
57
|
+
create(agentId: string, envelope: CronCreateEnvelope, options?: {
|
|
58
|
+
allowDuplicate?: boolean;
|
|
59
|
+
}): Promise<StoredCronAutomation>;
|
|
60
|
+
findDuplicates(agentId: string, envelope: CronCreateEnvelope): Promise<StoredCronAutomation[]>;
|
|
58
61
|
list(agentId?: string): Promise<StoredCronAutomation[]>;
|
|
59
62
|
update(cronId: string, patch: CronAutomationPatch): Promise<StoredCronAutomation | undefined>;
|
|
60
63
|
delete(cronId: string): Promise<boolean>;
|
package/plugin/index.js
CHANGED
|
@@ -821,6 +821,74 @@ function setNextOccurrence(job, value) {
|
|
|
821
821
|
function sameRequest(job, envelope) {
|
|
822
822
|
return job.name === envelope.name && job.payload.message === envelope.prompt && job.schedule.kind === "cron" && job.schedule.expr === envelope.cron && timezoneFor(job.schedule) === envelope.timezone;
|
|
823
823
|
}
|
|
824
|
+
function normalizedText(value) {
|
|
825
|
+
return value.normalize("NFKC").toLocaleLowerCase().replace(/[\p{P}\p{S}\s]+/gu, "");
|
|
826
|
+
}
|
|
827
|
+
function bigramDice(left, right) {
|
|
828
|
+
if (left === right)
|
|
829
|
+
return 1;
|
|
830
|
+
if (left.length < 2 || right.length < 2)
|
|
831
|
+
return 0;
|
|
832
|
+
const leftBigrams = /* @__PURE__ */ new Map();
|
|
833
|
+
for (let index = 0; index < left.length - 1; index += 1) {
|
|
834
|
+
const bigram = left.slice(index, index + 2);
|
|
835
|
+
leftBigrams.set(bigram, (leftBigrams.get(bigram) ?? 0) + 1);
|
|
836
|
+
}
|
|
837
|
+
let overlap = 0;
|
|
838
|
+
for (let index = 0; index < right.length - 1; index += 1) {
|
|
839
|
+
const bigram = right.slice(index, index + 2);
|
|
840
|
+
const remaining = leftBigrams.get(bigram) ?? 0;
|
|
841
|
+
if (remaining > 0) {
|
|
842
|
+
overlap += 1;
|
|
843
|
+
leftBigrams.set(bigram, remaining - 1);
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
return 2 * overlap / (left.length + right.length - 2);
|
|
847
|
+
}
|
|
848
|
+
function similarText(left, right, threshold = 0.82, minimumLength = 8) {
|
|
849
|
+
const normalizedLeft = normalizedText(left);
|
|
850
|
+
const normalizedRight = normalizedText(right);
|
|
851
|
+
if (normalizedLeft === normalizedRight)
|
|
852
|
+
return true;
|
|
853
|
+
if (Math.min(normalizedLeft.length, normalizedRight.length) < minimumLength)
|
|
854
|
+
return false;
|
|
855
|
+
return bigramDice(normalizedLeft, normalizedRight) >= threshold;
|
|
856
|
+
}
|
|
857
|
+
function longestCommonSubstringLength(left, right) {
|
|
858
|
+
const previous = new Array(right.length + 1).fill(0);
|
|
859
|
+
let longest = 0;
|
|
860
|
+
for (let leftIndex = 1; leftIndex <= left.length; leftIndex += 1) {
|
|
861
|
+
for (let rightIndex = right.length; rightIndex >= 1; rightIndex -= 1) {
|
|
862
|
+
if (left[leftIndex - 1] === right[rightIndex - 1]) {
|
|
863
|
+
const length = (previous[rightIndex - 1] ?? 0) + 1;
|
|
864
|
+
previous[rightIndex] = length;
|
|
865
|
+
longest = Math.max(longest, length);
|
|
866
|
+
} else {
|
|
867
|
+
previous[rightIndex] = 0;
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
return longest;
|
|
872
|
+
}
|
|
873
|
+
function similarName(left, right) {
|
|
874
|
+
const normalizedLeft = normalizedText(left);
|
|
875
|
+
const normalizedRight = normalizedText(right);
|
|
876
|
+
if (normalizedLeft === normalizedRight)
|
|
877
|
+
return true;
|
|
878
|
+
const shorterLength = Math.min(normalizedLeft.length, normalizedRight.length);
|
|
879
|
+
if (shorterLength < 6)
|
|
880
|
+
return false;
|
|
881
|
+
if (bigramDice(normalizedLeft, normalizedRight) >= 0.65)
|
|
882
|
+
return true;
|
|
883
|
+
const sharedLength = longestCommonSubstringLength(normalizedLeft, normalizedRight);
|
|
884
|
+
return sharedLength >= 6 && sharedLength / shorterLength >= 0.5;
|
|
885
|
+
}
|
|
886
|
+
function normalizedCron(value) {
|
|
887
|
+
return value.trim().split(/\s+/u).join(" ");
|
|
888
|
+
}
|
|
889
|
+
function sameAutomation(job, envelope) {
|
|
890
|
+
return job.schedule.kind === "cron" && (similarName(job.name, envelope.name) || similarText(job.payload.message, envelope.prompt)) && normalizedCron(job.schedule.expr) === normalizedCron(envelope.cron) && timezoneFor(job.schedule) === envelope.timezone;
|
|
891
|
+
}
|
|
824
892
|
var CronAutomationManager = class {
|
|
825
893
|
repository;
|
|
826
894
|
options;
|
|
@@ -883,7 +951,7 @@ var CronAutomationManager = class {
|
|
|
883
951
|
this.repository.close();
|
|
884
952
|
}
|
|
885
953
|
}
|
|
886
|
-
async create(agentId, envelope) {
|
|
954
|
+
async create(agentId, envelope, options = {}) {
|
|
887
955
|
await this.start();
|
|
888
956
|
return this.enqueue(async () => {
|
|
889
957
|
if (this.stopping)
|
|
@@ -902,6 +970,9 @@ var CronAutomationManager = class {
|
|
|
902
970
|
const now = this.now();
|
|
903
971
|
const schedule = { kind: "cron", expr: envelope.cron, tz: envelope.timezone };
|
|
904
972
|
assertMinimumScheduleInterval(schedule);
|
|
973
|
+
if (!options.allowDuplicate && [...this.jobs.values()].some((job2) => sameAutomation(job2, envelope))) {
|
|
974
|
+
throw new CronAutomationInputError("duplicate_automation", "a cron automation with the same or similar name or prompt, cron expression, and timezone already exists");
|
|
975
|
+
}
|
|
905
976
|
const nextRunAtMs = nextOccurrence(now, schedule);
|
|
906
977
|
const job = {
|
|
907
978
|
id,
|
|
@@ -931,6 +1002,12 @@ var CronAutomationManager = class {
|
|
|
931
1002
|
return cloneJob(job);
|
|
932
1003
|
});
|
|
933
1004
|
}
|
|
1005
|
+
/** Matching names are intentionally allowed: the work and schedule define a duplicate. */
|
|
1006
|
+
async findDuplicates(agentId, envelope) {
|
|
1007
|
+
await this.start();
|
|
1008
|
+
const idempotentId = automationId(agentId, envelope.requestId);
|
|
1009
|
+
return [...this.jobs.values()].filter((job) => job.id !== idempotentId && sameAutomation(job, envelope)).sort((left, right) => left.createdAtMs - right.createdAtMs || left.id.localeCompare(right.id)).map(cloneJob);
|
|
1010
|
+
}
|
|
934
1011
|
async update(jobId, patch) {
|
|
935
1012
|
await this.start();
|
|
936
1013
|
return this.enqueue(async () => {
|
|
@@ -1652,6 +1729,29 @@ function acknowledgement(cron) {
|
|
|
1652
1729
|
}
|
|
1653
1730
|
});
|
|
1654
1731
|
}
|
|
1732
|
+
function duplicateChoiceRequest(envelope, duplicates) {
|
|
1733
|
+
const choices = duplicates.map((cron) => ({
|
|
1734
|
+
id: cron.id,
|
|
1735
|
+
name: cron.name,
|
|
1736
|
+
enabled: cron.enabled,
|
|
1737
|
+
schedule: cron.schedule,
|
|
1738
|
+
nextRunAt: cron.state.nextRunAtMs === void 0 ? null : new Date(cron.state.nextRunAtMs).toISOString()
|
|
1739
|
+
}));
|
|
1740
|
+
return createUserMessage({
|
|
1741
|
+
content: [
|
|
1742
|
+
{
|
|
1743
|
+
type: "text",
|
|
1744
|
+
text: [
|
|
1745
|
+
"[CRON AUTOMATION DUPLICATE DETECTED]",
|
|
1746
|
+
"Do not create or run the requested task yet. A task with the same or similar name or prompt, cron expression, and timezone already exists. Tell the user which matching task was found and ask them to choose exactly one: update the existing task, create another task anyway, or cancel. Do not make that choice for the user.",
|
|
1747
|
+
`requested_task_json: ${JSON.stringify({ name: envelope.name, prompt: envelope.prompt, cron: envelope.cron, timezone: envelope.timezone })}`,
|
|
1748
|
+
`matching_tasks_json: ${JSON.stringify(choices)}`
|
|
1749
|
+
].join("\n")
|
|
1750
|
+
}
|
|
1751
|
+
],
|
|
1752
|
+
source: { kind: "plugin", plugin: name, form: "notice", summary: "Cron automation duplicate detected" }
|
|
1753
|
+
});
|
|
1754
|
+
}
|
|
1655
1755
|
function registrationFailure(error) {
|
|
1656
1756
|
const message = error instanceof CronAutomationInputError ? error.message : "The cron automation could not be persisted.";
|
|
1657
1757
|
return createUserMessage({
|
|
@@ -1735,7 +1835,15 @@ function modelSelectionFromEvents(events) {
|
|
|
1735
1835
|
}
|
|
1736
1836
|
async function modelSelectionForResume(owner, agentId) {
|
|
1737
1837
|
const persistence = sessionPersistenceOf(owner);
|
|
1738
|
-
|
|
1838
|
+
let persisted;
|
|
1839
|
+
if (persistence) {
|
|
1840
|
+
const handle = await persistence.open(agentId, "read");
|
|
1841
|
+
try {
|
|
1842
|
+
persisted = modelSelectionFromEvents((await handle.read()).events);
|
|
1843
|
+
} finally {
|
|
1844
|
+
await handle.close();
|
|
1845
|
+
}
|
|
1846
|
+
}
|
|
1739
1847
|
return persisted ?? agentDefaultModelOf(owner)?.currentSelection();
|
|
1740
1848
|
}
|
|
1741
1849
|
async function resumeAgent(owner, agentId) {
|
|
@@ -1753,8 +1861,8 @@ async function resumeAgent(owner, agentId) {
|
|
|
1753
1861
|
model: selection.model
|
|
1754
1862
|
},
|
|
1755
1863
|
...presets ? {
|
|
1756
|
-
setup: async (agentCtx) => {
|
|
1757
|
-
await presets.mount(agentCtx,
|
|
1864
|
+
setup: async (agentCtx, agent) => {
|
|
1865
|
+
await presets.mount(agentCtx, agent.session.header.agentPreset);
|
|
1758
1866
|
}
|
|
1759
1867
|
} : {}
|
|
1760
1868
|
});
|
|
@@ -1976,8 +2084,11 @@ var CronAutomationService = class extends Service {
|
|
|
1976
2084
|
await this.pushTail;
|
|
1977
2085
|
await this.manager.stop();
|
|
1978
2086
|
}
|
|
1979
|
-
create(agentId, envelope) {
|
|
1980
|
-
return this.manager.create(agentId, envelope);
|
|
2087
|
+
create(agentId, envelope, options) {
|
|
2088
|
+
return this.manager.create(agentId, envelope, options);
|
|
2089
|
+
}
|
|
2090
|
+
findDuplicates(agentId, envelope) {
|
|
2091
|
+
return this.manager.findDuplicates(agentId, envelope);
|
|
1981
2092
|
}
|
|
1982
2093
|
list(agentId) {
|
|
1983
2094
|
return this.manager.list(agentId);
|
|
@@ -2030,11 +2141,21 @@ var CronAutomationService = class extends Service {
|
|
|
2030
2141
|
if (typeof body !== "object" || body === null || Array.isArray(body) || !("agentId" in body) || !("cron" in body)) {
|
|
2031
2142
|
throw new CronAutomationInputError("invalid_envelope", "body must contain agentId and cron");
|
|
2032
2143
|
}
|
|
2033
|
-
const { agentId, cron, ...extra } = body;
|
|
2034
|
-
if (Object.keys(extra).length > 0 || typeof agentId !== "string" || agentId.length === 0) {
|
|
2035
|
-
throw new CronAutomationInputError("invalid_envelope", "body must contain
|
|
2144
|
+
const { agentId, cron, duplicateAction, ...extra } = body;
|
|
2145
|
+
if (Object.keys(extra).length > 0 || typeof agentId !== "string" || agentId.length === 0 || duplicateAction !== void 0 && duplicateAction !== "create") {
|
|
2146
|
+
throw new CronAutomationInputError("invalid_envelope", "body must contain agentId, cron, and optionally duplicateAction=create");
|
|
2147
|
+
}
|
|
2148
|
+
const envelope = decodeCronCreateEnvelope(cron);
|
|
2149
|
+
const duplicates = await this.findDuplicates(agentId, envelope);
|
|
2150
|
+
if (duplicates.length > 0 && duplicateAction !== "create") {
|
|
2151
|
+
sendJson(res, 409, {
|
|
2152
|
+
status: "duplicate",
|
|
2153
|
+
requested: { name: envelope.name, prompt: envelope.prompt, cron: envelope.cron, timezone: envelope.timezone },
|
|
2154
|
+
duplicates
|
|
2155
|
+
});
|
|
2156
|
+
return;
|
|
2036
2157
|
}
|
|
2037
|
-
const created = await this.create(agentId,
|
|
2158
|
+
const created = await this.create(agentId, envelope, { allowDuplicate: duplicateAction === "create" });
|
|
2038
2159
|
sendJson(res, 201, created);
|
|
2039
2160
|
return;
|
|
2040
2161
|
}
|
|
@@ -2119,7 +2240,7 @@ function apply(ctx, config) {
|
|
|
2119
2240
|
const logger = ctx.logger("dsh-cron-automation");
|
|
2120
2241
|
const unregisterTool = ctx.tools.register(defineTool({
|
|
2121
2242
|
name: "manage_cron_automation",
|
|
2122
|
-
description: `Create, edit, enable, disable, delete, list, or immediately run persistent cron automations. Use this whenever the user asks to create or edit a scheduled task, including requests from the Mobook scheduled-task editor. Always call this tool for an edit even when prior conversation says the requested schedule is invalid; this tool's current validation is authoritative. Preserve the requested interval and report validation errors instead of silently substituting another schedule. Creating or updating only persists the schedule and prompt; do not execute the prompt, write files, or create other tasks from that content until the job is due or the user asks to run it now. After create or update, tell the user only the task name and next run time; do not mention that you skipped execution or that you only registered the job. Cron expressions have five fields, timezone is an IANA name, and recurring executions must be at least ${MIN_CRON_INTERVAL_SECONDS} seconds (${MIN_CRON_INTERVAL_SECONDS / 60} minutes) apart.`,
|
|
2243
|
+
description: `Create, edit, enable, disable, delete, list, or immediately run persistent cron automations. Use this whenever the user asks to create or edit a scheduled task, including requests from the Mobook scheduled-task editor. Always call this tool for an edit even when prior conversation says the requested schedule is invalid; this tool's current validation is authoritative. Preserve the requested interval and report validation errors instead of silently substituting another schedule. Before creating, inspect the returned duplicate result. If any existing task has a same or similar name or prompt with the same cron expression and timezone, ask the user whether to update it, create another anyway, or cancel; do not choose on their behalf. Use duplicateAction=create only after the user explicitly chose another task. Creating or updating only persists the schedule and prompt; do not execute the prompt, write files, or create other tasks from that content until the job is due or the user asks to run it now. After create or update, tell the user only the task name and next run time; do not mention that you skipped execution or that you only registered the job. Cron expressions have five fields, timezone is an IANA name, and recurring executions must be at least ${MIN_CRON_INTERVAL_SECONDS} seconds (${MIN_CRON_INTERVAL_SECONDS / 60} minutes) apart.`,
|
|
2123
2244
|
parameters: {
|
|
2124
2245
|
action: { type: "string", enum: ["create", "update", "list", "delete", "run"], required: true },
|
|
2125
2246
|
id: { type: "string", description: "Existing cron id for update, delete, or run." },
|
|
@@ -2133,7 +2254,12 @@ function apply(ctx, config) {
|
|
|
2133
2254
|
description: `Standard five-field cron expression with a minimum interval of ${MIN_CRON_INTERVAL_SECONDS} seconds (${MIN_CRON_INTERVAL_SECONDS / 60} minutes).`
|
|
2134
2255
|
},
|
|
2135
2256
|
timezone: { type: "string", description: "IANA timezone, for example Asia/Shanghai." },
|
|
2136
|
-
enabled: { type: "boolean" }
|
|
2257
|
+
enabled: { type: "boolean" },
|
|
2258
|
+
duplicateAction: {
|
|
2259
|
+
type: "string",
|
|
2260
|
+
enum: ["ask", "create"],
|
|
2261
|
+
description: "For create only. Use create only after the user explicitly chose another matching task."
|
|
2262
|
+
}
|
|
2137
2263
|
},
|
|
2138
2264
|
output: {
|
|
2139
2265
|
schema: { type: "json" },
|
|
@@ -2148,14 +2274,23 @@ function apply(ctx, config) {
|
|
|
2148
2274
|
if (args.action === "create") {
|
|
2149
2275
|
if (!args.name || !args.prompt || !args.cron || !args.timezone)
|
|
2150
2276
|
throw new Error("create requires name, prompt, cron, and timezone");
|
|
2151
|
-
const
|
|
2277
|
+
const envelope = {
|
|
2152
2278
|
type: "dsh/cron.create",
|
|
2153
2279
|
requestId: `tool-${String(exec.callId).slice(0, 120)}`,
|
|
2154
2280
|
name: args.name,
|
|
2155
2281
|
prompt: args.prompt,
|
|
2156
2282
|
cron: args.cron,
|
|
2157
2283
|
timezone: args.timezone
|
|
2158
|
-
}
|
|
2284
|
+
};
|
|
2285
|
+
const duplicates = await service.findDuplicates(agentId, envelope);
|
|
2286
|
+
if (duplicates.length > 0 && args.duplicateAction !== "create") {
|
|
2287
|
+
return {
|
|
2288
|
+
status: "duplicate",
|
|
2289
|
+
requested: { name: envelope.name, prompt: envelope.prompt, cron: envelope.cron, timezone: envelope.timezone },
|
|
2290
|
+
duplicates: JSON.parse(JSON.stringify(duplicates))
|
|
2291
|
+
};
|
|
2292
|
+
}
|
|
2293
|
+
const created = await service.create(agentId, envelope, { allowDuplicate: args.duplicateAction === "create" });
|
|
2159
2294
|
return JSON.parse(JSON.stringify(created));
|
|
2160
2295
|
}
|
|
2161
2296
|
if (!args.id)
|
|
@@ -2225,6 +2360,11 @@ function apply(ctx, config) {
|
|
|
2225
2360
|
}
|
|
2226
2361
|
handled = true;
|
|
2227
2362
|
try {
|
|
2363
|
+
const duplicates = await service.findDuplicates(agent.id, envelope);
|
|
2364
|
+
if (duplicates.length > 0) {
|
|
2365
|
+
replacements.push(duplicateChoiceRequest(envelope, duplicates));
|
|
2366
|
+
continue;
|
|
2367
|
+
}
|
|
2228
2368
|
const cron = await service.create(agent.id, envelope);
|
|
2229
2369
|
replacements.push(acknowledgement(cron));
|
|
2230
2370
|
logger.info("registered cron %s for agent %s at %s", cron.id, cron.agentId, cron.state.nextRunAtMs === void 0 ? "none" : new Date(cron.state.nextRunAtMs).toISOString());
|
package/src/manager.d.ts
CHANGED
|
@@ -41,7 +41,11 @@ export declare class CronAutomationManager {
|
|
|
41
41
|
start(): Promise<void>;
|
|
42
42
|
private initialize;
|
|
43
43
|
stop(): Promise<void>;
|
|
44
|
-
create(agentId: string, envelope: CronCreateEnvelope
|
|
44
|
+
create(agentId: string, envelope: CronCreateEnvelope, options?: {
|
|
45
|
+
allowDuplicate?: boolean;
|
|
46
|
+
}): Promise<StoredCronAutomation>;
|
|
47
|
+
/** Matching names are intentionally allowed: the work and schedule define a duplicate. */
|
|
48
|
+
findDuplicates(agentId: string, envelope: CronCreateEnvelope): Promise<StoredCronAutomation[]>;
|
|
45
49
|
update(jobId: string, patch: CronAutomationPatch): Promise<StoredCronAutomation | undefined>;
|
|
46
50
|
delete(jobId: string): Promise<boolean>;
|
|
47
51
|
runOnceStatus(jobId: string): Promise<CronRunStatus>;
|
package/src/manager.js
CHANGED
|
@@ -92,6 +92,82 @@ function sameRequest(job, envelope) {
|
|
|
92
92
|
job.schedule.expr === envelope.cron &&
|
|
93
93
|
timezoneFor(job.schedule) === envelope.timezone);
|
|
94
94
|
}
|
|
95
|
+
function normalizedText(value) {
|
|
96
|
+
return value
|
|
97
|
+
.normalize('NFKC')
|
|
98
|
+
.toLocaleLowerCase()
|
|
99
|
+
.replace(/[\p{P}\p{S}\s]+/gu, '');
|
|
100
|
+
}
|
|
101
|
+
function bigramDice(left, right) {
|
|
102
|
+
if (left === right)
|
|
103
|
+
return 1;
|
|
104
|
+
if (left.length < 2 || right.length < 2)
|
|
105
|
+
return 0;
|
|
106
|
+
const leftBigrams = new Map();
|
|
107
|
+
for (let index = 0; index < left.length - 1; index += 1) {
|
|
108
|
+
const bigram = left.slice(index, index + 2);
|
|
109
|
+
leftBigrams.set(bigram, (leftBigrams.get(bigram) ?? 0) + 1);
|
|
110
|
+
}
|
|
111
|
+
let overlap = 0;
|
|
112
|
+
for (let index = 0; index < right.length - 1; index += 1) {
|
|
113
|
+
const bigram = right.slice(index, index + 2);
|
|
114
|
+
const remaining = leftBigrams.get(bigram) ?? 0;
|
|
115
|
+
if (remaining > 0) {
|
|
116
|
+
overlap += 1;
|
|
117
|
+
leftBigrams.set(bigram, remaining - 1);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return (2 * overlap) / (left.length + right.length - 2);
|
|
121
|
+
}
|
|
122
|
+
function similarText(left, right, threshold = 0.82, minimumLength = 8) {
|
|
123
|
+
const normalizedLeft = normalizedText(left);
|
|
124
|
+
const normalizedRight = normalizedText(right);
|
|
125
|
+
if (normalizedLeft === normalizedRight)
|
|
126
|
+
return true;
|
|
127
|
+
if (Math.min(normalizedLeft.length, normalizedRight.length) < minimumLength)
|
|
128
|
+
return false;
|
|
129
|
+
return bigramDice(normalizedLeft, normalizedRight) >= threshold;
|
|
130
|
+
}
|
|
131
|
+
function longestCommonSubstringLength(left, right) {
|
|
132
|
+
const previous = new Array(right.length + 1).fill(0);
|
|
133
|
+
let longest = 0;
|
|
134
|
+
for (let leftIndex = 1; leftIndex <= left.length; leftIndex += 1) {
|
|
135
|
+
for (let rightIndex = right.length; rightIndex >= 1; rightIndex -= 1) {
|
|
136
|
+
if (left[leftIndex - 1] === right[rightIndex - 1]) {
|
|
137
|
+
const length = (previous[rightIndex - 1] ?? 0) + 1;
|
|
138
|
+
previous[rightIndex] = length;
|
|
139
|
+
longest = Math.max(longest, length);
|
|
140
|
+
}
|
|
141
|
+
else {
|
|
142
|
+
previous[rightIndex] = 0;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return longest;
|
|
147
|
+
}
|
|
148
|
+
function similarName(left, right) {
|
|
149
|
+
const normalizedLeft = normalizedText(left);
|
|
150
|
+
const normalizedRight = normalizedText(right);
|
|
151
|
+
if (normalizedLeft === normalizedRight)
|
|
152
|
+
return true;
|
|
153
|
+
const shorterLength = Math.min(normalizedLeft.length, normalizedRight.length);
|
|
154
|
+
if (shorterLength < 6)
|
|
155
|
+
return false;
|
|
156
|
+
if (bigramDice(normalizedLeft, normalizedRight) >= 0.65)
|
|
157
|
+
return true;
|
|
158
|
+
const sharedLength = longestCommonSubstringLength(normalizedLeft, normalizedRight);
|
|
159
|
+
return sharedLength >= 6 && sharedLength / shorterLength >= 0.5;
|
|
160
|
+
}
|
|
161
|
+
function normalizedCron(value) {
|
|
162
|
+
return value.trim().split(/\s+/u).join(' ');
|
|
163
|
+
}
|
|
164
|
+
/** A new editor submission has a new request id and often a new agent id. */
|
|
165
|
+
function sameAutomation(job, envelope) {
|
|
166
|
+
return (job.schedule.kind === 'cron' &&
|
|
167
|
+
(similarName(job.name, envelope.name) || similarText(job.payload.message, envelope.prompt)) &&
|
|
168
|
+
normalizedCron(job.schedule.expr) === normalizedCron(envelope.cron) &&
|
|
169
|
+
timezoneFor(job.schedule) === envelope.timezone);
|
|
170
|
+
}
|
|
95
171
|
export class CronAutomationManager {
|
|
96
172
|
repository;
|
|
97
173
|
options;
|
|
@@ -155,7 +231,7 @@ export class CronAutomationManager {
|
|
|
155
231
|
this.repository.close();
|
|
156
232
|
}
|
|
157
233
|
}
|
|
158
|
-
async create(agentId, envelope) {
|
|
234
|
+
async create(agentId, envelope, options = {}) {
|
|
159
235
|
await this.start();
|
|
160
236
|
return this.enqueue(async () => {
|
|
161
237
|
if (this.stopping)
|
|
@@ -174,6 +250,9 @@ export class CronAutomationManager {
|
|
|
174
250
|
const now = this.now();
|
|
175
251
|
const schedule = { kind: 'cron', expr: envelope.cron, tz: envelope.timezone };
|
|
176
252
|
assertMinimumScheduleInterval(schedule);
|
|
253
|
+
if (!options.allowDuplicate && [...this.jobs.values()].some((job) => sameAutomation(job, envelope))) {
|
|
254
|
+
throw new CronAutomationInputError('duplicate_automation', 'a cron automation with the same or similar name or prompt, cron expression, and timezone already exists');
|
|
255
|
+
}
|
|
177
256
|
const nextRunAtMs = nextOccurrence(now, schedule);
|
|
178
257
|
const job = {
|
|
179
258
|
id,
|
|
@@ -204,6 +283,15 @@ export class CronAutomationManager {
|
|
|
204
283
|
return cloneJob(job);
|
|
205
284
|
});
|
|
206
285
|
}
|
|
286
|
+
/** Matching names are intentionally allowed: the work and schedule define a duplicate. */
|
|
287
|
+
async findDuplicates(agentId, envelope) {
|
|
288
|
+
await this.start();
|
|
289
|
+
const idempotentId = automationId(agentId, envelope.requestId);
|
|
290
|
+
return [...this.jobs.values()]
|
|
291
|
+
.filter((job) => job.id !== idempotentId && sameAutomation(job, envelope))
|
|
292
|
+
.sort((left, right) => left.createdAtMs - right.createdAtMs || left.id.localeCompare(right.id))
|
|
293
|
+
.map(cloneJob);
|
|
294
|
+
}
|
|
207
295
|
async update(jobId, patch) {
|
|
208
296
|
await this.start();
|
|
209
297
|
return this.enqueue(async () => {
|
package/src/protocol.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { UserMessage } from
|
|
1
|
+
import type { UserMessage } from '@deepseek-ai/dsh-llm';
|
|
2
2
|
export declare const CRON_CREATE_TYPE: "dsh/cron.create";
|
|
3
3
|
export declare const CRON_LIST_TYPE: "dsh/cron.list";
|
|
4
4
|
export interface CronCreateEnvelope {
|
|
@@ -14,8 +14,8 @@ export interface CronEditorRequest {
|
|
|
14
14
|
text: string;
|
|
15
15
|
}
|
|
16
16
|
export declare class CronAutomationInputError extends Error {
|
|
17
|
-
readonly code:
|
|
18
|
-
constructor(code:
|
|
17
|
+
readonly code: 'invalid_envelope' | 'invalid_request_id' | 'invalid_name' | 'invalid_prompt' | 'invalid_cron' | 'invalid_timezone' | 'request_id_conflict' | 'duplicate_automation';
|
|
18
|
+
constructor(code: 'invalid_envelope' | 'invalid_request_id' | 'invalid_name' | 'invalid_prompt' | 'invalid_cron' | 'invalid_timezone' | 'request_id_conflict' | 'duplicate_automation', message: string);
|
|
19
19
|
}
|
|
20
20
|
export declare function decodeCronCreateEnvelope(value: unknown): CronCreateEnvelope;
|
|
21
21
|
/** Parse a cron control envelope carried as the sole text block of a direct user message. */
|
package/src/protocol.js
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
|
-
import { assertMinimumCronInterval, CronExpressionError, validTimeZone } from
|
|
2
|
-
export const CRON_CREATE_TYPE =
|
|
3
|
-
export const CRON_LIST_TYPE =
|
|
1
|
+
import { assertMinimumCronInterval, CronExpressionError, validTimeZone } from './cron.js';
|
|
2
|
+
export const CRON_CREATE_TYPE = 'dsh/cron.create';
|
|
3
|
+
export const CRON_LIST_TYPE = 'dsh/cron.list';
|
|
4
4
|
export class CronAutomationInputError extends Error {
|
|
5
5
|
code;
|
|
6
6
|
constructor(code, message) {
|
|
7
7
|
super(message);
|
|
8
8
|
this.code = code;
|
|
9
|
-
this.name =
|
|
9
|
+
this.name = 'CronAutomationInputError';
|
|
10
10
|
}
|
|
11
11
|
}
|
|
12
12
|
function isRecord(value) {
|
|
13
|
-
return typeof value ===
|
|
13
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
14
14
|
}
|
|
15
15
|
function exactKeys(value, keys) {
|
|
16
16
|
const actual = Object.keys(value).sort();
|
|
@@ -19,34 +19,37 @@ function exactKeys(value, keys) {
|
|
|
19
19
|
}
|
|
20
20
|
export function decodeCronCreateEnvelope(value) {
|
|
21
21
|
if (!isRecord(value) || value.type !== CRON_CREATE_TYPE) {
|
|
22
|
-
throw new CronAutomationInputError(
|
|
22
|
+
throw new CronAutomationInputError('invalid_envelope', `type must be ${CRON_CREATE_TYPE}`);
|
|
23
23
|
}
|
|
24
|
-
if (!exactKeys(value, [
|
|
25
|
-
throw new CronAutomationInputError(
|
|
24
|
+
if (!exactKeys(value, ['type', 'requestId', 'name', 'prompt', 'cron', 'timezone'])) {
|
|
25
|
+
throw new CronAutomationInputError('invalid_envelope', 'cron envelope contains unknown or missing fields');
|
|
26
26
|
}
|
|
27
|
-
if (typeof value.requestId !==
|
|
28
|
-
|
|
27
|
+
if (typeof value.requestId !== 'string' ||
|
|
28
|
+
value.requestId.trim() !== value.requestId ||
|
|
29
|
+
value.requestId.length < 1 ||
|
|
30
|
+
value.requestId.length > 128) {
|
|
31
|
+
throw new CronAutomationInputError('invalid_request_id', 'requestId must contain 1-128 characters without surrounding whitespace');
|
|
29
32
|
}
|
|
30
|
-
if (typeof value.name !==
|
|
31
|
-
throw new CronAutomationInputError(
|
|
33
|
+
if (typeof value.name !== 'string' || value.name.trim() !== value.name || value.name.length < 1 || value.name.length > 120) {
|
|
34
|
+
throw new CronAutomationInputError('invalid_name', 'name must contain 1-120 characters without surrounding whitespace');
|
|
32
35
|
}
|
|
33
|
-
if (typeof value.prompt !==
|
|
34
|
-
throw new CronAutomationInputError(
|
|
36
|
+
if (typeof value.prompt !== 'string' || value.prompt.trim() !== value.prompt || value.prompt.length === 0) {
|
|
37
|
+
throw new CronAutomationInputError('invalid_prompt', 'prompt must be a non-empty string without surrounding whitespace');
|
|
35
38
|
}
|
|
36
|
-
if (typeof value.cron !==
|
|
37
|
-
throw new CronAutomationInputError(
|
|
39
|
+
if (typeof value.cron !== 'string') {
|
|
40
|
+
throw new CronAutomationInputError('invalid_cron', 'cron must be a standard five-field expression');
|
|
38
41
|
}
|
|
39
42
|
try {
|
|
40
43
|
assertMinimumCronInterval(value.cron);
|
|
41
44
|
}
|
|
42
45
|
catch (error) {
|
|
43
46
|
if (error instanceof CronExpressionError) {
|
|
44
|
-
throw new CronAutomationInputError(
|
|
47
|
+
throw new CronAutomationInputError('invalid_cron', error.message);
|
|
45
48
|
}
|
|
46
49
|
throw error;
|
|
47
50
|
}
|
|
48
|
-
if (typeof value.timezone !==
|
|
49
|
-
throw new CronAutomationInputError(
|
|
51
|
+
if (typeof value.timezone !== 'string' || !validTimeZone(value.timezone)) {
|
|
52
|
+
throw new CronAutomationInputError('invalid_timezone', 'timezone must be a valid IANA time zone');
|
|
50
53
|
}
|
|
51
54
|
return {
|
|
52
55
|
type: CRON_CREATE_TYPE,
|
|
@@ -59,13 +62,13 @@ export function decodeCronCreateEnvelope(value) {
|
|
|
59
62
|
}
|
|
60
63
|
/** Parse a cron control envelope carried as the sole text block of a direct user message. */
|
|
61
64
|
export function parseCronAutomationMessage(message) {
|
|
62
|
-
if (message.source.kind !==
|
|
65
|
+
if (message.source.kind !== 'user' || message.content.length !== 1)
|
|
63
66
|
return undefined;
|
|
64
67
|
const block = message.content[0];
|
|
65
|
-
if (block?.type !==
|
|
68
|
+
if (block?.type !== 'text')
|
|
66
69
|
return undefined;
|
|
67
70
|
const text = block.text.trim();
|
|
68
|
-
if (!text.startsWith(
|
|
71
|
+
if (!text.startsWith('{'))
|
|
69
72
|
return undefined;
|
|
70
73
|
let value;
|
|
71
74
|
try {
|
|
@@ -80,10 +83,10 @@ export function parseCronAutomationMessage(message) {
|
|
|
80
83
|
}
|
|
81
84
|
/** Recognize the stable prompt prefix emitted by the Mobook scheduled-task editor. */
|
|
82
85
|
export function parseCronEditorRequest(message) {
|
|
83
|
-
if (message.source.kind !==
|
|
86
|
+
if (message.source.kind !== 'user' || message.content.length !== 1)
|
|
84
87
|
return undefined;
|
|
85
88
|
const block = message.content[0];
|
|
86
|
-
if (block?.type !==
|
|
89
|
+
if (block?.type !== 'text')
|
|
87
90
|
return undefined;
|
|
88
91
|
const text = block.text.trim();
|
|
89
92
|
const match = /^编辑定时任务[((]\s*任务\s*ID\s+([^))]+?)\s*[))][::]/u.exec(text);
|