agent-loft 0.2.29 → 0.2.31
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 +5 -0
- package/package.json +1 -1
- package/src/agents/claude/driver.ts +3 -2
- package/src/agents/claude/session.ts +11 -4
- package/src/agents/codex/session.ts +9 -3
- package/src/channels/feishu/bot.ts +8 -4
- package/src/channels/feishu/turn-stream.ts +3 -2
- package/src/channels/web/api.ts +4 -0
- package/src/channels/web/server.ts +14 -2
- package/src/channels/web/tasks-api.ts +94 -0
- package/src/core/chat-mode.ts +14 -14
- package/src/core/hub.ts +95 -16
- package/src/core/memory-mcp.ts +1 -1
- package/src/core/personal.ts +3 -1
- package/src/core/store.ts +2 -0
- package/src/core/task-prompt.ts +38 -0
- package/src/core/task-runner.ts +232 -0
- package/src/core/task-schedule.ts +250 -0
- package/src/core/tasks-bridge.ts +252 -0
- package/src/core/tasks-mcp.ts +55 -0
- package/src/core/tasks.ts +281 -0
- package/src/core/types.ts +2 -0
- package/src/main.ts +8 -0
- package/src/mcp/tasks-server.ts +206 -0
- package/web/app.js +8 -5560
- package/web/css/base.css +291 -0
- package/web/css/composer.css +506 -0
- package/web/css/loader.css +196 -0
- package/web/css/messages.css +734 -0
- package/web/css/mobile.css +100 -0
- package/web/css/overlay.css +437 -0
- package/web/css/schedule.css +93 -0
- package/web/css/settings.css +335 -0
- package/web/css/sidebar.css +593 -0
- package/web/css/tasks.css +455 -0
- package/web/css/topbar.css +205 -0
- package/web/index.html +12 -1
- package/web/lib/api.js +26 -0
- package/web/lib/dom.js +101 -0
- package/web/lib/markdown.js +52 -0
- package/web/lib/overlay.js +144 -0
- package/web/lib/push.js +70 -0
- package/web/lib/time.js +60 -0
- package/web/lib/ws.js +236 -0
- package/web/settings/agents.js +210 -0
- package/web/settings/commands.js +411 -0
- package/web/settings/common.js +42 -0
- package/web/settings/feishu.js +181 -0
- package/web/settings/general.js +184 -0
- package/web/settings/index.js +63 -0
- package/web/settings/memory.js +366 -0
- package/web/settings/notify.js +80 -0
- package/web/settings/security.js +188 -0
- package/web/settings/users.js +189 -0
- package/web/state.js +46 -0
- package/web/views/auth.js +61 -0
- package/web/views/composer.js +324 -0
- package/web/views/conv-actions.js +412 -0
- package/web/views/interaction.js +140 -0
- package/web/views/layout.js +197 -0
- package/web/views/messages.js +531 -0
- package/web/views/quota.js +145 -0
- package/web/views/schedule.js +120 -0
- package/web/views/sidebar.js +458 -0
- package/web/views/slash.js +185 -0
- package/web/views/tasks.js +460 -0
- package/web/views/topbar.js +400 -0
- package/web/views/voice.js +189 -0
- package/web/style.css +0 -3500
package/src/core/hub.ts
CHANGED
|
@@ -46,6 +46,8 @@ import type {
|
|
|
46
46
|
import { buildCatalog, expandCustomCommand, type AgentCommand, type CommandEntry, type CommandPrefs } from "./command-catalog.js";
|
|
47
47
|
import { helpText, type ParsedCommand } from "./commands.js";
|
|
48
48
|
import { listScheduled } from "./scheduled.js";
|
|
49
|
+
import { stripContext } from "./chat-mode.js";
|
|
50
|
+
import { isSilentReply } from "./task-prompt.js";
|
|
49
51
|
|
|
50
52
|
// ---------- 事件 ----------
|
|
51
53
|
|
|
@@ -66,12 +68,22 @@ export type ConvEvent =
|
|
|
66
68
|
| { type: "step"; kind: StepKind; text: string }
|
|
67
69
|
| { type: "delta"; text: string }
|
|
68
70
|
| { type: "text"; text: string }
|
|
69
|
-
|
|
71
|
+
/**
|
|
72
|
+
* task:这一轮是定时任务发起的;silent:任务让 agent 自己判断要不要提醒,它判断这次不用 ——
|
|
73
|
+
* 推送、飞书都不发,网页上折叠显示
|
|
74
|
+
*/
|
|
75
|
+
| { type: "result"; text: string; footer: string; at: number; anchor?: string; task?: { id: string; title: string }; silent?: true }
|
|
70
76
|
| { type: "error"; message: string }
|
|
71
77
|
| { type: "notice"; text: string }
|
|
72
78
|
| { type: "state"; busy: boolean }
|
|
73
79
|
| { type: "meta" };
|
|
74
80
|
|
|
81
|
+
/** 定时任务发起的一轮(见 core/task-runner.ts) */
|
|
82
|
+
export type TaskTurn = { taskId: string; runId: string; title: string; smart: boolean };
|
|
83
|
+
|
|
84
|
+
/** 定时任务的一轮结束了 */
|
|
85
|
+
export type TaskTurnEnd = TaskTurn & { key: string; status: "notified" | "silent" | "error" | "interrupted"; text: string };
|
|
86
|
+
|
|
75
87
|
/** 一条输入是在哪个时刻收到的:停止代次 + 生命周期代次 */
|
|
76
88
|
/**
|
|
77
89
|
* 一条输入收到时的状态快照。
|
|
@@ -141,6 +153,8 @@ export type ConversationInfo = {
|
|
|
141
153
|
personal?: boolean;
|
|
142
154
|
/** 排着的定时消息(见 core/scheduled.ts),按发送时间先后 */
|
|
143
155
|
scheduled?: { id: string; text: string; at: number }[];
|
|
156
|
+
/** 定时任务的专用对话 */
|
|
157
|
+
taskId?: string;
|
|
144
158
|
};
|
|
145
159
|
|
|
146
160
|
/** 普通成员点了会落盘的「总是允许」 */
|
|
@@ -162,9 +176,14 @@ function withTimeout<T>(p: Promise<T>, what: string, ms = QUERY_TIMEOUT_MS): Pro
|
|
|
162
176
|
|
|
163
177
|
type Live = { id: number; agent: string; cwd: string; session: AgentSession };
|
|
164
178
|
|
|
165
|
-
class Hub extends EventEmitter<{ event: [EventEnvelope] }> {
|
|
179
|
+
class Hub extends EventEmitter<{ event: [EventEnvelope]; task: [TaskTurnEnd] }> {
|
|
166
180
|
private drivers = new Map<string, AgentDriver>();
|
|
167
181
|
private live = new Map<string, Live>();
|
|
182
|
+
/**
|
|
183
|
+
* 每条会话已经送进 agent、还没出结果的轮次,按先后排:定时任务发起的带着 TaskTurn,普通消息是 null。
|
|
184
|
+
* 和 inflight 一样一轮一项,出结果 / 出错 / 被丢弃时从头上取走。
|
|
185
|
+
*/
|
|
186
|
+
private turnTags = new Map<string, (TaskTurn | null)[]>();
|
|
168
187
|
private buffers = new Map<string, EventEnvelope[]>();
|
|
169
188
|
private seq = 0;
|
|
170
189
|
/** 每条会话还没收到结果的轮数,> 0 即忙 */
|
|
@@ -446,6 +465,7 @@ class Hub extends EventEmitter<{ event: [EventEnvelope] }> {
|
|
|
446
465
|
...(this.drivers.get(conv.agent)?.forkAt ? { canFork: true } : {}),
|
|
447
466
|
mode: conv.mode ?? "work",
|
|
448
467
|
...(memoryOwnerOf(conv) ? { personal: true } : {}),
|
|
468
|
+
...(conv.taskId ? { taskId: conv.taskId } : {}),
|
|
449
469
|
...(() => {
|
|
450
470
|
const list = listScheduled(key);
|
|
451
471
|
return list.length ? { scheduled: list.map(({ id, text, at }) => ({ id, text, at })) } : {};
|
|
@@ -498,6 +518,8 @@ class Hub extends EventEmitter<{ event: [EventEnvelope] }> {
|
|
|
498
518
|
// 记忆在拉起会话这一刻读一次:会话里 agent 自己记下的,上下文里本来就有;
|
|
499
519
|
// 用户在设置里改了,memoryChanged() 会让空闲的会话下次说话时带着新的重新拉起
|
|
500
520
|
const memory = sessionMemory(conv);
|
|
521
|
+
// 定时任务工具:和记忆一样只给有主人的对话(飞书群聊里谁都能让机器人建任务,算不清是谁的)
|
|
522
|
+
const taskOwner = memoryOwnerOf(conv);
|
|
501
523
|
const mode = conv.mode ?? "work";
|
|
502
524
|
// 续着的会话上次被告知的模式和现在不一样(新会话没有历史,算原装的工作模式)
|
|
503
525
|
const announceMode = (resumeId ? (binding.announcedMode ?? "work") : "work") !== mode;
|
|
@@ -520,6 +542,7 @@ class Hub extends EventEmitter<{ event: [EventEnvelope] }> {
|
|
|
520
542
|
...(conv.mode === "chat" ? { mode: "chat" as const } : {}),
|
|
521
543
|
...(memory ? { memory } : {}),
|
|
522
544
|
...(announceMode ? { announceMode: true } : {}),
|
|
545
|
+
...(taskOwner ? { tasks: { userId: taskOwner, convKey: key } } : {}),
|
|
523
546
|
},
|
|
524
547
|
{
|
|
525
548
|
onTurnStart: () => {
|
|
@@ -536,13 +559,18 @@ class Hub extends EventEmitter<{ event: [EventEnvelope] }> {
|
|
|
536
559
|
if (result.costUsd !== undefined) parts.push(`约 $${result.costUsd.toFixed(4)}`);
|
|
537
560
|
if (result.tokens !== undefined) parts.push(`${result.tokens} tokens`);
|
|
538
561
|
const footer = parts.join(" ");
|
|
562
|
+
const tag = this.takeTurnTag(key);
|
|
563
|
+
const silent = !!tag?.smart && isSilentReply(result.text);
|
|
539
564
|
this.emitEvent(key, {
|
|
540
565
|
type: "result",
|
|
541
566
|
text: result.text,
|
|
542
567
|
footer,
|
|
543
568
|
at: Date.now(),
|
|
544
569
|
...(result.anchor ? { anchor: result.anchor } : {}),
|
|
570
|
+
...(tag ? { task: { id: tag.taskId, title: tag.title } } : {}),
|
|
571
|
+
...(silent ? { silent: true as const } : {}),
|
|
545
572
|
});
|
|
573
|
+
if (tag) this.emit("task", { ...tag, key, status: silent ? "silent" : "notified", text: result.text });
|
|
546
574
|
const conv = getConversation(key);
|
|
547
575
|
recordFooter(holder.session?.sessionId ?? (conv ? agentBinding(conv, agent).sessionId : undefined), result.text, footer);
|
|
548
576
|
this.setInflight(key, (this.inflight.get(key) ?? 1) - 1);
|
|
@@ -560,7 +588,12 @@ class Hub extends EventEmitter<{ event: [EventEnvelope] }> {
|
|
|
560
588
|
console.error(`[${agent}] ${key} 出错:`, err instanceof Error ? err.message : err);
|
|
561
589
|
// 这一轮已经结束了,挂着的审批没人会再等它
|
|
562
590
|
interactions.cancelAll(key, "本轮出错结束");
|
|
563
|
-
|
|
591
|
+
const stopped = Date.now() - (this.stoppedAt.get(key) ?? 0) < 15_000;
|
|
592
|
+
const tag = this.takeTurnTag(key);
|
|
593
|
+
if (tag) {
|
|
594
|
+
this.emit("task", { ...tag, key, status: stopped ? "interrupted" : "error", text: stopped ? "被停止了" : diagnosis.chatMessage });
|
|
595
|
+
}
|
|
596
|
+
if (stopped) {
|
|
564
597
|
// 用户自己点的停止:中断导致的「没能正常收尾」不是故障
|
|
565
598
|
this.emitEvent(key, { type: "notice", text: "已中断。" });
|
|
566
599
|
} else {
|
|
@@ -584,6 +617,11 @@ class Hub extends EventEmitter<{ event: [EventEnvelope] }> {
|
|
|
584
617
|
},
|
|
585
618
|
onDropped: (n) => {
|
|
586
619
|
if (!alive() || n <= 0) return;
|
|
620
|
+
// 丢的是排在最后、还没开始的那 n 条(Codex 先报正在跑的那轮出错、再报丢弃,那时队头已经取走了)
|
|
621
|
+
const tags = this.turnTags.get(key) ?? [];
|
|
622
|
+
for (const tag of tags.splice(Math.max(0, tags.length - n), n)) {
|
|
623
|
+
if (tag) this.emit("task", { ...tag, key, status: "interrupted", text: "排队时被停止了" });
|
|
624
|
+
}
|
|
587
625
|
this.setInflight(key, (this.inflight.get(key) ?? n) - n);
|
|
588
626
|
},
|
|
589
627
|
onSessionId: (sessionId) => {
|
|
@@ -656,6 +694,8 @@ class Hub extends EventEmitter<{ event: [EventEnvelope] }> {
|
|
|
656
694
|
const existing = this.live.get(key);
|
|
657
695
|
if (existing) this.rememberRuntime(key, existing);
|
|
658
696
|
this.live.delete(key);
|
|
697
|
+
for (const tag of this.turnTags.get(key) ?? []) if (tag) this.emit("task", { ...tag, key, status: "interrupted", text: note });
|
|
698
|
+
this.turnTags.delete(key);
|
|
659
699
|
this.setInflight(key, 0);
|
|
660
700
|
if (!existing) return;
|
|
661
701
|
// dispose 卡住(CLI 不响应)不能把这条会话的操作队列永远堵死
|
|
@@ -747,9 +787,10 @@ class Hub extends EventEmitter<{ event: [EventEnvelope] }> {
|
|
|
747
787
|
* gen:这条消息是在哪个停止代次下收到的。渠道自己还要先做点异步的事(飞书下载附件)
|
|
748
788
|
* 才调到这里时必须传:下载期间点了停止,这条消息就不该再发出去。
|
|
749
789
|
*/
|
|
750
|
-
send(key: string, input: UserInput, origin: Origin, gen?: InputGen): void {
|
|
790
|
+
send(key: string, input: UserInput, origin: Origin, gen?: InputGen, opts: { task?: TaskTurn } = {}): void {
|
|
751
791
|
const conv = getConversation(key);
|
|
752
792
|
if (!conv) throw new Error(`会话不存在:${key}`);
|
|
793
|
+
const task = opts.task;
|
|
753
794
|
// 用户自定义命令(设置 → 命令):展开成预设的提示词再发,界面上和 agent 收到的都是展开后的内容
|
|
754
795
|
const custom = (config().commands as Record<string, CommandPrefs | undefined>)[conv.agent]?.custom;
|
|
755
796
|
const expanded = expandCustomCommand(input.text, custom);
|
|
@@ -772,10 +813,14 @@ class Hub extends EventEmitter<{ event: [EventEnvelope] }> {
|
|
|
772
813
|
return false;
|
|
773
814
|
};
|
|
774
815
|
// 最先检查:停止之前收到的旧消息,不能去回答停止之后新冒出来的提问
|
|
775
|
-
if (cancelled())
|
|
816
|
+
if (cancelled()) {
|
|
817
|
+
if (task) this.emit("task", { ...task, key, status: "interrupted", text: "没有发出(停止了或者会话被切换了)" });
|
|
818
|
+
return;
|
|
819
|
+
}
|
|
776
820
|
const shown = {
|
|
777
821
|
type: "user" as const,
|
|
778
|
-
|
|
822
|
+
// 附给 agent 的背景(定时任务的说明)不显示
|
|
823
|
+
text: stripContext(input.text),
|
|
779
824
|
attachments: input.attachments.map((a) => `${KIND_LABEL[a.kind]} ${a.name}`),
|
|
780
825
|
files: input.attachments.map(attachmentInfo),
|
|
781
826
|
origin,
|
|
@@ -787,7 +832,8 @@ class Hub extends EventEmitter<{ event: [EventEnvelope] }> {
|
|
|
787
832
|
* 没有切换生效过;expectId 给了的话,还必须正是收到时挂着的那一个(不能去回答后来才冒出的新提问)。
|
|
788
833
|
*/
|
|
789
834
|
const tryAnswer = (expectId?: string): boolean => {
|
|
790
|
-
|
|
835
|
+
// 定时任务的提示词不是给挂着的提问的回答
|
|
836
|
+
if (task || input.attachments.length > 0) return false;
|
|
791
837
|
const asking = interactions.findQuestion(key);
|
|
792
838
|
if (!asking || asking.owner === undefined || asking.owner !== this.liveSessionId(key)) return false;
|
|
793
839
|
if (expectId !== undefined && asking.id !== expectId) return false;
|
|
@@ -807,28 +853,49 @@ class Hub extends EventEmitter<{ event: [EventEnvelope] }> {
|
|
|
807
853
|
// 闲置过期要按这次说话之前的活跃时间判断,所以先记下、后 touch
|
|
808
854
|
const lastActive = conv.activeAt;
|
|
809
855
|
touchConversation(key);
|
|
810
|
-
if (!conv.title && conv.channel === "web" &&
|
|
856
|
+
if (!conv.title && conv.channel === "web" && shown.text.trim()) {
|
|
811
857
|
// 先用首条消息占位,第一轮结束后换成 agent 自己生成的短标题(syncTitle)
|
|
812
|
-
updateConversation(key, { title:
|
|
858
|
+
updateConversation(key, { title: shown.text.trim().replace(/\s+/g, " ").slice(0, 40), titleAuto: true });
|
|
813
859
|
this.emitEvent(key, { type: "meta" });
|
|
814
860
|
}
|
|
815
861
|
this.emitEvent(key, shown);
|
|
862
|
+
const gone = (text: string) => {
|
|
863
|
+
if (task) this.emit("task", { ...task, key, status: "interrupted", text });
|
|
864
|
+
};
|
|
816
865
|
void this.serial(key, async () => {
|
|
817
|
-
if (cancelled()) return;
|
|
866
|
+
if (cancelled()) return gone("没有发出(停止了或者会话被切换了)");
|
|
818
867
|
if (questionAtEntry && tryAnswer(questionAtEntry)) return;
|
|
819
|
-
// 收到之后发生过显式切换(恢复 / 分叉 /
|
|
868
|
+
// 收到之后发生过显式切换(恢复 / 分叉 / 新会话…):用户已经明确指定了会话,不再按旧的闲置时间作废它。
|
|
869
|
+
// 定时任务也不按闲置过期:每天跑一次的任务要接着上次的上下文
|
|
820
870
|
const ttl = config().sessionTtlMs;
|
|
821
871
|
const switchedAfterEntry = (this.lastSwitch.get(key)?.at ?? 0) >= g.at;
|
|
822
|
-
const expired = !switchedAfterEntry && ttl > 0 && Date.now() - lastActive > ttl;
|
|
872
|
+
const expired = !task && !switchedAfterEntry && ttl > 0 && Date.now() - lastActive > ttl;
|
|
823
873
|
if (expired) {
|
|
824
874
|
await this.expireNow(key);
|
|
825
875
|
// 清理要等旧会话 dispose,期间可能又点了停止
|
|
826
876
|
if (cancelled()) return;
|
|
827
877
|
}
|
|
828
|
-
this.sendNow(key, input);
|
|
878
|
+
this.sendNow(key, input, task ?? null);
|
|
829
879
|
});
|
|
830
880
|
}
|
|
831
881
|
|
|
882
|
+
/** 刚点过停止(被打断的那一轮还可能晚到一个出错,见 onError 里的 stopped) */
|
|
883
|
+
recentlyStopped(key: string): boolean {
|
|
884
|
+
return Date.now() - (this.stoppedAt.get(key) ?? 0) < 15_000;
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
/** 这条会话里正在跑的那一轮是不是定时任务发起的 */
|
|
888
|
+
currentTaskTurn(key: string): TaskTurn | undefined {
|
|
889
|
+
return this.turnTags.get(key)?.[0] ?? undefined;
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
private takeTurnTag(key: string): TaskTurn | null {
|
|
893
|
+
const tags = this.turnTags.get(key);
|
|
894
|
+
const tag = tags?.shift() ?? null;
|
|
895
|
+
if (tags && tags.length === 0) this.turnTags.delete(key);
|
|
896
|
+
return tag;
|
|
897
|
+
}
|
|
898
|
+
|
|
832
899
|
/** 闲置太久:不再续用上次的会话(活着的也丢掉),开新的 */
|
|
833
900
|
private async expireNow(key: string): Promise<void> {
|
|
834
901
|
const conv = getConversation(key);
|
|
@@ -842,20 +909,29 @@ class Hub extends EventEmitter<{ event: [EventEnvelope] }> {
|
|
|
842
909
|
this.emitEvent(key, { type: "meta" });
|
|
843
910
|
}
|
|
844
911
|
|
|
845
|
-
private sendNow(key: string, input: UserInput): void {
|
|
912
|
+
private sendNow(key: string, input: UserInput, tag: TaskTurn | null): void {
|
|
913
|
+
const fail = (err: unknown) => {
|
|
914
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
915
|
+
this.emitEvent(key, { type: "error", message });
|
|
916
|
+
if (tag) this.emit("task", { ...tag, key, status: "error", text: message });
|
|
917
|
+
};
|
|
846
918
|
let session: AgentSession;
|
|
847
919
|
try {
|
|
848
920
|
session = this.session(key);
|
|
849
921
|
} catch (err) {
|
|
850
|
-
|
|
922
|
+
fail(err);
|
|
851
923
|
return;
|
|
852
924
|
}
|
|
853
925
|
this.setInflight(key, (this.inflight.get(key) ?? 0) + 1);
|
|
926
|
+
const tags = this.turnTags.get(key) ?? [];
|
|
927
|
+
tags.push(tag);
|
|
928
|
+
this.turnTags.set(key, tags);
|
|
854
929
|
try {
|
|
855
930
|
session.send(input);
|
|
856
931
|
} catch (err) {
|
|
932
|
+
tags.pop();
|
|
857
933
|
this.setInflight(key, (this.inflight.get(key) ?? 1) - 1);
|
|
858
|
-
|
|
934
|
+
fail(err);
|
|
859
935
|
}
|
|
860
936
|
}
|
|
861
937
|
|
|
@@ -1125,6 +1201,9 @@ class Hub extends EventEmitter<{ event: [EventEnvelope] }> {
|
|
|
1125
1201
|
this.stoppedAt.set(key, Date.now());
|
|
1126
1202
|
interactions.cancelAll(key, "本轮已中断");
|
|
1127
1203
|
await this.live.get(key)?.session.interrupt();
|
|
1204
|
+
// 和 inflight 一起清零:被打断的那一轮不一定还会报结果,留着的话下一轮的结果会被当成定时任务的
|
|
1205
|
+
for (const tag of this.turnTags.get(key) ?? []) if (tag) this.emit("task", { ...tag, key, status: "interrupted", text: "被停止了" });
|
|
1206
|
+
this.turnTags.delete(key);
|
|
1128
1207
|
this.setInflight(key, 0);
|
|
1129
1208
|
return "已请求中断。";
|
|
1130
1209
|
}
|
package/src/core/memory-mcp.ts
CHANGED
|
@@ -9,7 +9,7 @@ export const MEMORY_MCP_NAME = "agent_loft_memory";
|
|
|
9
9
|
const serverPath = fileURLToPath(new URL("../mcp/memory-server.ts", import.meta.url));
|
|
10
10
|
|
|
11
11
|
/** tsx 的 ESM loader:源码是 TypeScript,子进程和主进程一样靠它直接跑 */
|
|
12
|
-
const tsxLoader = (() => {
|
|
12
|
+
export const tsxLoader = (() => {
|
|
13
13
|
const require = createRequire(import.meta.url);
|
|
14
14
|
// tsx/esm 的导出只有 import 条件,require.resolve 解不到;从包目录拼出来
|
|
15
15
|
const pkg = require.resolve("tsx/package.json");
|
package/src/core/personal.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { deleteMemory, memoryPrompt, readMemory } from "./memory.js";
|
|
3
3
|
import { purgeHistory } from "./chat-history.js";
|
|
4
4
|
import { unlinkUser } from "./feishu-links.js";
|
|
5
|
+
import { deleteUserTasks } from "./tasks.js";
|
|
5
6
|
import type { Conversation } from "./store.js";
|
|
6
7
|
import { getUser, legacyOwnerId } from "./users.js";
|
|
7
8
|
|
|
@@ -36,9 +37,10 @@ export function sessionMemory(
|
|
|
36
37
|
};
|
|
37
38
|
}
|
|
38
39
|
|
|
39
|
-
/**
|
|
40
|
+
/** 删用户时一起删掉的个人数据:记忆、聊天记录索引、飞书绑定和定时任务 */
|
|
40
41
|
export function purgeUserData(userId: string): void {
|
|
41
42
|
deleteMemory(userId);
|
|
42
43
|
purgeHistory(userId);
|
|
43
44
|
unlinkUser(userId);
|
|
45
|
+
deleteUserTasks(userId);
|
|
44
46
|
}
|
package/src/core/store.ts
CHANGED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// 定时任务每次运行时发给 agent 的话:用户写的提示词 + 一段 agent-loft 附上的说明(用户看不到)。
|
|
2
|
+
//
|
|
3
|
+
// 「只在需要时提醒」靠一个暗号:agent 判断这次没什么要说的,就在回复第一行只写 SILENT_MARK,
|
|
4
|
+
// hub 看到它就把这一轮标成 silent,推送和飞书都不发。没写暗号一律当成要提醒 —— 宁可多提醒一次,
|
|
5
|
+
// 也不能漏掉。
|
|
6
|
+
import { wrapContext } from "./chat-mode.js";
|
|
7
|
+
import { formatDateTime, serverTimeZone } from "./task-schedule.js";
|
|
8
|
+
|
|
9
|
+
export const SILENT_MARK = "[[静默]]";
|
|
10
|
+
|
|
11
|
+
export function isSilentReply(text: string): boolean {
|
|
12
|
+
return text.trimStart().startsWith(SILENT_MARK);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** 去掉暗号,剩下的是 agent 顺手记的检查结果 */
|
|
16
|
+
export function stripSilentMark(text: string): string {
|
|
17
|
+
return isSilentReply(text) ? text.trimStart().slice(SILENT_MARK.length).trim() : text;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function taskMessage(
|
|
21
|
+
task: { title: string; prompt: string; notify: "smart" | "always" },
|
|
22
|
+
opts: { now?: number; manual?: boolean; version?: string } = {},
|
|
23
|
+
): string {
|
|
24
|
+
const now = opts.now ?? Date.now();
|
|
25
|
+
const lines = [
|
|
26
|
+
`这条消息是定时任务「${task.title}」${opts.manual ? "(用户刚才手动点了「立即运行」)" : "到点自动发来的"},现在是 ${formatDateTime(now)}(${serverTimeZone()})。${opts.manual ? "" : "用户此刻多半不在屏幕前,不要向他提问、等他回答。"}`,
|
|
27
|
+
...(opts.version ? [`agent-loft 服务端当前版本:${opts.version}。`] : []),
|
|
28
|
+
"按上面的要求做;这条对话里以前运行的结果可以拿来对比(比如「和上次比有没有变化」)。",
|
|
29
|
+
];
|
|
30
|
+
if (task.notify === "smart") {
|
|
31
|
+
lines.push(
|
|
32
|
+
"",
|
|
33
|
+
`用户希望只在有需要时才被提醒:如果按上面的要求,这次没有需要告诉他的事(比如没有更新、没有变化、条件没满足),回复的第一行只写 ${SILENT_MARK},后面用一两句话记下这次检查的结果(下次可以对比),他就不会收到通知。`,
|
|
34
|
+
`有需要告诉他的事时正常回复,开头直接说结论,不要写 ${SILENT_MARK}。`,
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
return `${task.prompt}\n\n${wrapContext(lines)}`;
|
|
38
|
+
}
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
// 定时任务的调度:每 30 秒看一眼哪些到点了,核对主人和对话,把提示词送进对话;一轮结束后记下结果。
|
|
2
|
+
//
|
|
3
|
+
// 到点时的核对(任何一条不满足都不跑):
|
|
4
|
+
// - 主人还在、没被停用;
|
|
5
|
+
// - 回到的对话还在、没被删,而且还是他的(飞书私聊换绑了账号之类会让对话换主人)—— 不满足就暂停任务;
|
|
6
|
+
// - 对话没在忙、也没挂着授权 / 提问:忙的话等它(最多 WAIT_MAX_MS),不能插进别人正在进行的那一轮,
|
|
7
|
+
// 更不能被当成对挂着的提问的回答。
|
|
8
|
+
import crypto from "node:crypto";
|
|
9
|
+
import { config, resolveWorkspacePath } from "./config.js";
|
|
10
|
+
import { hub, type TaskTurnEnd } from "./hub.js";
|
|
11
|
+
import { interactions } from "./interactions.js";
|
|
12
|
+
import { memoryOwnerOf } from "./personal.js";
|
|
13
|
+
import { getConversation, updateAgentBinding, updateConversation, type Conversation } from "./store.js";
|
|
14
|
+
import { formatDateTime } from "./task-schedule.js";
|
|
15
|
+
import { stripSilentMark, taskMessage } from "./task-prompt.js";
|
|
16
|
+
import { advance, allTasks, finishRun, pauseTask, persistTask, pushRun, runId, type Task, type TaskRun } from "./tasks.js";
|
|
17
|
+
import { RUNNING_VERSION } from "./upgrade.js";
|
|
18
|
+
import { getUser } from "./users.js";
|
|
19
|
+
|
|
20
|
+
const TICK_MS = 30_000;
|
|
21
|
+
/** 服务停着的时候错过的:错过不到这么久的,启动后补跑一次;更早的记一笔「错过了」 */
|
|
22
|
+
const CATCHUP_MS = 2 * 3600_000;
|
|
23
|
+
/** 到点时对话在忙:最多等这么久,还忙就跳过这一次 */
|
|
24
|
+
const WAIT_MAX_MS = 30 * 60_000;
|
|
25
|
+
/** 送出去之后对话已经不忙了、却一直没等到结果(被别的操作吞掉了):这么久之后按中断记 */
|
|
26
|
+
const LOST_MS = 2 * 60_000;
|
|
27
|
+
|
|
28
|
+
/** 到点了、在等对话空下来的 */
|
|
29
|
+
const waiting = new Map<string, { since: number }>();
|
|
30
|
+
/** 正在跑的:任务 id → 这次运行 */
|
|
31
|
+
const running = new Map<string, { runId: string; key: string; at: number }>();
|
|
32
|
+
let timer: NodeJS.Timeout | null = null;
|
|
33
|
+
|
|
34
|
+
/** 这条对话现在归谁(和记忆同一个规则:飞书群聊、没绑定的私聊没有主人) */
|
|
35
|
+
function ownerOf(conv: Conversation): string | undefined {
|
|
36
|
+
return memoryOwnerOf(conv);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function preview(text: string, max = 120): string {
|
|
40
|
+
const plain = text
|
|
41
|
+
.replace(/```[\s\S]*?```/g, " [代码] ")
|
|
42
|
+
.replace(/[`*_~#>|]/g, "")
|
|
43
|
+
.replace(/\s+/g, " ")
|
|
44
|
+
.trim();
|
|
45
|
+
return plain.length > max ? `${plain.slice(0, max - 1)}…` : plain;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
type Resolved = { key: string } | { pause: string } | { error: string };
|
|
49
|
+
|
|
50
|
+
/** 这次该发到哪条对话;专用对话还没有(或者被删了)就新建一条 */
|
|
51
|
+
function resolveConv(task: Task): Resolved {
|
|
52
|
+
if (task.target.kind === "conv") {
|
|
53
|
+
const conv = getConversation(task.target.key);
|
|
54
|
+
if (!conv || conv.hidden) return { pause: "回复的那条对话已经被删除,任务已暂停。可以改成「专用对话」后再启用。" };
|
|
55
|
+
if (ownerOf(conv) !== task.owner) return { pause: "回复的那条对话已经不属于你了,任务已暂停。" };
|
|
56
|
+
return { key: conv.key };
|
|
57
|
+
}
|
|
58
|
+
const existing = task.target.key ? getConversation(task.target.key) : undefined;
|
|
59
|
+
if (existing && !existing.hidden && existing.taskId === task.id && ownerOf(existing) === task.owner) return { key: existing.key };
|
|
60
|
+
|
|
61
|
+
const enabled = hub.enabledAgents().map((d) => d.id);
|
|
62
|
+
if (!enabled.includes(task.agent)) return { error: `${hub.label(task.agent)} 没有启用` };
|
|
63
|
+
let cwd: string | null = null;
|
|
64
|
+
if (task.cwd) {
|
|
65
|
+
cwd = resolveWorkspacePath(task.cwd);
|
|
66
|
+
if (!cwd) return { error: `工作目录 ${task.cwd} 不存在了(或者不在工作区里),改一下任务的目录` };
|
|
67
|
+
}
|
|
68
|
+
const key = `web:${crypto.randomBytes(6).toString("hex")}`;
|
|
69
|
+
// 侧栏里专用对话前面有时钟图标(taskId),标题不用再加
|
|
70
|
+
hub.ensure({ key, channel: "web", channelId: "web", defaultAgent: task.agent, owner: task.owner, title: task.title.slice(0, 80) });
|
|
71
|
+
updateConversation(key, {
|
|
72
|
+
titleAuto: false,
|
|
73
|
+
taskId: task.id,
|
|
74
|
+
...(task.mode === "chat" ? { mode: "chat" as const } : {}),
|
|
75
|
+
...(cwd && cwd !== config().workspaceRoot ? { cwd, cwdExplicit: true } : {}),
|
|
76
|
+
});
|
|
77
|
+
if (task.model || task.effort) {
|
|
78
|
+
updateAgentBinding(key, task.agent, { ...(task.model ? { model: task.model } : {}), ...(task.effort ? { effort: task.effort } : {}) });
|
|
79
|
+
}
|
|
80
|
+
task.target = { kind: "dedicated", key };
|
|
81
|
+
persistTask(task);
|
|
82
|
+
hub.emitEvent(key, { type: "meta" });
|
|
83
|
+
return { key };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function record(task: Task, status: TaskRun["status"], note: string, now = Date.now()): void {
|
|
87
|
+
pushRun(task, { id: runId(), at: now, endedAt: now, status, note });
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* 试着跑一次。manual:用户点了「立即运行」—— 对话忙就直接报错,不排队。
|
|
92
|
+
* 返回给人看的错误;undefined 表示已经送出去了(或者在排队等)。
|
|
93
|
+
*/
|
|
94
|
+
function fire(task: Task, manual: boolean, now = Date.now()): string | undefined {
|
|
95
|
+
const user = getUser(task.owner);
|
|
96
|
+
if (!user || user.disabled) {
|
|
97
|
+
waiting.delete(task.id);
|
|
98
|
+
record(task, "skipped", "账号已停用", now);
|
|
99
|
+
return "账号已停用";
|
|
100
|
+
}
|
|
101
|
+
const where = resolveConv(task);
|
|
102
|
+
if ("pause" in where) {
|
|
103
|
+
waiting.delete(task.id);
|
|
104
|
+
pauseTask(task, where.pause, now);
|
|
105
|
+
return where.pause;
|
|
106
|
+
}
|
|
107
|
+
if ("error" in where) {
|
|
108
|
+
waiting.delete(task.id);
|
|
109
|
+
record(task, "error", where.error, now);
|
|
110
|
+
return where.error;
|
|
111
|
+
}
|
|
112
|
+
const key = where.key;
|
|
113
|
+
const conv = getConversation(key)!;
|
|
114
|
+
if (!hub.enabledAgents().some((d) => d.id === conv.agent)) {
|
|
115
|
+
waiting.delete(task.id);
|
|
116
|
+
record(task, "error", `${hub.label(conv.agent)} 没有启用`, now);
|
|
117
|
+
return `${hub.label(conv.agent)} 没有启用`;
|
|
118
|
+
}
|
|
119
|
+
// 刚点过停止的也当忙:被打断的那一轮可能还会晚到一个出错,别让它算到这次运行头上
|
|
120
|
+
if (hub.isBusy(key) || hub.recentlyStopped(key) || interactions.listPending(key).length > 0) {
|
|
121
|
+
if (manual) return "这条对话正在回复或者在等你处理授权,等它结束再试";
|
|
122
|
+
const since = waiting.get(task.id)?.since ?? now;
|
|
123
|
+
if (now - since > WAIT_MAX_MS) {
|
|
124
|
+
waiting.delete(task.id);
|
|
125
|
+
record(task, "skipped", `对话一直在忙(等了 ${WAIT_MAX_MS / 60_000} 分钟),跳过了这一次`, now);
|
|
126
|
+
} else {
|
|
127
|
+
waiting.set(task.id, { since });
|
|
128
|
+
}
|
|
129
|
+
return undefined;
|
|
130
|
+
}
|
|
131
|
+
waiting.delete(task.id);
|
|
132
|
+
const run = pushRun(task, { id: runId(), at: now, status: "running", convKey: key, ...(manual ? { note: "手动运行" } : {}) });
|
|
133
|
+
running.set(task.id, { runId: run.id, key, at: now });
|
|
134
|
+
console.log(`[定时任务] ${task.id}「${task.title}」${manual ? "手动运行" : "到点运行"},发到 ${key}`);
|
|
135
|
+
hub.send(
|
|
136
|
+
key,
|
|
137
|
+
{ text: taskMessage(task, { now, manual, version: RUNNING_VERSION }), attachments: [] },
|
|
138
|
+
{ channel: "task", channelId: task.id, name: `定时任务「${task.title}」` },
|
|
139
|
+
undefined,
|
|
140
|
+
{ task: { taskId: task.id, runId: run.id, title: task.title, smart: task.notify === "smart" } },
|
|
141
|
+
);
|
|
142
|
+
return undefined;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function onTurnEnd(e: TaskTurnEnd): void {
|
|
146
|
+
const cur = running.get(e.taskId);
|
|
147
|
+
if (cur?.runId === e.runId) running.delete(e.taskId);
|
|
148
|
+
const text = e.status === "notified" || e.status === "silent" ? preview(stripSilentMark(e.text)) : "";
|
|
149
|
+
finishRun(e.taskId, e.runId, {
|
|
150
|
+
status: e.status,
|
|
151
|
+
endedAt: Date.now(),
|
|
152
|
+
...(text ? { preview: text } : {}),
|
|
153
|
+
...(e.status === "error" || e.status === "interrupted" ? { note: preview(e.text) } : {}),
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function tick(): void {
|
|
158
|
+
const now = Date.now();
|
|
159
|
+
for (const [taskId, cur] of running) {
|
|
160
|
+
// 送出去了,对话早就不忙了,却没等到结果:被吞掉了(比如期间切了会话),别让它永远「运行中」
|
|
161
|
+
if (now - cur.at > LOST_MS && !hub.isBusy(cur.key) && hub.currentTaskTurn(cur.key)?.runId !== cur.runId) {
|
|
162
|
+
running.delete(taskId);
|
|
163
|
+
finishRun(taskId, cur.runId, { status: "interrupted", endedAt: now, note: "没有拿到结果" });
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
for (const task of allTasks()) {
|
|
167
|
+
if (running.has(task.id)) continue;
|
|
168
|
+
if (waiting.has(task.id)) {
|
|
169
|
+
// 等着的时候被暂停了:这次就不跑了(「一次」的任务到点时已经自动停用,照样要跑完这一次)
|
|
170
|
+
if (!task.enabled && task.schedule.kind !== "once") waiting.delete(task.id);
|
|
171
|
+
else fire(task, false, now);
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
if (!task.enabled || task.nextRunAt === undefined || task.nextRunAt > now) continue;
|
|
175
|
+
// 先把下一次算好再跑:跑的过程中出了什么岔子,也不会在下一个 tick 又跑一遍
|
|
176
|
+
advance(task, now);
|
|
177
|
+
fire(task, false, now);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** 网页上点「立即运行」 */
|
|
182
|
+
export function runTaskNow(task: Task): string | undefined {
|
|
183
|
+
if (running.has(task.id)) return "这个任务正在运行";
|
|
184
|
+
return fire(task, true);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function isTaskRunning(id: string): boolean {
|
|
188
|
+
return running.has(id) || waiting.has(id);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** 任务删了:它的专用对话留着(里面有历史),只是不再是谁的专用对话 */
|
|
192
|
+
export function forgetTask(task: Task): void {
|
|
193
|
+
waiting.delete(task.id);
|
|
194
|
+
running.delete(task.id);
|
|
195
|
+
if (task.target.kind === "dedicated" && task.target.key) {
|
|
196
|
+
const conv = getConversation(task.target.key);
|
|
197
|
+
if (conv?.taskId === task.id) {
|
|
198
|
+
updateConversation(conv.key, { taskId: undefined });
|
|
199
|
+
hub.emitEvent(conv.key, { type: "meta" });
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export function startTaskRunner(): void {
|
|
205
|
+
if (timer) return;
|
|
206
|
+
const now = Date.now();
|
|
207
|
+
for (const task of allTasks()) {
|
|
208
|
+
// 上次进程退出时还在跑的:结果已经拿不到了
|
|
209
|
+
let dirty = false;
|
|
210
|
+
for (const run of task.runs) {
|
|
211
|
+
if (run.status !== "running") continue;
|
|
212
|
+
Object.assign(run, { status: "interrupted", endedAt: now, note: "服务重启,这次运行被打断了" });
|
|
213
|
+
dirty = true;
|
|
214
|
+
}
|
|
215
|
+
if (dirty) persistTask(task);
|
|
216
|
+
if (task.enabled && task.nextRunAt !== undefined && task.nextRunAt < now - CATCHUP_MS) {
|
|
217
|
+
record(task, "skipped", `服务没在运行,错过了 ${formatDateTime(task.nextRunAt)} 这一次`, now);
|
|
218
|
+
advance(task, now);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
hub.on("task", onTurnEnd);
|
|
222
|
+
timer = setInterval(tick, TICK_MS);
|
|
223
|
+
timer.unref();
|
|
224
|
+
// 等 agent 起来了再补跑错过的
|
|
225
|
+
setTimeout(tick, 5_000).unref();
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export function stopTaskRunner(): void {
|
|
229
|
+
if (timer) clearInterval(timer);
|
|
230
|
+
timer = null;
|
|
231
|
+
hub.off("task", onTurnEnd);
|
|
232
|
+
}
|