@evandu/super-pi 0.1.131 → 0.1.133

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 (70) hide show
  1. package/README.md +18 -0
  2. package/agents/super-pi-feishu/index.ts +88 -0
  3. package/agents/super-pi-knowledge/index.ts +1 -1
  4. package/agents/super-pi-task/core.ts +177 -0
  5. package/agents/super-pi-task/index.ts +59 -0
  6. package/dist/.super-pi-check-inputs.json +2 -2
  7. package/dist/client/.super-pi-client-sources.json +12 -9
  8. package/dist/client/assets/CodeMirrorEditor-CZZfGD0U.js +2 -0
  9. package/dist/client/assets/{dist-B448EWTM.js → dist-0FH-oFco.js} +1 -1
  10. package/dist/client/assets/{dist-Fhkmvjdx.js → dist-4Pts9xPO.js} +1 -1
  11. package/dist/client/assets/{dist-BcE6tJvc.js → dist-B5ruVtUG.js} +1 -1
  12. package/dist/client/assets/{dist-CpuexUlh.js → dist-BJinarhP.js} +1 -1
  13. package/dist/client/assets/{dist-Cybsoapc.js → dist-BffE00vI.js} +1 -1
  14. package/dist/client/assets/{dist-DxoWS5MH.js → dist-BtTFCNVQ.js} +1 -1
  15. package/dist/client/assets/{dist-CtdlYgGc.js → dist-C2nuBCP4.js} +1 -1
  16. package/dist/client/assets/{dist-hfpd-aWa.js → dist-CApAttfj.js} +1 -1
  17. package/dist/client/assets/{dist-D6PoSxR5.js → dist-CE5BnwJa.js} +1 -1
  18. package/dist/client/assets/{dist-COp25nhe.js → dist-CFPXj4ws.js} +1 -1
  19. package/dist/client/assets/{dist-D7bj81KQ.js → dist-CUKaDTyO.js} +1 -1
  20. package/dist/client/assets/{dist-BFlq-kiF.js → dist-CYD2SqmG.js} +1 -1
  21. package/dist/client/assets/{dist-lQWWWxh9.js → dist-CxsunljU.js} +1 -1
  22. package/dist/client/assets/{dist-BXVFgriA.js → dist-D4x1XTQb.js} +1 -1
  23. package/dist/client/assets/{dist-DXNDtyHP.js → dist-DHqqpxo4.js} +1 -1
  24. package/dist/client/assets/{dist-r5Af94tH.js → dist-DOYWHMIq.js} +1 -1
  25. package/dist/client/assets/{dist-B68Q8uXX.js → dist-DQf5nNxa.js} +1 -1
  26. package/dist/client/assets/{dist-C6qSkkeN.js → dist-DbbBgSwL.js} +1 -1
  27. package/dist/client/assets/{dist-B1_1Ui9E.js → dist-LFfokYJs.js} +1 -1
  28. package/dist/client/assets/{dist-ByIqsb5-.js → dist-aL2NGji_.js} +1 -1
  29. package/dist/client/assets/{dist-C9AkgNbt.js → dist-azDN8a65.js} +1 -1
  30. package/dist/client/assets/{dist-DP2ygAFF.js → dist-fYp8Xwrx.js} +1 -1
  31. package/dist/client/assets/{index-CXB6U5mz.css → index-D3MEG8uH.css} +1 -1
  32. package/dist/client/assets/index-YEN4ysHF.js +100 -0
  33. package/dist/client/index.html +2 -2
  34. package/dist/server/agents.js +21 -1
  35. package/dist/server/application/feishu-service.d.ts +179 -0
  36. package/dist/server/application/feishu-service.js +358 -0
  37. package/dist/server/application/run-service.js +15 -1
  38. package/dist/server/application/scheduled-task-service.d.ts +164 -0
  39. package/dist/server/application/scheduled-task-service.js +350 -0
  40. package/dist/server/command-handler.d.ts +25 -1
  41. package/dist/server/command-handler.js +60 -1
  42. package/dist/server/feishu-cli.d.ts +223 -0
  43. package/dist/server/feishu-cli.js +453 -0
  44. package/dist/server/feishu-config.d.ts +68 -0
  45. package/dist/server/feishu-config.js +84 -0
  46. package/dist/server/feishu.d.ts +187 -0
  47. package/dist/server/feishu.js +695 -0
  48. package/dist/server/files.js +3 -3
  49. package/dist/server/http-server.js +232 -1
  50. package/dist/server/index.js +29 -2
  51. package/dist/server/knowledge.js +10 -2
  52. package/dist/server/persistence/app-state-repository.d.ts +13 -1
  53. package/dist/server/persistence/sqlite-state-repository.js +21 -0
  54. package/dist/server/pi-session-events.js +10 -2
  55. package/dist/server/pi-session-pool.d.ts +33 -1
  56. package/dist/server/pi-session-pool.js +20 -2
  57. package/dist/server/pi-session.js +3 -0
  58. package/dist/server/runtime.d.ts +47 -1
  59. package/dist/server/runtime.js +110 -15
  60. package/dist/server/scheduled-tasks.d.ts +101 -0
  61. package/dist/server/scheduled-tasks.js +138 -0
  62. package/dist/server/server-context.d.ts +16 -0
  63. package/dist/server/store.d.ts +30 -0
  64. package/dist/server/store.js +81 -2
  65. package/dist/server/task-tool.d.ts +18 -0
  66. package/dist/server/task-tool.js +18 -0
  67. package/dist/server/types.d.ts +56 -0
  68. package/package.json +26 -4
  69. package/dist/client/assets/CodeMirrorEditor-BlQhH3vs.js +0 -2
  70. package/dist/client/assets/index-BApJ3Zbq.js +0 -98
@@ -0,0 +1,164 @@
1
+ import type { EventBroadcaster } from "../server-context.js";
2
+ import type { Store } from "../store.js";
3
+ import { describeSchedule, type ScheduledTask, type ScheduleSpec } from "../scheduled-tasks.js";
4
+ import type { ModelSelection } from "../types.js";
5
+ import type { ConversationService } from "./conversation-service.js";
6
+ import type { RunService } from "./run-service.js";
7
+ /** The constructor's last parameter: the only seams this service admits. */
8
+ export type ScheduledTaskServiceOptions = {
9
+ /**
10
+ * The TEST SEAM for the clock: how often the sweep runs. Production never passes this and gets the
11
+ * fifteen-second sweep; a test passes a few milliseconds and waits for real time instead of
12
+ * faking timers - the same role `sessionFactory` plays in `PiRpcRuntime`.
13
+ */
14
+ sweepIntervalMs?: number;
15
+ };
16
+ /** What a caller wants a task to be. Everything but the id and the bookkeeping is optional. */
17
+ export type ScheduledTaskInput = {
18
+ name?: string;
19
+ prompt?: string;
20
+ schedule?: unknown;
21
+ workspaceId?: string;
22
+ model?: ModelSelection | null;
23
+ agentId?: string | null;
24
+ enabled?: boolean;
25
+ };
26
+ /**
27
+ * THE SCHEDULED TASKS SERVICE: the list, the clock, and the runs.
28
+ *
29
+ * WHAT LIVES HERE, AND WHY NOT IN THE STORE. The store owns the record and the disk; this owns the
30
+ * clock and the act of running. Keeping the two apart is what lets the store stay a thing that can be
31
+ * opened, repaired and closed without a timer running behind it - and it is why a scheduled task
32
+ * survives a restart as a RECORD while the thing that fires it is built fresh from that record.
33
+ */
34
+ export declare class ScheduledTaskService {
35
+ private readonly store;
36
+ private readonly runService;
37
+ private readonly conversationService;
38
+ private readonly broadcaster;
39
+ /** The runs currently in flight, by task id. A task in here is not fired again. */
40
+ private readonly inFlight;
41
+ private timer?;
42
+ private stopped;
43
+ constructor(store: Store, runService: RunService, conversationService: ConversationService, broadcaster: EventBroadcaster, options?: ScheduledTaskServiceOptions);
44
+ private readonly sweepIntervalMs;
45
+ /**
46
+ * Starts the clock.
47
+ *
48
+ * `unref()` so the sweep never holds the process open: the shutdown sequence closes the server and
49
+ * the store, and a pending interval would keep the process alive after that, which is the difference
50
+ * between a server that exits and one that has to be killed.
51
+ */
52
+ start(): void;
53
+ /**
54
+ * Stops the clock and waits for whatever is running.
55
+ *
56
+ * The in-flight runs are NOT aborted: a task that is halfway through its answer is a real answer in
57
+ * a real conversation, and killing it because the server is shutting down would throw that work
58
+ * away. The sweep stops, the runs finish, and the next start picks the schedule up from what is on
59
+ * disk.
60
+ */
61
+ stop(): Promise<void>;
62
+ /** Every task on record, newest first. */
63
+ list(): ScheduledTask[];
64
+ /** The tasks of ONE workspace, which is what the panel shows. */
65
+ listForWorkspace(workspaceId: string | undefined): ScheduledTask[];
66
+ /**
67
+ * The task, if it belongs to THIS workspace - and a plain "not found" if it does not.
68
+ *
69
+ * WHY THE OWNERSHIP CHECK IS HERE, IN THE SERVICE. A task is a thing that RUNS in a workspace: it
70
+ * opens its conversation there, uses that workspace's defaults and its tools. A caller in another
71
+ * workspace cannot even list these tasks - `listForWorkspace` is the scoped read - so it must not
72
+ * be able to change or delete them either. Before this, `update` and `delete` acted on an id alone:
73
+ * a task created from workspace B could be rewritten and then deleted by a conversation in
74
+ * workspace A, and it STILL RAN in B, so A's prompt executed inside B with B's model and B's tools
75
+ * - an invisible cross-workspace write (measured by scripts/scheduled-task-scope.test.mjs, ledger
76
+ * TEST-026).
77
+ *
78
+ * THE REFUSAL DOES NOT SAY WHERE IT IS. "Not found" is the whole answer: a caller must not be able
79
+ * to learn that a task exists in a workspace it cannot see.
80
+ */
81
+ requireForWorkspace(id: string, workspaceId: string): ScheduledTask;
82
+ /**
83
+ * Creates a task.
84
+ *
85
+ * `createdBy` is recorded because the two kinds are not the same thing to a reader: a task the user
86
+ * asked for and a task an agent invented look identical in the list, and the difference is exactly
87
+ * what a person wants to know before they delete it.
88
+ */
89
+ create(input: ScheduledTaskInput, createdBy: "user" | "agent"): Promise<ScheduledTask>;
90
+ /**
91
+ * Changes a task.
92
+ *
93
+ * EVERY CHANGE RECOMPUTES `nextRunAt`, because a task whose prompt was edited an hour before its
94
+ * slot must not fire at the old slot with the old schedule - and a task whose schedule was edited
95
+ * must not keep the old next time. The one exception is a manual trigger, which is not a change at
96
+ * all: it runs the task once and leaves the clock where it was.
97
+ */
98
+ update(id: string, input: ScheduledTaskInput): Promise<ScheduledTask>;
99
+ /** Turns a task on or off. */
100
+ setEnabled(id: string, enabled: boolean): Promise<ScheduledTask>;
101
+ /** Removes a task. Its conversation stays: it holds the answers the task produced. */
102
+ remove(id: string): Promise<void>;
103
+ /**
104
+ * Runs a task NOW, once.
105
+ *
106
+ * The clock is untouched: a manual run does not make the next scheduled run earlier or later, and it
107
+ * does not count as the task having fired. This is the difference between 「先试一次」 and 「改一下周期」,
108
+ * and conflating them would make 「先试一次」 silently rewrite the user's schedule.
109
+ *
110
+ * THE ACK RETURNS BEFORE THE RUN FINISHES. The run itself takes as long as a model turn takes -
111
+ * seconds, usually, sometimes minutes - and a command that waited for it would hold the socket's
112
+ * whole command chain hostage for that long. The outcome is not lost: it lands in `lastRunStatus`
113
+ * and arrives at the panel through the ordinary state broadcast, where a person reads it.
114
+ */
115
+ trigger(id: string): Promise<void>;
116
+ /**
117
+ * The sweep: every task whose moment has come runs once.
118
+ *
119
+ * The tasks that are due are collected BEFORE any of them runs, and each one advances its own
120
+ * `nextRunAt` before it runs - so a task that takes three minutes cannot be fired again by the next
121
+ * sweep, and two sweeps can never both pick up the same task.
122
+ */
123
+ private sweep;
124
+ /**
125
+ * THE ACTUAL RUN, for both the clock and the button.
126
+ *
127
+ * The task gets its OWN conversation, created on the first run, and the prompt is sent into it
128
+ * verbatim as a user message - the same path a person's own message takes. That sameness is the point:
129
+ * a scheduled task has no special powers, so it cannot do anything a message could not, and it gets
130
+ * the same model, the same tools, the same memory.
131
+ */
132
+ private fire;
133
+ /** The body of a run: get a conversation, put the prompt into it, remember how it went. */
134
+ private runTask;
135
+ /**
136
+ * The conversation the task runs in, created the first time it is needed.
137
+ *
138
+ * LAZY, so that creating a task does not create a conversation nobody has spoken in. The name says
139
+ * what the conversation is, because it appears in the conversation list like any other and the user
140
+ * has to be able to tell it apart from their own work.
141
+ */
142
+ private ensureConversation;
143
+ /**
144
+ * Puts the task's model and agent on its conversation.
145
+ *
146
+ * EVERY RUN, not only the first: the user can change the model in the panel, and the next run is the
147
+ * one that has to use it. A failure here is the task's failure - a model that Pi no longer offers
148
+ * cannot be silently ignored, because the run would then happen on some other model and the panel
149
+ * would say it succeeded.
150
+ */
151
+ private applyConfiguration;
152
+ /** Writes down how the run went, and tells the panel. */
153
+ private record;
154
+ /** The task, or a refusal in words the reader can act on. */
155
+ private require;
156
+ }
157
+ /**
158
+ * The schedule in words, for the text an agent reads back after creating a task.
159
+ *
160
+ * Exported from here rather than from `scheduled-tasks.ts` so that the HTTP layer and the WS layer
161
+ * say the same thing about one schedule.
162
+ */
163
+ export { describeSchedule };
164
+ export type { ScheduledTask, ScheduleSpec };
@@ -0,0 +1,350 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { describeSchedule, nextRunAt, normalizeSchedule } from "../scheduled-tasks.js";
3
+ /**
4
+ * HOW OFTEN THE SCHEDULER LOOKS.
5
+ *
6
+ * A sweep every fifteen seconds, rather than one timer per task. A per-task timer would fire closer to
7
+ * the requested minute, and it would need a timer to be created, replaced and destroyed every time a
8
+ * task was edited - a class of bug (a timer left pointing at the old schedule) that a single sweep
9
+ * cannot have. The cost of the sweep is a scan of a list that is bounded at a hundred entries.
10
+ *
11
+ * FIFTEEN SECONDS IS ALSO THE HONEST ANSWER to "how precise is 'every day at 09:00'": a task can fire
12
+ * up to fifteen seconds after the minute it was asked for, and never before it.
13
+ */
14
+ const SWEEP_INTERVAL_MS = 15_000;
15
+ /**
16
+ * THE SCHEDULED TASKS SERVICE: the list, the clock, and the runs.
17
+ *
18
+ * WHAT LIVES HERE, AND WHY NOT IN THE STORE. The store owns the record and the disk; this owns the
19
+ * clock and the act of running. Keeping the two apart is what lets the store stay a thing that can be
20
+ * opened, repaired and closed without a timer running behind it - and it is why a scheduled task
21
+ * survives a restart as a RECORD while the thing that fires it is built fresh from that record.
22
+ */
23
+ export class ScheduledTaskService {
24
+ store;
25
+ runService;
26
+ conversationService;
27
+ broadcaster;
28
+ /** The runs currently in flight, by task id. A task in here is not fired again. */
29
+ inFlight = new Map();
30
+ timer;
31
+ stopped = true;
32
+ constructor(store, runService, conversationService, broadcaster, options = {}) {
33
+ this.store = store;
34
+ this.runService = runService;
35
+ this.conversationService = conversationService;
36
+ this.broadcaster = broadcaster;
37
+ this.sweepIntervalMs = options.sweepIntervalMs ?? SWEEP_INTERVAL_MS;
38
+ }
39
+ sweepIntervalMs;
40
+ /**
41
+ * Starts the clock.
42
+ *
43
+ * `unref()` so the sweep never holds the process open: the shutdown sequence closes the server and
44
+ * the store, and a pending interval would keep the process alive after that, which is the difference
45
+ * between a server that exits and one that has to be killed.
46
+ */
47
+ start() {
48
+ if (this.timer)
49
+ return;
50
+ this.stopped = false;
51
+ this.timer = setInterval(() => { void this.sweep(); }, this.sweepIntervalMs);
52
+ this.timer.unref?.();
53
+ }
54
+ /**
55
+ * Stops the clock and waits for whatever is running.
56
+ *
57
+ * The in-flight runs are NOT aborted: a task that is halfway through its answer is a real answer in
58
+ * a real conversation, and killing it because the server is shutting down would throw that work
59
+ * away. The sweep stops, the runs finish, and the next start picks the schedule up from what is on
60
+ * disk.
61
+ */
62
+ async stop() {
63
+ this.stopped = true;
64
+ if (this.timer) {
65
+ clearInterval(this.timer);
66
+ this.timer = undefined;
67
+ }
68
+ await Promise.allSettled([...this.inFlight.values()]);
69
+ }
70
+ /** Every task on record, newest first. */
71
+ list() {
72
+ return [...(this.store.snapshot().scheduledTasks ?? [])].sort((a, b) => b.createdAt - a.createdAt);
73
+ }
74
+ /** The tasks of ONE workspace, which is what the panel shows. */
75
+ listForWorkspace(workspaceId) {
76
+ if (!workspaceId)
77
+ return [];
78
+ return this.list().filter((task) => task.workspaceId === workspaceId);
79
+ }
80
+ /**
81
+ * The task, if it belongs to THIS workspace - and a plain "not found" if it does not.
82
+ *
83
+ * WHY THE OWNERSHIP CHECK IS HERE, IN THE SERVICE. A task is a thing that RUNS in a workspace: it
84
+ * opens its conversation there, uses that workspace's defaults and its tools. A caller in another
85
+ * workspace cannot even list these tasks - `listForWorkspace` is the scoped read - so it must not
86
+ * be able to change or delete them either. Before this, `update` and `delete` acted on an id alone:
87
+ * a task created from workspace B could be rewritten and then deleted by a conversation in
88
+ * workspace A, and it STILL RAN in B, so A's prompt executed inside B with B's model and B's tools
89
+ * - an invisible cross-workspace write (measured by scripts/scheduled-task-scope.test.mjs, ledger
90
+ * TEST-026).
91
+ *
92
+ * THE REFUSAL DOES NOT SAY WHERE IT IS. "Not found" is the whole answer: a caller must not be able
93
+ * to learn that a task exists in a workspace it cannot see.
94
+ */
95
+ requireForWorkspace(id, workspaceId) {
96
+ const task = this.listForWorkspace(workspaceId).find((item) => item.id === id);
97
+ if (!task)
98
+ throw new Error("找不到这个定时任务。");
99
+ return task;
100
+ }
101
+ /**
102
+ * Creates a task.
103
+ *
104
+ * `createdBy` is recorded because the two kinds are not the same thing to a reader: a task the user
105
+ * asked for and a task an agent invented look identical in the list, and the difference is exactly
106
+ * what a person wants to know before they delete it.
107
+ */
108
+ async create(input, createdBy) {
109
+ const prompt = (input.prompt ?? "").trim();
110
+ if (!prompt)
111
+ throw new Error("定时任务需要一段提示词:到点时这段话会被当作用户消息发给 agent。");
112
+ const workspaceId = (input.workspaceId ?? "").trim();
113
+ if (!workspaceId)
114
+ throw new Error("定时任务需要属于一个工作区。");
115
+ if (!this.store.snapshot().workspaces.some((workspace) => workspace.id === workspaceId))
116
+ throw new Error("找不到这个工作区。");
117
+ const schedule = normalizeSchedule(input.schedule);
118
+ const now = Date.now();
119
+ const firstRunAt = nextRunAt(schedule, now);
120
+ // A ONE-SHOT TASK WHOSE MOMENT HAS PASSED is not a task, it is a memory of one. Refusing here is
121
+ // what keeps it from being created with no next run and never firing - a task that exists but
122
+ // cannot run is the worst outcome, because nothing on screen explains why.
123
+ if (schedule.kind === "once" && firstRunAt === undefined)
124
+ throw new Error("一次性任务的触发时刻要在将来。");
125
+ const task = {
126
+ id: `scheduled_${randomUUID()}`,
127
+ name: (input.name ?? "").trim() || defaultName(prompt),
128
+ prompt,
129
+ workspaceId,
130
+ schedule,
131
+ enabled: input.enabled === false ? false : true,
132
+ nextRunAt: firstRunAt,
133
+ createdBy,
134
+ createdAt: now,
135
+ updatedAt: now,
136
+ ...(input.model ? { model: input.model } : {}),
137
+ ...(input.agentId ? { agentId: input.agentId } : {}),
138
+ };
139
+ const stored = await this.store.setScheduledTask(task);
140
+ this.broadcaster.broadcastState();
141
+ return stored;
142
+ }
143
+ /**
144
+ * Changes a task.
145
+ *
146
+ * EVERY CHANGE RECOMPUTES `nextRunAt`, because a task whose prompt was edited an hour before its
147
+ * slot must not fire at the old slot with the old schedule - and a task whose schedule was edited
148
+ * must not keep the old next time. The one exception is a manual trigger, which is not a change at
149
+ * all: it runs the task once and leaves the clock where it was.
150
+ */
151
+ async update(id, input) {
152
+ const task = this.require(id);
153
+ const now = Date.now();
154
+ const schedule = input.schedule === undefined ? task.schedule : normalizeSchedule(input.schedule);
155
+ const updated = {
156
+ ...task,
157
+ name: input.name === undefined ? task.name : (input.name.trim() || task.name),
158
+ prompt: input.prompt === undefined ? task.prompt : (input.prompt.trim() || task.prompt),
159
+ schedule,
160
+ enabled: input.enabled === undefined ? task.enabled : input.enabled,
161
+ updatedAt: now,
162
+ };
163
+ if (input.model !== undefined) {
164
+ if (input.model)
165
+ updated.model = input.model;
166
+ else
167
+ delete updated.model;
168
+ }
169
+ if (input.agentId !== undefined) {
170
+ if (input.agentId)
171
+ updated.agentId = input.agentId;
172
+ else
173
+ delete updated.agentId;
174
+ }
175
+ // A DISABLED TASK HAS NO NEXT TIME, and an enabled one always has one - unless it is a one-shot
176
+ // whose moment has passed, which is a finished task rather than a broken one.
177
+ updated.nextRunAt = updated.enabled ? nextRunAt(schedule, now) : undefined;
178
+ const stored = await this.store.setScheduledTask(updated);
179
+ this.broadcaster.broadcastState();
180
+ return stored;
181
+ }
182
+ /** Turns a task on or off. */
183
+ async setEnabled(id, enabled) {
184
+ const task = this.require(id);
185
+ if (task.enabled === enabled)
186
+ return task;
187
+ return this.update(id, { enabled });
188
+ }
189
+ /** Removes a task. Its conversation stays: it holds the answers the task produced. */
190
+ async remove(id) {
191
+ this.require(id);
192
+ await this.store.removeScheduledTask(id);
193
+ this.broadcaster.broadcastState();
194
+ }
195
+ /**
196
+ * Runs a task NOW, once.
197
+ *
198
+ * The clock is untouched: a manual run does not make the next scheduled run earlier or later, and it
199
+ * does not count as the task having fired. This is the difference between 「先试一次」 and 「改一下周期」,
200
+ * and conflating them would make 「先试一次」 silently rewrite the user's schedule.
201
+ *
202
+ * THE ACK RETURNS BEFORE THE RUN FINISHES. The run itself takes as long as a model turn takes -
203
+ * seconds, usually, sometimes minutes - and a command that waited for it would hold the socket's
204
+ * whole command chain hostage for that long. The outcome is not lost: it lands in `lastRunStatus`
205
+ * and arrives at the panel through the ordinary state broadcast, where a person reads it.
206
+ */
207
+ async trigger(id) {
208
+ const task = this.require(id);
209
+ this.fire(task, "manual").catch(() => undefined);
210
+ }
211
+ /**
212
+ * The sweep: every task whose moment has come runs once.
213
+ *
214
+ * The tasks that are due are collected BEFORE any of them runs, and each one advances its own
215
+ * `nextRunAt` before it runs - so a task that takes three minutes cannot be fired again by the next
216
+ * sweep, and two sweeps can never both pick up the same task.
217
+ */
218
+ async sweep() {
219
+ if (this.stopped)
220
+ return;
221
+ const now = Date.now();
222
+ const due = (this.store.snapshot().scheduledTasks ?? []).filter((task) => task.enabled && task.nextRunAt !== undefined && task.nextRunAt <= now);
223
+ for (const task of due) {
224
+ if (this.inFlight.has(task.id))
225
+ continue;
226
+ const next = nextRunAt(task.schedule, now);
227
+ await this.store.setScheduledTask({
228
+ ...task,
229
+ // A ONE-SHOT TASK IS FINISHED once it has fired. It is disabled rather than deleted, because
230
+ // the answers it produced are worth keeping and the user can see that it ran.
231
+ enabled: next === undefined ? false : task.enabled,
232
+ nextRunAt: next,
233
+ updatedAt: now,
234
+ });
235
+ this.broadcaster.broadcastState();
236
+ void this.fire(task, "schedule");
237
+ }
238
+ }
239
+ /**
240
+ * THE ACTUAL RUN, for both the clock and the button.
241
+ *
242
+ * The task gets its OWN conversation, created on the first run, and the prompt is sent into it
243
+ * verbatim as a user message - the same path a person's own message takes. That sameness is the point:
244
+ * a scheduled task has no special powers, so it cannot do anything a message could not, and it gets
245
+ * the same model, the same tools, the same memory.
246
+ */
247
+ async fire(task, reason) {
248
+ const running = this.inFlight.get(task.id);
249
+ if (running) {
250
+ await running;
251
+ return;
252
+ }
253
+ const promise = this.runTask(task, reason).finally(() => { this.inFlight.delete(task.id); });
254
+ this.inFlight.set(task.id, promise);
255
+ await promise;
256
+ }
257
+ /** The body of a run: get a conversation, put the prompt into it, remember how it went. */
258
+ async runTask(task, reason) {
259
+ const startedAt = Date.now();
260
+ try {
261
+ const conversationId = await this.ensureConversation(task);
262
+ await this.runService.enqueueAndRun(conversationId, task.prompt);
263
+ await this.record(task, { status: "completed", message: reason === "manual" ? "手动触发完成" : "按时触发完成", at: startedAt });
264
+ }
265
+ catch (error) {
266
+ // A FAILED RUN IS A RECORD, not a silence. The task keeps its schedule - a broken model or a
267
+ // missing workspace is worth retrying at the next slot - and the failure is written where the
268
+ // panel can show it.
269
+ await this.record(task, { status: "failed", message: error instanceof Error ? error.message : String(error), at: startedAt });
270
+ }
271
+ }
272
+ /**
273
+ * The conversation the task runs in, created the first time it is needed.
274
+ *
275
+ * LAZY, so that creating a task does not create a conversation nobody has spoken in. The name says
276
+ * what the conversation is, because it appears in the conversation list like any other and the user
277
+ * has to be able to tell it apart from their own work.
278
+ */
279
+ async ensureConversation(task) {
280
+ const state = this.store.snapshot();
281
+ const existing = task.conversationId ? state.conversations.find((conversation) => conversation.id === task.conversationId) : undefined;
282
+ if (existing) {
283
+ await this.applyConfiguration(task, existing.id);
284
+ return existing.id;
285
+ }
286
+ const conversationId = await this.conversationService.createConversation(task.workspaceId);
287
+ await this.conversationService.renameConversation(conversationId, `定时:${task.name}`);
288
+ // THE CURRENT ROW, NOT THE STALE ONE. `task` is the snapshot the caller was holding, and its
289
+ // `nextRunAt` may already have been advanced by a sweep that fired this run - writing it back
290
+ // would move the clock backwards and the next sweep would fire the task a second time. The
291
+ // fresh row is read before the write, so the only field this changes is the one this line
292
+ // means to change.
293
+ const current = this.store.snapshot().scheduledTasks?.find((item) => item.id === task.id);
294
+ await this.store.setScheduledTask({ ...(current ?? task), conversationId, updatedAt: Date.now() });
295
+ await this.applyConfiguration(task, conversationId);
296
+ return conversationId;
297
+ }
298
+ /**
299
+ * Puts the task's model and agent on its conversation.
300
+ *
301
+ * EVERY RUN, not only the first: the user can change the model in the panel, and the next run is the
302
+ * one that has to use it. A failure here is the task's failure - a model that Pi no longer offers
303
+ * cannot be silently ignored, because the run would then happen on some other model and the panel
304
+ * would say it succeeded.
305
+ */
306
+ async applyConfiguration(task, conversationId) {
307
+ if (task.model) {
308
+ await this.conversationService.setModel(conversationId, task.model.provider, task.model.modelId);
309
+ }
310
+ if (task.agentId) {
311
+ await this.conversationService.setAgent(conversationId, task.agentId);
312
+ }
313
+ }
314
+ /** Writes down how the run went, and tells the panel. */
315
+ async record(task, outcome) {
316
+ const current = this.store.snapshot().scheduledTasks?.find((item) => item.id === task.id);
317
+ if (!current)
318
+ return;
319
+ await this.store.setScheduledTask({
320
+ ...current,
321
+ lastRunAt: outcome.at,
322
+ lastRunStatus: outcome.status,
323
+ lastRunMessage: outcome.message,
324
+ updatedAt: Date.now(),
325
+ });
326
+ this.broadcaster.broadcastState();
327
+ }
328
+ /** The task, or a refusal in words the reader can act on. */
329
+ require(id) {
330
+ const task = this.store.snapshot().scheduledTasks?.find((item) => item.id === id);
331
+ if (!task)
332
+ throw new Error("找不到这个定时任务。");
333
+ return task;
334
+ }
335
+ }
336
+ /**
337
+ * The name a task gets when nobody gave it one: the first line of its prompt, cut to something that
338
+ * fits on a line in the panel.
339
+ */
340
+ function defaultName(prompt) {
341
+ const firstLine = prompt.split("\n").map((line) => line.trim()).find(Boolean) ?? prompt.trim();
342
+ return firstLine.length > 24 ? `${firstLine.slice(0, 24)}…` : firstLine;
343
+ }
344
+ /**
345
+ * The schedule in words, for the text an agent reads back after creating a task.
346
+ *
347
+ * Exported from here rather than from `scheduled-tasks.ts` so that the HTTP layer and the WS layer
348
+ * say the same thing about one schedule.
349
+ */
350
+ export { describeSchedule };
@@ -5,6 +5,7 @@ import type { RunService } from "./application/run-service.js";
5
5
  import type { ExtensionUiService } from "./application/extension-ui-service.js";
6
6
  import type { CatalogService } from "./application/catalog-service.js";
7
7
  import type { ProviderService } from "./application/provider-service.js";
8
+ import type { ScheduledTaskService } from "./application/scheduled-task-service.js";
8
9
  import type { TerminalService } from "./terminal.js";
9
10
  export type SocketSender = (event: ServerEvent) => void;
10
11
  /**
@@ -25,6 +26,14 @@ export declare class CommandHandler {
25
26
  * and a missing service has to say so rather than take the whole handler down.
26
27
  */
27
28
  private readonly providerService?;
29
+ /**
30
+ * The scheduled tasks, and the clock behind them.
31
+ *
32
+ * Optional because the handler is constructed by tests that have no clock to give it, and a missing
33
+ * service has to say so rather than take the whole handler down - the same rule `providerService`
34
+ * follows.
35
+ */
36
+ private readonly scheduledTaskService?;
28
37
  /**
29
38
  * The built-in terminal service.
30
39
  *
@@ -40,7 +49,15 @@ export declare class CommandHandler {
40
49
  * Optional because the command handler is constructed by tests that do not care about providers,
41
50
  * and a missing service has to say so rather than take the whole handler down.
42
51
  */
43
- providerService?: ProviderService | undefined);
52
+ providerService?: ProviderService | undefined,
53
+ /**
54
+ * The scheduled tasks, and the clock behind them.
55
+ *
56
+ * Optional because the handler is constructed by tests that have no clock to give it, and a missing
57
+ * service has to say so rather than take the whole handler down - the same rule `providerService`
58
+ * follows.
59
+ */
60
+ scheduledTaskService?: ScheduledTaskService | undefined);
44
61
  /**
45
62
  * Processes a client command.
46
63
  *
@@ -64,4 +81,11 @@ export declare class CommandHandler {
64
81
  /** The other half of `claimRevert`. Safe to call for a conversation nothing claimed. */
65
82
  releaseRevert(conversationId: string): void;
66
83
  handleCommand(command: ClientCommand, sendToSocket?: SocketSender, remoteAddress?: string): Promise<string | void>;
84
+ /**
85
+ * The scheduled-task service, or a refusal.
86
+ *
87
+ * A refusal in the client's own words rather than a crash: the panel that sent the command shows
88
+ * the message, and a handler that threw a TypeError would show nothing but a failed operation.
89
+ */
90
+ private requireScheduledTasks;
67
91
  }
@@ -13,6 +13,7 @@ export class CommandHandler {
13
13
  catalogService;
14
14
  rootDir;
15
15
  providerService;
16
+ scheduledTaskService;
16
17
  /**
17
18
  * The built-in terminal service.
18
19
  *
@@ -28,7 +29,15 @@ export class CommandHandler {
28
29
  * Optional because the command handler is constructed by tests that do not care about providers,
29
30
  * and a missing service has to say so rather than take the whole handler down.
30
31
  */
31
- providerService) {
32
+ providerService,
33
+ /**
34
+ * The scheduled tasks, and the clock behind them.
35
+ *
36
+ * Optional because the handler is constructed by tests that have no clock to give it, and a missing
37
+ * service has to say so rather than take the whole handler down - the same rule `providerService`
38
+ * follows.
39
+ */
40
+ scheduledTaskService) {
32
41
  this.workspaceService = workspaceService;
33
42
  this.conversationService = conversationService;
34
43
  this.runService = runService;
@@ -36,6 +45,7 @@ export class CommandHandler {
36
45
  this.catalogService = catalogService;
37
46
  this.rootDir = rootDir;
38
47
  this.providerService = providerService;
48
+ this.scheduledTaskService = scheduledTaskService;
39
49
  this.terminalService = terminalService;
40
50
  }
41
51
  /**
@@ -295,6 +305,44 @@ export class CommandHandler {
295
305
  this.runService.stopWorkflow(command.workflowId);
296
306
  return;
297
307
  }
308
+ // THE SCHEDULED TASKS, five commands over one list.
309
+ //
310
+ // They are all here rather than in a service of their own because they are all one verb applied to
311
+ // one collection: the service owns the schedule and the clock, and this is the routing table that
312
+ // says which of its methods a command reaches.
313
+ if (command.type === "scheduledTask.create") {
314
+ await this.requireScheduledTasks().create({
315
+ name: command.name,
316
+ prompt: command.prompt,
317
+ schedule: command.schedule,
318
+ workspaceId: command.workspaceId,
319
+ model: command.model,
320
+ agentId: command.agentId,
321
+ }, "user");
322
+ return;
323
+ }
324
+ if (command.type === "scheduledTask.update") {
325
+ await this.requireScheduledTasks().update(command.id, {
326
+ name: command.name,
327
+ prompt: command.prompt,
328
+ schedule: command.schedule,
329
+ model: command.model,
330
+ agentId: command.agentId,
331
+ });
332
+ return;
333
+ }
334
+ if (command.type === "scheduledTask.delete") {
335
+ await this.requireScheduledTasks().remove(command.id);
336
+ return;
337
+ }
338
+ if (command.type === "scheduledTask.trigger") {
339
+ await this.requireScheduledTasks().trigger(command.id);
340
+ return;
341
+ }
342
+ if (command.type === "scheduledTask.toggle") {
343
+ await this.requireScheduledTasks().setEnabled(command.id, command.enabled);
344
+ return;
345
+ }
298
346
  if (command.type === "extension.ui.answer") {
299
347
  const answer = command.cancelled
300
348
  ? { cancelled: true }
@@ -396,4 +444,15 @@ export class CommandHandler {
396
444
  }
397
445
  throw new Error(`未知的命令类型:${JSON.stringify(command.type ?? null)}`);
398
446
  }
447
+ /**
448
+ * The scheduled-task service, or a refusal.
449
+ *
450
+ * A refusal in the client's own words rather than a crash: the panel that sent the command shows
451
+ * the message, and a handler that threw a TypeError would show nothing but a failed operation.
452
+ */
453
+ requireScheduledTasks() {
454
+ if (!this.scheduledTaskService)
455
+ throw new Error("定时任务功能在这个实例上没有启用。");
456
+ return this.scheduledTaskService;
457
+ }
399
458
  }