agent-loft 0.2.30 → 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 CHANGED
@@ -58,6 +58,7 @@ Logo 就是这个意思:一大两小几颗星(多个 agent)站在同一张
58
58
  - **多用户**(信任型):管理员在 设置 → 用户 里给别人建账号。每个人只看得到自己的对话、授权和通知,设置只有管理员能改,见下面「安全」。
59
59
  - **聊天模式 / 工作模式**:右上角的「💬 聊天 / 🛠 工作」,或者发 `/chat`、`/work`,同一条对话随时切换、上下文接着。聊天模式换成精简的系统提示,只留联网搜索,不加载技能、MCP、插件,不执行命令、不读写本机文件(文本附件的内容直接放进消息),每轮的固定开销小得多:实测一句话 Claude 约 1.6 万 → 2 千 token,Codex 约 2 万 → 1.2 万(Codex 的基础指令改不了)。适合日常聊天、查资料;要动手干活时切回工作模式。切换后第一轮要重建提示缓存,会贵一些;在工作模式里读过的大段内容还留在历史里。
60
60
  - **记忆**:像 ChatGPT 的记忆,设置 → 记忆。「关于我」加上一条条记住的事,每次对话都带上;agent 在聊天里遇到长期有用的信息会自己用 `remember` 记一条(回复里会说),记错了用 `forget` 删。**自动整理**:把上次整理之后新说的话交给模型(不留会话记录的一次性调用),补充、更新或删掉过时的记忆,用户自己加的条目它不动。什么时候整理每个人自己定:每天一次(默认凌晨 4 点,把当天的对话合起来一次整理,最省)/每段对话聊完后(停多久可选,改了什么会在对话里说一声)/只在点「现在整理」时/不整理;整理哪些对话也能选:全部/只整理聊天模式的/只整理工作模式的;用哪个 agent 的哪个模型也能选,默认 Claude Haiku,没有 Claude 就用 Codex 的默认模型、思考程度 low。**参考以前的聊天**:agent 遇到「上次说的那个……」会用 `search_past_chats` 搜你以前的对话(关键词搜索,只有你自己的、没被删掉的对话;第一次用时会把已有对话收录进来)。两项都能在设置里单独关。每个用户一份,Claude 和 Codex、Web 和飞书共用。只用在有主人的对话里:你的 Web 对话,以及绑定了飞书账号后和机器人的私聊;飞书群聊不用。别人(包括管理员)看不到你的记忆,管理员只能清空它;删用户时记忆一起删,不转给管理员。
61
+ - **定时任务**:像 ChatGPT 的 Tasks,侧栏「最近」上面的「定时任务」。到点把一段提示词交给 agent,结果发回对话:一次 / 每天 / 工作日 / 每周 / 每月 / 每隔几小时 / 自定义 cron,按服务端时区,两次至少隔 15 分钟。**有需要才提醒**(默认):agent 自己判断这次有没有要告诉你的事(比如「有新版本才告诉我」),没有就在回复第一行写 `[[静默]]`,推送、飞书都不发,对话里折叠成一行;没写暗号一律照常提醒。结果发到**专用对话**(任务自己的一条对话,第一次运行时新建、之后续用,可选聊天 / 工作模式和目录),或者**原来的对话**。也能直接在对话里说「每天早上 9 点帮我……」:agent 用 `create_task` / `update_task` / `delete_task` / `list_tasks` 工具管理,新建、修改、删除都会先弹授权卡让你确认(只有暂停不用;没有「总是允许」),任务自己运行的那一轮里不能再建、改、删任务。按人隔离:每人只看得到、改得了自己的任务(最多 30 个),删用户时一起删;到点再核对一次对话还是不是他的,对话被删或换了主人就暂停任务。到点对话在忙(正在回复或挂着授权)就等它,最多 30 分钟;服务停着时错过 2 小时以内的启动后补跑一次。工具和记忆一样只给有主人的对话,飞书群聊里没有。
61
62
  - **飞书账号绑定**:设置 → 账号 → 飞书账号 →「生成绑定码」,再在飞书私聊机器人发 `/bind 绑定码`(10 分钟内有效,群里发不算)。绑定后这个私聊就是你的个人会话:用你的记忆,`/sessions` 只列你的会话,`/web` 只能接上你自己的 Web 会话,管理员在 Web 上也看不到它。必须本人在两边各操作一次,管理员不能替人绑定(只能解除),否则就能借飞书读到别人的记忆。
62
63
 
63
64
  ## 安装
@@ -160,9 +161,13 @@ src/
160
161
  memory-curator.ts 录入聊天、回填已有对话、聊完后自动整理记忆
161
162
  feishu-links.ts 飞书账号 ↔ 用户的绑定和绑定码
162
163
  chat-mode.ts 聊天模式告诉 agent 的话(Claude 换系统提示,Codex 附在消息后面)
164
+ tasks.ts 定时任务的存储(按人隔离);task-schedule.ts 频率和 cron;task-prompt.ts 每次运行附给 agent 的说明和静默暗号
165
+ task-runner.ts 定时任务的调度:到点核对主人和对话、送进对话、记下结果
166
+ tasks-bridge.ts 定时任务工具在主进程这头:核对签名、弹授权卡、增删改(tasks-mcp.ts 定怎么拉起)
163
167
  pairing.ts uploads.ts commands.ts …
164
168
  mcp/
165
169
  memory-server.ts 记忆工具(remember / forget / search_past_chats)的 stdio MCP 服务,两个 agent 共用
170
+ tasks-server.ts 定时任务工具的 stdio MCP 服务:只传话,经 unix socket 转给主进程
166
171
  agents/
167
172
  claude/ Claude Agent SDK 驱动
168
173
  codex/ codex app-server 驱动
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-loft",
3
- "version": "0.2.30",
3
+ "version": "0.2.31",
4
4
  "type": "module",
5
5
  "description": "多个 agent 的工作台:把本机 Claude Code / Codex 放到一处统一管理,用飞书和 Web 页面随时驱动——会话、审批、模型切换都共用",
6
6
  "keywords": [
@@ -13,6 +13,7 @@ import { agentEnvironment } from "../../core/agent-env.js";
13
13
  import os from "node:os";
14
14
  import type { AgentCommand } from "../../core/command-catalog.js";
15
15
  import { splitAttachmentNote } from "../../core/uploads.js";
16
+ import { stripContext } from "../../core/chat-mode.js";
16
17
  import { config } from "../../core/config.js";
17
18
  import type {
18
19
  AgentDriver,
@@ -69,7 +70,7 @@ function readCommands(cwd: string): Promise<AgentCommand[]> {
69
70
  const SESSION_ID = /^[A-Za-z0-9_-]{8,64}$/;
70
71
 
71
72
  function titleOf(info: { customTitle?: string; summary?: string; firstPrompt?: string }): string {
72
- const raw = (info.customTitle || info.summary || info.firstPrompt || "").replace(/\s+/g, " ").trim();
73
+ const raw = stripContext(info.customTitle || info.summary || info.firstPrompt || "").replace(/\s+/g, " ").trim();
73
74
  if (!raw) return "(无标题)";
74
75
  return raw.length > 60 ? `${raw.slice(0, 60)}…` : raw;
75
76
  }
@@ -157,7 +158,7 @@ export class ClaudeDriver implements AgentDriver {
157
158
  const texts = blocks.filter((b) => b.type === "text" && b.text).map((b) => b.text!);
158
159
  if (texts.length === 0) continue; // 纯 tool_result
159
160
  // 我们自己拼进去的附件清单:从正文里拆出来,单独作为附件给前端展示
160
- const { text, attachments } = splitAttachmentNote(texts.join("\n"));
161
+ const { text, attachments } = splitAttachmentNote(stripContext(texts.join("\n")));
161
162
  if (!text && attachments.length === 0) continue;
162
163
  if (text.startsWith("<command-") || text.startsWith("<local-command")) continue;
163
164
  if (text.startsWith("<task-notification>")) {
@@ -16,6 +16,7 @@ import {
16
16
  import { agentEnvironment } from "../../core/agent-env.js";
17
17
  import { chatSystemPrompt } from "../../core/chat-mode.js";
18
18
  import { MEMORY_MCP_NAME, memoryMcpSpawn } from "../../core/memory-mcp.js";
19
+ import { TASKS_MCP_NAME, tasksMcpSpawn } from "../../core/tasks-mcp.js";
19
20
  import { describeAttachments, inlineTextBlocks, readTextAttachments } from "../../core/uploads.js";
20
21
  import type {
21
22
  AgentEvents,
@@ -127,6 +128,8 @@ function buildContent(input: UserInput, chat: boolean): string | ContentBlock[]
127
128
 
128
129
  /** 记忆工具在 Claude 里的全名 */
129
130
  const MEMORY_TOOLS = new Set(["remember", "forget", "search_past_chats"].map((t) => `mcp__${MEMORY_MCP_NAME}__${t}`));
131
+ /** 定时任务工具:要用户确认的那几个由 agent-loft 自己弹卡(tasks-bridge.ts),这里不再问一遍 */
132
+ const TASK_TOOLS = new Set(["list_tasks", "create_task", "update_task", "delete_task"].map((t) => `mcp__${TASKS_MCP_NAME}__${t}`));
130
133
  /** 聊天模式的全部工具:只能联网,碰不到本机 */
131
134
  const CHAT_TOOLS = ["WebSearch", "WebFetch"];
132
135
 
@@ -145,9 +148,12 @@ const CHAT_TOOLS = ["WebSearch", "WebFetch"];
145
148
  */
146
149
  export function modeOptions(opts: SessionOptions): Partial<Options> {
147
150
  const memory = opts.memory;
148
- const mcpServers: Record<string, McpServerConfig> = memory
149
- ? { [MEMORY_MCP_NAME]: { type: "stdio", ...memoryMcpSpawn(memory.userId, "claude", { search: !!memory.search, ...(memory.convKey ? { convKey: memory.convKey } : {}) }) } }
150
- : {};
151
+ const mcpServers: Record<string, McpServerConfig> = {
152
+ ...(memory
153
+ ? { [MEMORY_MCP_NAME]: { type: "stdio", ...memoryMcpSpawn(memory.userId, "claude", { search: !!memory.search, ...(memory.convKey ? { convKey: memory.convKey } : {}) }) } }
154
+ : {}),
155
+ ...(opts.tasks ? { [TASKS_MCP_NAME]: { type: "stdio", ...tasksMcpSpawn(opts.tasks.userId, opts.tasks.convKey, "claude") } } : {}),
156
+ };
151
157
  if (opts.mode === "chat") {
152
158
  return {
153
159
  systemPrompt: { type: "custom", prompt: chatSystemPrompt("WebSearch(搜索)和 WebFetch(读网页)", memory?.prompt), snapshot: false },
@@ -165,7 +171,7 @@ export function modeOptions(opts: SessionOptions): Partial<Options> {
165
171
  const append = [memory?.prompt, switched].filter(Boolean).join("\n\n");
166
172
  return {
167
173
  systemPrompt: { type: "preset", preset: "claude_code", ...(append ? { append } : {}), snapshot: false },
168
- ...(memory ? { mcpServers } : {}),
174
+ ...(Object.keys(mcpServers).length ? { mcpServers } : {}),
169
175
  };
170
176
  }
171
177
 
@@ -351,6 +357,7 @@ export class ClaudeSession implements AgentSession {
351
357
  // 自家的记忆工具,以及聊天模式下的联网工具:不问。
352
358
  // 不用 allowedTools 放行 —— SDK 会对每个会话打一条「canUseTool 被绕过」的警告
353
359
  if (MEMORY_TOOLS.has(toolName) && this.opts.memory) return { behavior: "allow", updatedInput: input };
360
+ if (TASK_TOOLS.has(toolName) && this.opts.tasks) return { behavior: "allow", updatedInput: input };
354
361
  if (this.opts.mode === "chat" && CHAT_TOOLS.includes(toolName)) return { behavior: "allow", updatedInput: input };
355
362
 
356
363
  // 提问不是授权:它问的是「你选哪个」
@@ -7,6 +7,7 @@ import { refusalFor, UnsupportedRequest } from "./refusals.js";
7
7
  import { config } from "../../core/config.js";
8
8
  import { contextBlock } from "../../core/chat-mode.js";
9
9
  import { MEMORY_MCP_NAME, memoryMcpSpawn } from "../../core/memory-mcp.js";
10
+ import { TASK_TOOL_TIMEOUT_SEC, TASKS_MCP_NAME, tasksMcpSpawn } from "../../core/tasks-mcp.js";
10
11
  import { describeAttachments, inlineTextBlocks, readTextAttachments } from "../../core/uploads.js";
11
12
  import type {
12
13
  AgentEvents,
@@ -130,7 +131,7 @@ export async function userMcpServers(): Promise<string[]> {
130
131
  if (userMcpCache && Date.now() - userMcpCache.at < 60_000) return userMcpCache.names;
131
132
  try {
132
133
  const res = await appServer.call<{ config?: { mcp_servers?: Record<string, unknown> | null } }>("config/read", {});
133
- const names = Object.keys(res.config?.mcp_servers ?? {}).filter((n) => n !== MEMORY_MCP_NAME);
134
+ const names = Object.keys(res.config?.mcp_servers ?? {}).filter((n) => n !== MEMORY_MCP_NAME && n !== TASKS_MCP_NAME);
134
135
  userMcpCache = { names, at: Date.now() };
135
136
  return names;
136
137
  } catch (err) {
@@ -363,12 +364,17 @@ export class CodexSession implements AgentSession {
363
364
  */
364
365
  private async modeParams(minimal = false): Promise<Record<string, unknown>> {
365
366
  const memory = this.opts.memory;
366
- const mcp = memory ? { [`mcp_servers.${MEMORY_MCP_NAME}`]: memoryMcpSpawn(memory.userId, "codex", { search: !!memory.search, ...(memory.convKey ? { convKey: memory.convKey } : {}) }) } : {};
367
+ const tasks = this.opts.tasks;
368
+ const mcp = {
369
+ ...(memory ? { [`mcp_servers.${MEMORY_MCP_NAME}`]: memoryMcpSpawn(memory.userId, "codex", { search: !!memory.search, ...(memory.convKey ? { convKey: memory.convKey } : {}) }) } : {}),
370
+ // 新建 / 修改任务要等用户点授权卡,Codex 默认 60 秒就算工具超时
371
+ ...(tasks ? { [`mcp_servers.${TASKS_MCP_NAME}`]: { ...tasksMcpSpawn(tasks.userId, tasks.convKey, "codex"), tool_timeout_sec: TASK_TOOL_TIMEOUT_SEC } } : {}),
372
+ };
367
373
  if (this.chat && !minimal) {
368
374
  const off = Object.fromEntries((await userMcpServers()).map((n) => [`mcp_servers.${n}.enabled`, false]));
369
375
  return { config: { ...CHAT_CONFIG, ...off, ...mcp } };
370
376
  }
371
- return memory ? { config: mcp } : {};
377
+ return Object.keys(mcp).length ? { config: mcp } : {};
372
378
  }
373
379
 
374
380
  /**
@@ -492,22 +492,26 @@ export class FeishuBot {
492
492
  // 别处(Web、别的飞书聊天)说的话,同步一句过来,不然这边看着没头没尾
493
493
  const o = ev.origin;
494
494
  const here = o.channel === "feishu" && o.channelId === this.cfg.id && (!o.chatId || o.chatId === chatId);
495
- if (!here) {
495
+ // 定时任务的提示词不同步过来:每次运行都发一条,飞书里就全是它了。结果出来时再带上任务名
496
+ if (!here && o.channel !== "task") {
496
497
  const files = ev.attachments.length ? `\n\n📎 ${ev.attachments.join("、")}` : "";
497
498
  const icon = o.channel === "web" ? "💻" : "💬";
498
499
  void this.client.sendCard(chatId, replyCard(`${icon} **${o.name ?? "Web"}**:${ev.text}${files}`));
499
500
  }
500
501
  return;
501
502
  }
503
+ // 定时任务这一轮不画过程卡:跑完之前不知道要不要提醒,卡片一发出去飞书就响了
502
504
  case "step":
503
- this.stream(key, chatId).addStep(ev.text);
505
+ if (!hub.currentTaskTurn(key)) this.stream(key, chatId).addStep(ev.text);
504
506
  return;
505
507
  case "text":
506
- this.stream(key, chatId).addText(ev.text);
508
+ if (!hub.currentTaskTurn(key)) this.stream(key, chatId).addText(ev.text);
507
509
  return;
508
510
  case "result": {
509
511
  const stream = this.stream(key, chatId);
510
- stream.finish(ev.text, ev.footer);
512
+ if (ev.silent) stream.finish("", ev.footer, true);
513
+ else if (ev.task) stream.finish(`⏰ **${ev.task.title}**\n\n${ev.text}`, ev.footer);
514
+ else stream.finish(ev.text, ev.footer);
511
515
  void stream.drain().then(() => this.clearTyping(key));
512
516
  return;
513
517
  }
@@ -69,8 +69,9 @@ export class TurnStream {
69
69
  /**
70
70
  * 收尾:过程卡定格为完成态,然后把最终回复作为普通消息发出去。
71
71
  * 整轮没有过程时不会留下任何卡片 —— 简单问答就该只有一条回复。
72
+ * quiet:不发回复(定时任务判断这次不用提醒),只把过程卡定格。
72
73
  */
73
- finish(finalText: string, footer?: string): void {
74
+ finish(finalText: string, footer?: string, quiet = false): void {
74
75
  this.enqueue(async () => {
75
76
  if (this.messageId) {
76
77
  await this.deps.updateCard(
@@ -79,7 +80,7 @@ export class TurnStream {
79
80
  );
80
81
  }
81
82
  const reply = finalText.trim() || this.text.trim();
82
- if (reply) await this.deps.sendReply(reply);
83
+ if (reply && !quiet) await this.deps.sendReply(reply);
83
84
  this.reset();
84
85
  });
85
86
  }
@@ -62,6 +62,7 @@ import {
62
62
  } from "./auth.js";
63
63
  import { canSeeConv, pushOwnedBy, sessionVisibleTo } from "./access.js";
64
64
  import { clientIp, cookies, HttpError, isHttps, readBody, readJsonBody, SECURITY_HEADERS, sendJson } from "./http.js";
65
+ import { handleTasksApi } from "./tasks-api.js";
65
66
  import { addSubscription, listSubscriptions, pushToAll, removeSubscription, removeSubscriptionsOf, vapidPublicKey } from "./push.js";
66
67
 
67
68
  export const COOKIE = "aim_session";
@@ -524,6 +525,9 @@ export async function handleApi(req: IncomingMessage, res: ServerResponse, url:
524
525
  return;
525
526
  }
526
527
 
528
+ // ----- 定时任务 -----
529
+ if (p.startsWith("/api/tasks") && (await handleTasksApi(req, res, p, user))) return;
530
+
527
531
  // ----- 目录选择器:列工作区内的子目录 -----
528
532
  if (p === "/api/dirs" && method === "GET") {
529
533
  const listing = listWorkspaceDirs(url.searchParams.get("path") ?? "");
@@ -13,6 +13,7 @@ import { parseCommand } from "../../core/commands.js";
13
13
  import { GLOBAL_ALWAYS_DENIED, hub, type EventEnvelope } from "../../core/hub.js";
14
14
  import { interactions, type Interaction } from "../../core/interactions.js";
15
15
  import { getConversation } from "../../core/store.js";
16
+ import { taskEvents } from "../../core/tasks.js";
16
17
  import { RUNNING_VERSION } from "../../core/upgrade.js";
17
18
  import type { Settlement } from "../../core/types.js";
18
19
  import { getUser, isAdmin, legacyOwnerId, type User } from "../../core/users.js";
@@ -180,6 +181,11 @@ function broadcastInteraction(i: Interaction): void {
180
181
  for (const client of clients) if (canSeeKey(client.user, i.convKey)) send(client, { t: "ix", interaction: i });
181
182
  }
182
183
 
184
+ /** 某人的定时任务变了(增删改、跑完一次):只告诉他自己开着的页面,页面自己去重新拉 */
185
+ function broadcastTasks(owner: string): void {
186
+ for (const client of clients) if (client.user.id === owner) send(client, { t: "tasks" });
187
+ }
188
+
183
189
  // ---------- 系统通知(Web Push) ----------
184
190
 
185
191
  /** 有页面正在前台看着这条会话:人就在眼前,不用推 */
@@ -235,10 +241,14 @@ function pushEvent(env: EventEnvelope): void {
235
241
  const ev = env.ev;
236
242
  if (ev.type !== "result" && ev.type !== "error") return;
237
243
  if (!isWebConv(env.key)) return;
244
+ // 定时任务这次判断不用提醒
245
+ if (ev.type === "result" && ev.silent) return;
238
246
  const name = convName(env.key);
239
247
  const payload =
240
248
  ev.type === "result"
241
- ? { title: `${name} · 回复完成`, body: plainPreview(ev.text) || "(没有文字回复)" }
249
+ ? ev.task
250
+ ? { title: `⏰ ${ev.task.title}`, body: plainPreview(ev.text) || "(没有文字回复)" }
251
+ : { title: `${name} · 回复完成`, body: plainPreview(ev.text) || "(没有文字回复)" }
242
252
  : { title: `${name} · 出错了`, body: plainPreview(ev.message) };
243
253
  notify(env.key, { ...payload, tag: `reply:${env.key}` });
244
254
  }
@@ -474,6 +484,7 @@ export async function startWeb(): Promise<void> {
474
484
  interactions.on("created", pushInteraction);
475
485
  interactions.on("updated", broadcastInteraction);
476
486
  interactions.on("settled", broadcastInteraction);
487
+ taskEvents.on("changed", broadcastTasks);
477
488
 
478
489
  await listen(server, host, port);
479
490
  console.log(`[web] 已监听 http://${host}:${port}`);
@@ -503,6 +514,7 @@ export async function stopWeb(): Promise<void> {
503
514
  interactions.off("created", pushInteraction);
504
515
  interactions.off("updated", broadcastInteraction);
505
516
  interactions.off("settled", broadcastInteraction);
517
+ taskEvents.off("changed", broadcastTasks);
506
518
  if (heartbeat) clearInterval(heartbeat);
507
519
  for (const client of clients) client.ws.close(1001, "服务重启");
508
520
  clients.clear();
@@ -0,0 +1,94 @@
1
+ // 定时任务页面的接口:/api/tasks。每个人只看得到、改得了自己的任务(别人的一律当不存在)。
2
+ import type { IncomingMessage, ServerResponse } from "node:http";
3
+ import { config, resolveWorkspacePath } from "../../core/config.js";
4
+ import { hub } from "../../core/hub.js";
5
+ import { memoryOwnerOf } from "../../core/personal.js";
6
+ import { getConversation } from "../../core/store.js";
7
+ import { forgetTask, isTaskRunning, runTaskNow } from "../../core/task-runner.js";
8
+ import { serverTimeZone } from "../../core/task-schedule.js";
9
+ import { createTask, deleteTask, getTask, listTasks, updateTask, viewOf, type Task, type TaskInput } from "../../core/tasks.js";
10
+ import type { User } from "../../core/users.js";
11
+ import { HttpError, readJsonBody, sendJson } from "./http.js";
12
+
13
+ function view(task: Task) {
14
+ const key = task.target.key;
15
+ const conv = key ? getConversation(key) : undefined;
16
+ return {
17
+ ...viewOf(task),
18
+ running: isTaskRunning(task.id),
19
+ // 结果在哪条对话里看(专用对话第一次运行前还没有)
20
+ ...(conv && !conv.hidden ? { convKey: conv.key, convTitle: conv.title ?? "" } : {}),
21
+ };
22
+ }
23
+
24
+ /** 表单 / 请求里的字段先在这里核一遍:agent 要启用着、目录要在工作区里、对话要是自己的 */
25
+ function checkInput(user: User, body: TaskInput): TaskInput {
26
+ const out: TaskInput = { ...body };
27
+ if (body.agent !== undefined) {
28
+ const agent = String(body.agent);
29
+ if (!hub.enabledAgents().some((d) => d.id === agent)) throw new HttpError(400, `${hub.label(agent)} 没有启用`);
30
+ }
31
+ if (typeof body.cwd === "string" && body.cwd.trim()) {
32
+ const cwd = resolveWorkspacePath(body.cwd.trim());
33
+ if (!cwd) throw new HttpError(400, `目录必须存在,且在 ${config().workspaceRoot} 之内`);
34
+ out.cwd = cwd === config().workspaceRoot ? "" : cwd;
35
+ }
36
+ const target = body.target as { kind?: string; key?: string } | undefined;
37
+ if (target?.kind === "conv") {
38
+ const conv = target.key ? getConversation(target.key) : undefined;
39
+ if (!conv || conv.hidden || memoryOwnerOf(conv) !== user.id) throw new HttpError(400, "回复到的那条对话不存在了,改成专用对话吧");
40
+ }
41
+ return out;
42
+ }
43
+
44
+ const bad = (err: unknown) => (err instanceof HttpError ? err : new HttpError(400, err instanceof Error ? err.message : String(err)));
45
+
46
+ /** 处理了就返回 true */
47
+ export async function handleTasksApi(req: IncomingMessage, res: ServerResponse, p: string, user: User): Promise<boolean> {
48
+ const method = req.method ?? "GET";
49
+ if (p === "/api/tasks" && method === "GET") {
50
+ sendJson(res, 200, { tasks: listTasks(user.id).map(view), tz: serverTimeZone(), now: Date.now() });
51
+ return true;
52
+ }
53
+ if (p === "/api/tasks" && method === "POST") {
54
+ const body = await readJsonBody<TaskInput>(req);
55
+ // 没选 agent(只启用了一个时表单里不显示):用第一个启用着的,不能默认成一个可能没启用的
56
+ if (body.agent === undefined || body.agent === "") body.agent = hub.enabledAgents()[0]?.id;
57
+ let task: Task;
58
+ try {
59
+ task = createTask(user.id, checkInput(user, body), "user");
60
+ } catch (err) {
61
+ throw bad(err);
62
+ }
63
+ sendJson(res, 200, view(task));
64
+ return true;
65
+ }
66
+ const m = /^\/api\/tasks\/([^/]+)(?:\/(run))?$/.exec(p);
67
+ if (!m) return false;
68
+ const task = getTask(user.id, decodeURIComponent(m[1]!));
69
+ if (!task) throw new HttpError(404, "这个任务不存在");
70
+ if (m[2] === "run" && method === "POST") {
71
+ const error = runTaskNow(task);
72
+ if (error) throw new HttpError(409, error);
73
+ sendJson(res, 200, view(task));
74
+ return true;
75
+ }
76
+ if (!m[2] && method === "PATCH") {
77
+ const body = await readJsonBody<TaskInput>(req);
78
+ let updated: Task;
79
+ try {
80
+ updated = updateTask(user.id, task.id, checkInput(user, body));
81
+ } catch (err) {
82
+ throw bad(err);
83
+ }
84
+ sendJson(res, 200, view(updated));
85
+ return true;
86
+ }
87
+ if (!m[2] && method === "DELETE") {
88
+ deleteTask(user.id, task.id);
89
+ forgetTask(task);
90
+ sendJson(res, 200, { ok: true });
91
+ return true;
92
+ }
93
+ return false;
94
+ }
@@ -42,21 +42,21 @@ export function contextBlock(mode: "chat" | "work", memory: string | undefined,
42
42
  mode === "chat"
43
43
  ? ["当前是「聊天模式」,你作为用户的私人 AI 助手和他聊天:", ...chatRules(search)]
44
44
  : ["当前是「工作模式」:可以执行命令、读写文件,按平常的方式干活。之前如果说过「聊天模式下不能做」,那个限制已经解除。"];
45
- return [
46
- OPEN,
47
- "(这段是 agent-loft 自动附上的背景,不是用户写的。照着做,但不要在回复里提到它。)",
48
- "",
49
- ...head,
50
- ...(memory ? ["", memory] : []),
51
- CLOSE,
52
- ].join("\n");
45
+ return wrapContext([...head, ...(memory ? ["", memory] : [])]);
46
+ }
47
+
48
+ /** 包成 agent-loft 附上的背景:用户看不到(读历史、实时显示时都剥掉),agent 照着做 */
49
+ export function wrapContext(lines: string[]): string {
50
+ return [OPEN, "(这段是 agent-loft 自动附上的背景,不是用户写的。照着做,但不要在回复里提到它。)", "", ...lines, CLOSE].join("\n");
53
51
  }
54
52
 
55
- /** 剥掉 contextBlock(读历史、列会话标题时用) */
53
+ /** 剥掉 contextBlock(读历史、列会话标题时用)。一条消息里可能有好几段(定时任务的说明 + Codex 的模式说明) */
56
54
  export function stripContext(text: string): string {
57
- const start = text.indexOf(OPEN);
58
- if (start < 0) return text;
59
- const end = text.indexOf(CLOSE, start);
60
- const rest = end < 0 ? "" : text.slice(end + CLOSE.length);
61
- return `${text.slice(0, start)}${rest}`.trim();
55
+ let out = text;
56
+ for (let start = out.indexOf(OPEN); start >= 0; start = out.indexOf(OPEN)) {
57
+ const end = out.indexOf(CLOSE, start);
58
+ const rest = end < 0 ? "" : out.slice(end + CLOSE.length);
59
+ out = `${out.slice(0, start)}${rest}`;
60
+ }
61
+ return out === text ? text : out.trim();
62
62
  }