agent-loft 0.2.24 → 0.2.26
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 +24 -2
- package/package.json +1 -1
- package/src/agents/claude/driver.ts +41 -0
- package/src/agents/claude/session.ts +68 -7
- package/src/agents/codex/driver.ts +55 -3
- package/src/agents/codex/session.ts +134 -7
- package/src/channels/feishu/bot.ts +91 -23
- package/src/channels/web/api.ts +209 -3
- package/src/cli/user.ts +3 -0
- package/src/core/chat-history.ts +250 -0
- package/src/core/chat-mode.ts +62 -0
- package/src/core/command-catalog.ts +3 -0
- package/src/core/commands.ts +4 -0
- package/src/core/feishu-links.ts +119 -0
- package/src/core/hub.ts +138 -1
- package/src/core/memory-curator.ts +447 -0
- package/src/core/memory-mcp.ts +37 -0
- package/src/core/memory.ts +311 -0
- package/src/core/personal.ts +44 -0
- package/src/core/scheduled.ts +113 -0
- package/src/core/store.ts +9 -2
- package/src/core/types.ts +34 -0
- package/src/core/uploads.ts +56 -3
- package/src/main.ts +12 -0
- package/src/mcp/memory-server.ts +168 -0
- package/web/app.js +669 -25
- package/web/style.css +160 -0
package/src/core/hub.ts
CHANGED
|
@@ -25,11 +25,15 @@ import {
|
|
|
25
25
|
type Conversation,
|
|
26
26
|
} from "./store.js";
|
|
27
27
|
import { attachmentInfo, clearUploads, ensureUploadsDir, KIND_LABEL } from "./uploads.js";
|
|
28
|
+
import { memoryOwnerOf, sessionMemory } from "./personal.js";
|
|
29
|
+
import { readMemory } from "./memory.js";
|
|
30
|
+
import { getUser } from "./users.js";
|
|
28
31
|
import type {
|
|
29
32
|
AgentDriver,
|
|
30
33
|
AttachmentInfo,
|
|
31
34
|
AgentSession,
|
|
32
35
|
ContextUsage,
|
|
36
|
+
ConversationMode,
|
|
33
37
|
ApprovalDecision,
|
|
34
38
|
HistoryMessage,
|
|
35
39
|
InteractionSpec,
|
|
@@ -41,6 +45,7 @@ import type {
|
|
|
41
45
|
} from "./types.js";
|
|
42
46
|
import { buildCatalog, expandCustomCommand, type AgentCommand, type CommandEntry, type CommandPrefs } from "./command-catalog.js";
|
|
43
47
|
import { helpText, type ParsedCommand } from "./commands.js";
|
|
48
|
+
import { listScheduled } from "./scheduled.js";
|
|
44
49
|
|
|
45
50
|
// ---------- 事件 ----------
|
|
46
51
|
|
|
@@ -130,6 +135,12 @@ export type ConversationInfo = {
|
|
|
130
135
|
forkOf?: string;
|
|
131
136
|
/** 当前 agent 能不能从某条回复处分叉成新对话 */
|
|
132
137
|
canFork?: boolean;
|
|
138
|
+
/** 聊天模式还是工作模式 */
|
|
139
|
+
mode: ConversationMode;
|
|
140
|
+
/** 这条对话有主人、会用上主人的记忆(主人自己关了记忆的话实际不会注入,见 personal.ts) */
|
|
141
|
+
personal?: boolean;
|
|
142
|
+
/** 排着的定时消息(见 core/scheduled.ts),按发送时间先后 */
|
|
143
|
+
scheduled?: { id: string; text: string; at: number }[];
|
|
133
144
|
};
|
|
134
145
|
|
|
135
146
|
/** 普通成员点了会落盘的「总是允许」 */
|
|
@@ -433,6 +444,12 @@ class Hub extends EventEmitter<{ event: [EventEnvelope] }> {
|
|
|
433
444
|
...(conv.hidden ? { hidden: true } : {}),
|
|
434
445
|
...(conv.forkOf ? { forkOf: conv.forkOf } : {}),
|
|
435
446
|
...(this.drivers.get(conv.agent)?.forkAt ? { canFork: true } : {}),
|
|
447
|
+
mode: conv.mode ?? "work",
|
|
448
|
+
...(memoryOwnerOf(conv) ? { personal: true } : {}),
|
|
449
|
+
...(() => {
|
|
450
|
+
const list = listScheduled(key);
|
|
451
|
+
return list.length ? { scheduled: list.map(({ id, text, at }) => ({ id, text, at })) } : {};
|
|
452
|
+
})(),
|
|
436
453
|
};
|
|
437
454
|
}
|
|
438
455
|
|
|
@@ -478,6 +495,19 @@ class Hub extends EventEmitter<{ event: [EventEnvelope] }> {
|
|
|
478
495
|
const holder: { session?: AgentSession } = {};
|
|
479
496
|
const alive = () => this.live.get(key)?.session === holder.session;
|
|
480
497
|
|
|
498
|
+
// 记忆在拉起会话这一刻读一次:会话里 agent 自己记下的,上下文里本来就有;
|
|
499
|
+
// 用户在设置里改了,memoryChanged() 会让空闲的会话下次说话时带着新的重新拉起
|
|
500
|
+
const memory = sessionMemory(conv);
|
|
501
|
+
const mode = conv.mode ?? "work";
|
|
502
|
+
// 续着的会话上次被告知的模式和现在不一样(新会话没有历史,算原装的工作模式)
|
|
503
|
+
const announceMode = (resumeId ? (binding.announcedMode ?? "work") : "work") !== mode;
|
|
504
|
+
// 真开始一轮了才算告诉过它(/status 之类也会拉起会话,但什么都没发过去)
|
|
505
|
+
let announced = false;
|
|
506
|
+
const markAnnounced = () => {
|
|
507
|
+
if (announced) return;
|
|
508
|
+
announced = true;
|
|
509
|
+
updateAgentBinding(key, agent, { announcedMode: mode === "work" ? undefined : mode });
|
|
510
|
+
};
|
|
481
511
|
const session = driver.createSession(
|
|
482
512
|
{
|
|
483
513
|
cwd: conv.cwd,
|
|
@@ -487,9 +517,16 @@ class Hub extends EventEmitter<{ event: [EventEnvelope] }> {
|
|
|
487
517
|
...(resumeId ? { resumeId } : {}),
|
|
488
518
|
...(fork ? { fork: true } : {}),
|
|
489
519
|
...(conv.autoEdit ? { autoEdit: true } : {}),
|
|
520
|
+
...(conv.mode === "chat" ? { mode: "chat" as const } : {}),
|
|
521
|
+
...(memory ? { memory } : {}),
|
|
522
|
+
...(announceMode ? { announceMode: true } : {}),
|
|
490
523
|
},
|
|
491
524
|
{
|
|
492
|
-
onTurnStart: () =>
|
|
525
|
+
onTurnStart: () => {
|
|
526
|
+
if (!alive()) return;
|
|
527
|
+
markAnnounced();
|
|
528
|
+
this.emitEvent(key, { type: "turn_start" });
|
|
529
|
+
},
|
|
493
530
|
onStep: (kind, text) => alive() && this.emitEvent(key, { type: "step", kind, text }),
|
|
494
531
|
onTextDelta: (text) => alive() && this.emitEvent(key, { type: "delta", text }),
|
|
495
532
|
onText: (text) => alive() && this.emitEvent(key, { type: "text", text }),
|
|
@@ -511,6 +548,7 @@ class Hub extends EventEmitter<{ event: [EventEnvelope] }> {
|
|
|
511
548
|
this.setInflight(key, (this.inflight.get(key) ?? 1) - 1);
|
|
512
549
|
this.refreshRuntimeOnce(key, liveId);
|
|
513
550
|
this.syncTitle(key, agent);
|
|
551
|
+
this.dropIfStale(key);
|
|
514
552
|
},
|
|
515
553
|
onError: (err) => {
|
|
516
554
|
if (!alive()) return;
|
|
@@ -529,6 +567,7 @@ class Hub extends EventEmitter<{ event: [EventEnvelope] }> {
|
|
|
529
567
|
this.emitEvent(key, { type: "error", message: diagnosis.chatMessage });
|
|
530
568
|
}
|
|
531
569
|
this.setInflight(key, (this.inflight.get(key) ?? 1) - 1);
|
|
570
|
+
this.dropIfStale(key);
|
|
532
571
|
},
|
|
533
572
|
onContext: (ctx) => {
|
|
534
573
|
if (!alive()) return;
|
|
@@ -979,6 +1018,91 @@ class Hub extends EventEmitter<{ event: [EventEnvelope] }> {
|
|
|
979
1018
|
return this.serial(key, () => this.setAutoEditNow(key, on));
|
|
980
1019
|
}
|
|
981
1020
|
|
|
1021
|
+
// ---------- 聊天 / 工作模式 ----------
|
|
1022
|
+
|
|
1023
|
+
/**
|
|
1024
|
+
* /chat、/work。同一条对话、同一个 agent 会话接着聊:丢掉活着的进程,下一条消息带着新模式的
|
|
1025
|
+
* 系统提示和工具 resume 回来(Claude、Codex 都实测过能接上上下文)。正在回复时不切:
|
|
1026
|
+
* 丢进程会把这一轮打断。
|
|
1027
|
+
*/
|
|
1028
|
+
private async setModeNow(key: string, mode: ConversationMode): Promise<string> {
|
|
1029
|
+
const conv = getConversation(key)!;
|
|
1030
|
+
if ((conv.mode ?? "work") === mode) {
|
|
1031
|
+
return mode === "chat" ? "已经是💬 聊天模式了。发 /work 切到工作模式。" : "已经是🛠 工作模式了。发 /chat 切到聊天模式。";
|
|
1032
|
+
}
|
|
1033
|
+
if (this.isBusy(key)) return "正在回复,等这一轮结束(或者先 /stop)再切换模式。";
|
|
1034
|
+
// 聊过的聊天对话切到工作模式:之后再回聊天模式就省不了了(Web 上点按钮时会先问,命令直接切,提醒一句)
|
|
1035
|
+
const hadChat = mode === "work" && !!agentBinding(conv).sessionId;
|
|
1036
|
+
await this.drop(key, "已切换模式");
|
|
1037
|
+
updateConversation(key, { mode: mode === "chat" ? "chat" : undefined });
|
|
1038
|
+
this.emitEvent(key, { type: "meta" });
|
|
1039
|
+
return mode === "chat"
|
|
1040
|
+
? "已切到💬 聊天模式:精简的系统提示,只能联网搜索,不执行命令、不读写文件,每轮的开销小很多。上下文接着之前的。要动手干活时发 /work。"
|
|
1041
|
+
: `已切到🛠 工作模式:${this.label(conv.agent)} 原装的全部能力(执行命令、改文件、技能、MCP)。上下文接着之前的。发 /chat 切回聊天模式。${
|
|
1042
|
+
hadChat ? "\n\n提示:工作模式会在这条会话里留下一大段背景,之后再切回聊天模式就省不了多少了。干完活想回到省 token 的聊天,发 /new 开个新会话再发 /chat。" : ""
|
|
1043
|
+
}`;
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
setMode(key: string, mode: ConversationMode): Promise<string> {
|
|
1047
|
+
return this.serial(key, () => this.setModeNow(key, mode));
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
// ---------- 记忆变了 / 主人变了 ----------
|
|
1051
|
+
|
|
1052
|
+
/** 记忆过期了的会话进程(Live.id):空闲下来就丢掉,下次说话带着新记忆重新拉起 */
|
|
1053
|
+
private staleLive = new Set<number>();
|
|
1054
|
+
|
|
1055
|
+
private dropIfStale(key: string): void {
|
|
1056
|
+
const live = this.live.get(key);
|
|
1057
|
+
if (!live || !this.staleLive.has(live.id) || this.isBusy(key)) return;
|
|
1058
|
+
this.staleLive.delete(live.id);
|
|
1059
|
+
void this.serial(key, async () => {
|
|
1060
|
+
// 排队期间可能已经换了进程、或者又开始说话了:那就留到下一轮结束
|
|
1061
|
+
const now = this.live.get(key);
|
|
1062
|
+
if (now?.id !== live.id) return;
|
|
1063
|
+
if (this.isBusy(key)) {
|
|
1064
|
+
this.staleLive.add(live.id);
|
|
1065
|
+
return;
|
|
1066
|
+
}
|
|
1067
|
+
await this.drop(key, "记忆已更新");
|
|
1068
|
+
});
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
/**
|
|
1072
|
+
* 用户在设置里改了记忆(关于我、增删条目、开关):他名下活着的会话都要换上新的。
|
|
1073
|
+
* agent 自己用 remember / forget 改的不走这里 —— 那些本来就在上下文里,没必要重启进程、白白丢掉提示缓存。
|
|
1074
|
+
*/
|
|
1075
|
+
memoryChanged(userId: string): void {
|
|
1076
|
+
for (const [key, live] of this.live) {
|
|
1077
|
+
const conv = getConversation(key);
|
|
1078
|
+
if (!conv || memoryOwnerOf(conv) !== userId) continue;
|
|
1079
|
+
this.staleLive.add(live.id);
|
|
1080
|
+
this.dropIfStale(key);
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
/**
|
|
1085
|
+
* 对话换主人(飞书私聊绑定 / 解绑 / 换绑账号时)。返回有没有变。
|
|
1086
|
+
* 旧主人的 agent 会话不能交给新主人接着用(上下文里是旧主人的话和记忆),绑定清掉、下条消息开新的;
|
|
1087
|
+
* 唯一的例外是从「没主(归管理员)」变成某个管理员自己:那些会话本来就是他能看的。
|
|
1088
|
+
* 同步改记录(紧接着的消息要按新主人处理),丢进程排进串行队列。
|
|
1089
|
+
*/
|
|
1090
|
+
reassignOwner(key: string, owner: string | undefined): boolean {
|
|
1091
|
+
const conv = getConversation(key);
|
|
1092
|
+
if (!conv || conv.owner === owner) return false;
|
|
1093
|
+
const keep = !conv.owner && !!owner && getUser(owner)?.role === "admin";
|
|
1094
|
+
const perAgent = keep
|
|
1095
|
+
? conv.perAgent
|
|
1096
|
+
: Object.fromEntries(Object.entries(conv.perAgent).map(([a, b]) => [a, { ...b, sessionId: undefined, forkFrom: undefined }]));
|
|
1097
|
+
updateConversation(key, { owner, perAgent });
|
|
1098
|
+
void this.switchOp(key, async (op) => {
|
|
1099
|
+
await this.drop(key, "对话换了主人");
|
|
1100
|
+
this.markSwitched(key, op.seq);
|
|
1101
|
+
this.emitEvent(key, { type: "meta" });
|
|
1102
|
+
});
|
|
1103
|
+
return true;
|
|
1104
|
+
}
|
|
1105
|
+
|
|
982
1106
|
/** 切 agent:各 agent 的会话各自保留,切回来接着聊。 */
|
|
983
1107
|
private async setAgentNow(key: string, agent: string, allowed: string[], op: SwitchOp): Promise<string> {
|
|
984
1108
|
const conv = getConversation(key)!;
|
|
@@ -1026,7 +1150,17 @@ class Hub extends EventEmitter<{ event: [EventEnvelope] }> {
|
|
|
1026
1150
|
const driver = this.requireDriver(conv.agent);
|
|
1027
1151
|
const runtime = await this.runtimeNow(key);
|
|
1028
1152
|
const extra = await withTimeout(driver.statusLines(), "读取状态").catch(() => [] as string[]);
|
|
1153
|
+
const memoryOwner = memoryOwnerOf(conv);
|
|
1154
|
+
const memoryLine = !memoryOwner
|
|
1155
|
+
? conv.channel === "feishu"
|
|
1156
|
+
? "记忆:不用(群聊,或者没绑定账号的私聊)"
|
|
1157
|
+
: "记忆:不用(这条对话没有主人)"
|
|
1158
|
+
: readMemory(memoryOwner).enabled
|
|
1159
|
+
? `记忆:开(${getUser(memoryOwner)?.name ?? memoryOwner} 的)`
|
|
1160
|
+
: "记忆:主人关掉了";
|
|
1029
1161
|
return [
|
|
1162
|
+
`模式:${conv.mode === "chat" ? "💬 聊天(/work 切到工作模式)" : "🛠 工作(/chat 切到聊天模式)"}`,
|
|
1163
|
+
memoryLine,
|
|
1030
1164
|
`Agent:${driver.label}`,
|
|
1031
1165
|
`会话:${agentBinding(conv).sessionId ?? "(尚未建立)"}`,
|
|
1032
1166
|
`目录:${conv.cwd}`,
|
|
@@ -1090,6 +1224,7 @@ class Hub extends EventEmitter<{ event: [EventEnvelope] }> {
|
|
|
1090
1224
|
cwd: conv.cwd,
|
|
1091
1225
|
...(conv.cwdExplicit ? { cwdExplicit: true } : {}),
|
|
1092
1226
|
...(conv.autoEdit ? { autoEdit: true } : {}),
|
|
1227
|
+
...(conv.mode ? { mode: conv.mode } : {}),
|
|
1093
1228
|
forkOf: key,
|
|
1094
1229
|
perAgent: {
|
|
1095
1230
|
[conv.agent]: {
|
|
@@ -1170,6 +1305,8 @@ class Hub extends EventEmitter<{ event: [EventEnvelope] }> {
|
|
|
1170
1305
|
return text(getConversation(key)!.cwd);
|
|
1171
1306
|
case "autoedit":
|
|
1172
1307
|
return text(await this.setAutoEdit(key, cmd.on));
|
|
1308
|
+
case "mode":
|
|
1309
|
+
return text(await this.setMode(key, cmd.mode));
|
|
1173
1310
|
case "status":
|
|
1174
1311
|
return text(await this.status(key));
|
|
1175
1312
|
case "cd":
|
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
// 聊天记录索引的录入,以及自动整理记忆。
|
|
2
|
+
//
|
|
3
|
+
// 三件事:
|
|
4
|
+
// 1. 录入(startHistoryCapture):hub 的事件流里,有主人的对话的每句话(用户说的、agent 的最终回复)
|
|
5
|
+
// 记进主人的聊天记录索引(chat-history.ts)。主人关了记忆就不记。
|
|
6
|
+
// 2. 回填(backfill):第一次用时,把已有对话从 agent 的记录里读一遍进索引,每人只做一次。
|
|
7
|
+
// 回填进来的只供搜索,不拿去整理 —— 一次整理几百段旧对话太费额度。
|
|
8
|
+
// 3. 整理:把上次整理之后新说的话交给模型(用户在记忆设置里选 agent 和模型,默认最省的),
|
|
9
|
+
// 让它对照现有记忆给出新增 / 更新 / 删除。用户自己加的条目它动不了。什么时候整理也由用户定
|
|
10
|
+
// (CurateSettings.when):每段聊完后 / 每天一次把当天的合起来 / 只在点「现在整理」时 / 不整理。
|
|
11
|
+
import { hub } from "./hub.js";
|
|
12
|
+
import {
|
|
13
|
+
appendRecords,
|
|
14
|
+
markExtracted,
|
|
15
|
+
patchMeta,
|
|
16
|
+
pendingConversations,
|
|
17
|
+
readMeta,
|
|
18
|
+
readRecords,
|
|
19
|
+
writeMeta,
|
|
20
|
+
type CurationLog,
|
|
21
|
+
type HistoryRecord,
|
|
22
|
+
} from "./chat-history.js";
|
|
23
|
+
import {
|
|
24
|
+
addMemoryEntry,
|
|
25
|
+
localDate,
|
|
26
|
+
MEMORY_LIMITS,
|
|
27
|
+
readMemory,
|
|
28
|
+
removeMemoryItem,
|
|
29
|
+
updateMemoryItem,
|
|
30
|
+
type CurateSettings,
|
|
31
|
+
type UserMemory,
|
|
32
|
+
} from "./memory.js";
|
|
33
|
+
import { memoryOwnerOf } from "./personal.js";
|
|
34
|
+
import { listConversations } from "./store.js";
|
|
35
|
+
import { activeUsers } from "./users.js";
|
|
36
|
+
import type { AgentDriver } from "./types.js";
|
|
37
|
+
import { agentAvailability } from "../agents/health.js";
|
|
38
|
+
|
|
39
|
+
/** 「每天一次」和「现在整理」也不碰还在聊的:最后一句过去这么久才算聊完 */
|
|
40
|
+
const SETTLE_MS = 10 * 60_000;
|
|
41
|
+
const SWEEP_MS = 5 * 60_000;
|
|
42
|
+
/** 一次调用交给模型的对话最多这么多字;当天聊得多就分几次 */
|
|
43
|
+
const INPUT_MAX = 16_000;
|
|
44
|
+
/** 一段对话里用户的话加起来太短(「好」「谢谢」)就不值得整理 */
|
|
45
|
+
const MIN_USER_CHARS = 15;
|
|
46
|
+
/** 一次调用最多改这么多 */
|
|
47
|
+
const MAX_CHANGES = 5;
|
|
48
|
+
|
|
49
|
+
// ---------- 录入 ----------
|
|
50
|
+
|
|
51
|
+
let capturing = false;
|
|
52
|
+
|
|
53
|
+
/** 记不记这个用户的聊天:开着记忆,并且整理和「参考以前的聊天」至少开了一个 */
|
|
54
|
+
function wants(memory: UserMemory): boolean {
|
|
55
|
+
return memory.enabled && (memory.curate.when !== "off" || memory.chatHistory);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function startHistoryCapture(): void {
|
|
59
|
+
if (capturing) return;
|
|
60
|
+
capturing = true;
|
|
61
|
+
hub.on("event", (env) => {
|
|
62
|
+
const ev = env.ev;
|
|
63
|
+
if (ev.type !== "user" && ev.type !== "result") return;
|
|
64
|
+
try {
|
|
65
|
+
const conv = hub.conversation(env.key);
|
|
66
|
+
if (!conv || conv.hidden) return;
|
|
67
|
+
const owner = memoryOwnerOf(conv);
|
|
68
|
+
if (!owner || !wants(readMemory(owner))) return;
|
|
69
|
+
const text =
|
|
70
|
+
ev.type === "user"
|
|
71
|
+
? [ev.text, ev.attachments.length ? `(附件:${ev.attachments.join("、")})` : ""].filter(Boolean).join("\n")
|
|
72
|
+
: ev.text;
|
|
73
|
+
if (!text.trim()) return;
|
|
74
|
+
appendRecords(owner, [{ k: env.key, a: conv.agent, r: ev.type === "user" ? "u" : "a", t: text, ts: env.ts }]);
|
|
75
|
+
} catch (err) {
|
|
76
|
+
console.warn("[记忆] 记录聊天失败:", err instanceof Error ? err.message : err);
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ---------- 回填 ----------
|
|
82
|
+
|
|
83
|
+
const dedupKey = (r: Pick<HistoryRecord, "k" | "r" | "t">) => `${r.k}|${r.r}|${r.t.slice(0, 200)}`;
|
|
84
|
+
|
|
85
|
+
/** 把这个用户已有的对话读进索引。只读每条对话当前绑着的 agent 会话 */
|
|
86
|
+
export async function backfill(userId: string): Promise<number> {
|
|
87
|
+
const existing = readRecords(userId);
|
|
88
|
+
const seen = new Set(existing.map(dedupKey));
|
|
89
|
+
const meta = readMeta(userId);
|
|
90
|
+
// 每条对话里还没整理的实时录入最早是什么时候:回填的「整理到」标记不能越过它,否则那些话就被跳过了
|
|
91
|
+
const firstPending = new Map<string, number>();
|
|
92
|
+
for (const r of existing) {
|
|
93
|
+
if (r.ts <= (meta.extracted[r.k] ?? 0)) continue;
|
|
94
|
+
firstPending.set(r.k, Math.min(firstPending.get(r.k) ?? Infinity, r.ts));
|
|
95
|
+
}
|
|
96
|
+
const enabled = new Set(hub.enabledAgents().map((d) => d.id));
|
|
97
|
+
let added = 0;
|
|
98
|
+
for (const conv of listConversations()) {
|
|
99
|
+
if (conv.hidden || memoryOwnerOf(conv) !== userId) continue;
|
|
100
|
+
for (const [agent, binding] of Object.entries(conv.perAgent)) {
|
|
101
|
+
const driver = hub.driver(agent);
|
|
102
|
+
if (!binding.sessionId || !driver || !enabled.has(agent)) continue;
|
|
103
|
+
let messages;
|
|
104
|
+
try {
|
|
105
|
+
messages = await withTimeout(driver.readHistory(binding.sessionId, conv.cwd), 30_000);
|
|
106
|
+
} catch (err) {
|
|
107
|
+
console.warn(`[记忆] 回填 ${conv.key} 失败:`, err instanceof Error ? err.message : err);
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
// 没带时间的(少数),沿用前一句的时间,开头的用对话创建时间
|
|
111
|
+
let prev = conv.createdAt;
|
|
112
|
+
const records: HistoryRecord[] = messages
|
|
113
|
+
.filter((m) => m.text.trim())
|
|
114
|
+
.map((m) => {
|
|
115
|
+
prev = m.ts ?? prev;
|
|
116
|
+
return { k: conv.key, a: agent, r: m.role === "user" ? ("u" as const) : ("a" as const), t: m.text, ts: prev };
|
|
117
|
+
})
|
|
118
|
+
.filter((r) => !seen.has(dedupKey(r)));
|
|
119
|
+
appendRecords(userId, records);
|
|
120
|
+
added += records.length;
|
|
121
|
+
// 回填的旧对话只供搜索,不整理
|
|
122
|
+
const last = Math.min(Math.max(0, ...records.map((r) => r.ts)), (firstPending.get(conv.key) ?? Infinity) - 1);
|
|
123
|
+
if (last > 0) meta.extracted[conv.key] = Math.max(meta.extracted[conv.key] ?? 0, last);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
meta.backfilled = true;
|
|
127
|
+
writeMeta(userId, meta);
|
|
128
|
+
if (added) console.log(`[记忆] 已把 ${added} 句以前的聊天收进 ${userId} 的聊天记录索引`);
|
|
129
|
+
return added;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ---------- 整理 ----------
|
|
133
|
+
|
|
134
|
+
export const CURATE_SYSTEM = [
|
|
135
|
+
"你在帮一个 AI 助手整理它对用户的长期记忆。会给你:用户自己写的「关于我」、已有的记忆条目(带 id),以及用户最近的一段或几段对话。",
|
|
136
|
+
"找出这些对话里值得长期记住的、关于用户本人的信息:身份和背景、长期的偏好和习惯、长期在做的项目、用的设备和环境、做过的重要决定、明确要求以后注意的事。",
|
|
137
|
+
"",
|
|
138
|
+
"规则:",
|
|
139
|
+
"- 只记关于用户的、以后的对话还用得上的事。不记一次性的任务细节、临时的问题、闲聊、助手自己说的话。",
|
|
140
|
+
"- 不记密码、密钥、验证码、证件号、详细住址、银行卡这类敏感信息。",
|
|
141
|
+
"- 已有条目里有的不要重复;信息变了就 update 那条(给 id),过时的、被用户否定的、和别的条目重复的就 remove。",
|
|
142
|
+
"- 标着「用户自己加的」的条目不要 update,也不要 remove。",
|
|
143
|
+
"- 每条一句话,以「用户」开头,写清时间背景(比如「2026-10 起」),用中文。",
|
|
144
|
+
"- 宁缺毋滥:没有值得记的就全部返回空数组。新增最多 3 条。",
|
|
145
|
+
].join("\n");
|
|
146
|
+
|
|
147
|
+
export const CURATE_SCHEMA = {
|
|
148
|
+
type: "object",
|
|
149
|
+
properties: {
|
|
150
|
+
add: { type: "array", items: { type: "string" } },
|
|
151
|
+
update: {
|
|
152
|
+
type: "array",
|
|
153
|
+
items: {
|
|
154
|
+
type: "object",
|
|
155
|
+
properties: { id: { type: "string" }, text: { type: "string" } },
|
|
156
|
+
required: ["id", "text"],
|
|
157
|
+
additionalProperties: false,
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
remove: { type: "array", items: { type: "string" } },
|
|
161
|
+
},
|
|
162
|
+
required: ["add", "update", "remove"],
|
|
163
|
+
additionalProperties: false,
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
export type Pending = { key: string; records: HistoryRecord[]; last: number };
|
|
167
|
+
|
|
168
|
+
/** 一段对话写成给模型看的文字。太长就只留最近的;助手的回复截短(要的是用户的事,不是回复内容) */
|
|
169
|
+
function conversationText(records: HistoryRecord[], budget: number): string[] {
|
|
170
|
+
const lines: string[] = [];
|
|
171
|
+
for (const r of [...records].reverse()) {
|
|
172
|
+
const text = r.r === "a" && r.t.length > 600 ? `${r.t.slice(0, 600)}…` : r.t;
|
|
173
|
+
const line = `${r.r === "u" ? "用户" : "助手"}:${text}`;
|
|
174
|
+
if (line.length > budget) break;
|
|
175
|
+
budget -= line.length;
|
|
176
|
+
lines.unshift(line);
|
|
177
|
+
}
|
|
178
|
+
return lines;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const lengthOf = (records: HistoryRecord[]) => records.reduce((n, r) => n + Math.min(r.t.length, r.r === "a" ? 600 : r.t.length) + 4, 0);
|
|
182
|
+
|
|
183
|
+
/** 交给模型的那段话:现有记忆 + 一段或几段对话 */
|
|
184
|
+
export function curatePrompt(memory: UserMemory, groups: { title?: string; records: HistoryRecord[] }[]): string {
|
|
185
|
+
const items = memory.items.map((i) => `- [${i.id}]${i.source === "user" ? "(用户自己加的)" : ""} ${i.text}`);
|
|
186
|
+
const perGroup = Math.floor(INPUT_MAX / Math.max(1, groups.length));
|
|
187
|
+
return [
|
|
188
|
+
`今天是 ${localDate(Date.now())}。`,
|
|
189
|
+
"",
|
|
190
|
+
"## 关于我",
|
|
191
|
+
memory.about.trim() || "(没写)",
|
|
192
|
+
"",
|
|
193
|
+
"## 已有的记忆",
|
|
194
|
+
...(items.length ? items : ["(还没有)"]),
|
|
195
|
+
...groups.flatMap((g) => ["", `## 对话${g.title ? `:${g.title}` : ""}`, ...conversationText(g.records, perGroup)]),
|
|
196
|
+
].join("\n");
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** 把要整理的对话分成几批,每批加起来不超过 INPUT_MAX(单段太长的自己一批,里面会截短) */
|
|
200
|
+
export function chunk(pendings: Pending[]): Pending[][] {
|
|
201
|
+
const out: Pending[][] = [];
|
|
202
|
+
let cur: Pending[] = [];
|
|
203
|
+
let size = 0;
|
|
204
|
+
for (const p of pendings) {
|
|
205
|
+
const n = Math.min(lengthOf(p.records), INPUT_MAX);
|
|
206
|
+
if (cur.length && size + n > INPUT_MAX) {
|
|
207
|
+
out.push(cur);
|
|
208
|
+
cur = [];
|
|
209
|
+
size = 0;
|
|
210
|
+
}
|
|
211
|
+
cur.push(p);
|
|
212
|
+
size += n;
|
|
213
|
+
}
|
|
214
|
+
if (cur.length) out.push(cur);
|
|
215
|
+
return out;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
type Proposal = { add: string[]; update: { id: string; text: string }[]; remove: string[] };
|
|
219
|
+
|
|
220
|
+
function parseProposal(raw: unknown): Proposal {
|
|
221
|
+
const o = (raw ?? {}) as Partial<Record<keyof Proposal, unknown>>;
|
|
222
|
+
const strings = (v: unknown) => (Array.isArray(v) ? v.filter((x): x is string => typeof x === "string" && !!x.trim()) : []);
|
|
223
|
+
const updates = Array.isArray(o.update)
|
|
224
|
+
? o.update.filter((u): u is { id: string; text: string } => !!u && typeof u.id === "string" && typeof u.text === "string" && !!u.text.trim())
|
|
225
|
+
: [];
|
|
226
|
+
return { add: strings(o.add), update: updates, remove: strings(o.remove) };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export type CurateResult = { added: string[]; updated: string[]; removed: string[] };
|
|
230
|
+
|
|
231
|
+
/** 按规则落实模型的提议:用户自己加的不动,总数有上限,单条出错不影响别的 */
|
|
232
|
+
export function applyProposal(userId: string, raw: unknown): CurateResult {
|
|
233
|
+
const proposal = parseProposal(raw);
|
|
234
|
+
const result: CurateResult = { added: [], updated: [], removed: [] };
|
|
235
|
+
let budget = MAX_CHANGES;
|
|
236
|
+
const mine = (id: string) => readMemory(userId).items.find((i) => i.id === id && i.source !== "user");
|
|
237
|
+
for (const id of proposal.remove) {
|
|
238
|
+
if (budget <= 0) break;
|
|
239
|
+
const item = mine(id);
|
|
240
|
+
if (!item) continue;
|
|
241
|
+
removeMemoryItem(userId, id);
|
|
242
|
+
result.removed.push(item.text);
|
|
243
|
+
budget--;
|
|
244
|
+
}
|
|
245
|
+
for (const u of proposal.update) {
|
|
246
|
+
if (budget <= 0) break;
|
|
247
|
+
if (!mine(u.id)) continue;
|
|
248
|
+
try {
|
|
249
|
+
updateMemoryItem(userId, u.id, u.text.slice(0, MEMORY_LIMITS.item));
|
|
250
|
+
result.updated.push(u.text);
|
|
251
|
+
budget--;
|
|
252
|
+
} catch {
|
|
253
|
+
// 内容不合规(空、太长):跳过这条
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
for (const text of proposal.add.slice(0, 3)) {
|
|
257
|
+
if (budget <= 0) break;
|
|
258
|
+
try {
|
|
259
|
+
const r = addMemoryEntry(userId, text.slice(0, MEMORY_LIMITS.item), "auto");
|
|
260
|
+
// 和已有的重复了:只更新时间,不算新增
|
|
261
|
+
if (!r.created) continue;
|
|
262
|
+
result.added.push(text);
|
|
263
|
+
// 满了挤掉了最旧的一条:也要告诉用户
|
|
264
|
+
if (r.evicted) result.removed.push(r.evicted.text);
|
|
265
|
+
budget--;
|
|
266
|
+
} catch {
|
|
267
|
+
// 全是用户自己加的满了、或者内容不合规:跳过
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
return result;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function clip(t: string, n = 40): string {
|
|
274
|
+
return t.length > n ? `${t.slice(0, n)}…` : t;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export function describeResult(r: CurateResult): string | undefined {
|
|
278
|
+
const parts = [
|
|
279
|
+
...r.added.map((t) => `记住了「${clip(t)}」`),
|
|
280
|
+
...r.updated.map((t) => `更新为「${clip(t)}」`),
|
|
281
|
+
...(r.removed.length ? [`删掉了 ${r.removed.length} 条过时的`] : []),
|
|
282
|
+
];
|
|
283
|
+
return parts.length ? `🧠 记忆已更新:${parts.join(";")}。(设置 → 记忆 里可以查看和修改)` : undefined;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** 按用户的设置挑 agent。指定的那个用不了就是 undefined(不悄悄换成另一个,免得用到他没想用的额度) */
|
|
287
|
+
export function pickDriver(settings: Pick<CurateSettings, "agent">): AgentDriver | undefined {
|
|
288
|
+
const usable = hub.enabledAgents().filter((d) => d.complete && agentAvailability(d.id as "claude" | "codex")?.available !== false);
|
|
289
|
+
if (settings.agent !== "auto") return usable.find((d) => d.id === settings.agent);
|
|
290
|
+
return usable.find((d) => d.id === "claude") ?? usable[0];
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function modelLabel(driver: AgentDriver, model: string): string {
|
|
294
|
+
return `${driver.label} · ${model || (driver.id === "claude" ? "Haiku" : "默认模型")}`;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** 每个用户连续失败的次数:一直失败(额度用完之类)三次后放过这批,别每 5 分钟烧一次 */
|
|
298
|
+
const failures = new Map<string, number>();
|
|
299
|
+
|
|
300
|
+
export type BatchOutcome = CurationLog;
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* 整理一批对话:太短的直接放过,其余分批交给模型,按批落实、按批记成整理过。
|
|
304
|
+
* notify:改了什么在每段对话里说一声(「聊完后」模式;每天 / 手动整理只记在设置页)。
|
|
305
|
+
*/
|
|
306
|
+
export async function curateBatch(
|
|
307
|
+
userId: string,
|
|
308
|
+
pendings: Pending[],
|
|
309
|
+
opts: { notify?: boolean; driver?: AgentDriver } = {},
|
|
310
|
+
): Promise<BatchOutcome> {
|
|
311
|
+
const memory = readMemory(userId);
|
|
312
|
+
const log: CurationLog = { at: Date.now(), conversations: 0, added: [], updated: [], removed: 0 };
|
|
313
|
+
const worth: Pending[] = [];
|
|
314
|
+
for (const p of pendings) {
|
|
315
|
+
const userChars = p.records.filter((r) => r.r === "u").reduce((n, r) => n + r.t.length, 0);
|
|
316
|
+
if (userChars < MIN_USER_CHARS) markExtracted(userId, p.key, p.last);
|
|
317
|
+
else worth.push(p);
|
|
318
|
+
}
|
|
319
|
+
if (worth.length === 0) return log;
|
|
320
|
+
const driver = opts.driver ?? pickDriver(memory.curate);
|
|
321
|
+
if (!driver) {
|
|
322
|
+
// 没有能用的 agent:留着,等它能用了再整理
|
|
323
|
+
log.error = memory.curate.agent === "auto" ? "没有能用的 agent" : `${memory.curate.agent === "claude" ? "Claude" : "Codex"} 现在用不了(没启用、没装或没登录)`;
|
|
324
|
+
patchMeta(userId, { lastCuration: log });
|
|
325
|
+
return log;
|
|
326
|
+
}
|
|
327
|
+
log.by = modelLabel(driver, memory.curate.model);
|
|
328
|
+
for (const batch of chunk(worth)) {
|
|
329
|
+
let raw: unknown;
|
|
330
|
+
try {
|
|
331
|
+
raw = await driver.complete!({
|
|
332
|
+
system: CURATE_SYSTEM,
|
|
333
|
+
prompt: curatePrompt(readMemory(userId), batch.map((p) => ({ title: hub.conversation(p.key)?.title, records: p.records }))),
|
|
334
|
+
schema: CURATE_SCHEMA,
|
|
335
|
+
...(memory.curate.model ? { model: memory.curate.model } : {}),
|
|
336
|
+
timeoutMs: 120_000,
|
|
337
|
+
});
|
|
338
|
+
} catch (err) {
|
|
339
|
+
const n = (failures.get(userId) ?? 0) + 1;
|
|
340
|
+
failures.set(userId, n);
|
|
341
|
+
log.error = err instanceof Error ? err.message : String(err);
|
|
342
|
+
console.warn(`[记忆] 整理 ${userId} 的 ${batch.length} 段对话失败(第 ${n} 次):`, log.error);
|
|
343
|
+
if (n >= 3) {
|
|
344
|
+
failures.delete(userId);
|
|
345
|
+
for (const p of batch) markExtracted(userId, p.key, p.last);
|
|
346
|
+
}
|
|
347
|
+
continue;
|
|
348
|
+
}
|
|
349
|
+
failures.delete(userId);
|
|
350
|
+
const result = applyProposal(userId, raw);
|
|
351
|
+
for (const p of batch) markExtracted(userId, p.key, p.last);
|
|
352
|
+
log.conversations += batch.length;
|
|
353
|
+
log.added.push(...result.added);
|
|
354
|
+
log.updated.push(...result.updated);
|
|
355
|
+
log.removed += result.removed.length;
|
|
356
|
+
const note = describeResult(result);
|
|
357
|
+
if (note && opts.notify) for (const p of batch) hub.notice(p.key, note);
|
|
358
|
+
}
|
|
359
|
+
console.log(`[记忆] ${userId}:整理了 ${log.conversations} 段对话(${log.by}),+${log.added.length} ~${log.updated.length} -${log.removed}`);
|
|
360
|
+
patchMeta(userId, { lastCuration: log });
|
|
361
|
+
return log;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** 某个用户等着整理的对话。删掉的对话直接记成整理过;正在回复的这次先不碰 */
|
|
365
|
+
function collect(userId: string, settleMs: number, now?: number): Pending[] {
|
|
366
|
+
const out: Pending[] = [];
|
|
367
|
+
for (const p of pendingConversations(userId, settleMs, now)) {
|
|
368
|
+
const conv = hub.conversation(p.key);
|
|
369
|
+
if (!conv || conv.hidden) {
|
|
370
|
+
markExtracted(userId, p.key, p.last);
|
|
371
|
+
continue;
|
|
372
|
+
}
|
|
373
|
+
if (!hub.isBusy(p.key)) out.push(p);
|
|
374
|
+
}
|
|
375
|
+
return out;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** 同一时间只整理一件事:定时的和用户点的「现在整理」不能撞在一起,同一段对话整理两次 */
|
|
379
|
+
let lock: Promise<unknown> = Promise.resolve();
|
|
380
|
+
function exclusive<T>(fn: () => Promise<T>): Promise<T> {
|
|
381
|
+
const next = lock.catch(() => {}).then(fn);
|
|
382
|
+
lock = next.catch(() => {});
|
|
383
|
+
return next;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** 看一遍所有人:该回填的回填,按各自的设置该整理的整理 */
|
|
387
|
+
export function sweepOnce(opts: { now?: number } = {}): Promise<void> {
|
|
388
|
+
return exclusive(async () => {
|
|
389
|
+
const now = opts.now ?? Date.now();
|
|
390
|
+
for (const user of activeUsers()) {
|
|
391
|
+
try {
|
|
392
|
+
const memory = readMemory(user.id);
|
|
393
|
+
if (!memory.enabled) continue;
|
|
394
|
+
if (memory.chatHistory && !readMeta(user.id).backfilled) await backfill(user.id);
|
|
395
|
+
const c = memory.curate;
|
|
396
|
+
if (c.when === "off") {
|
|
397
|
+
// 不整理:聊过的不攒着,之后再打开也不会一下子整理一大堆旧的
|
|
398
|
+
for (const p of pendingConversations(user.id, 0, now)) markExtracted(user.id, p.key, p.last);
|
|
399
|
+
} else if (c.when === "idle") {
|
|
400
|
+
for (const p of collect(user.id, c.idleMinutes * 60_000, now)) await curateBatch(user.id, [p], { notify: true });
|
|
401
|
+
} else if (c.when === "daily") {
|
|
402
|
+
const today = localDate(now);
|
|
403
|
+
if (new Date(now).getHours() < c.dailyHour || readMeta(user.id).lastDaily === today) continue;
|
|
404
|
+
patchMeta(user.id, { lastDaily: today });
|
|
405
|
+
await curateBatch(user.id, collect(user.id, SETTLE_MS, now));
|
|
406
|
+
}
|
|
407
|
+
// manual:等用户点
|
|
408
|
+
} catch (err) {
|
|
409
|
+
console.error(`[记忆] 整理 ${user.id} 出错:`, err);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/** 用户点了「现在整理」:他所有没整理过的对话合起来整理一次(正在回复的那段除外) */
|
|
416
|
+
export function curateNow(userId: string, opts: { now?: number } = {}): Promise<BatchOutcome> {
|
|
417
|
+
return exclusive(async () => {
|
|
418
|
+
const memory = readMemory(userId);
|
|
419
|
+
if (!memory.enabled || memory.curate.when === "off") {
|
|
420
|
+
return { at: Date.now(), conversations: 0, added: [], updated: [], removed: 0, error: memory.enabled ? "整理已关闭" : "记忆已关闭" };
|
|
421
|
+
}
|
|
422
|
+
const pendings = collect(userId, 0, opts.now);
|
|
423
|
+
return curateBatch(userId, pendings);
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/** 还没整理的对话有几段(设置页显示「有 N 段对话等着整理」) */
|
|
428
|
+
export function pendingCount(userId: string): number {
|
|
429
|
+
return pendingConversations(userId, 0).filter((p) => !hub.conversation(p.key)?.hidden).length;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
export function startMemoryCurator(): void {
|
|
433
|
+
startHistoryCapture();
|
|
434
|
+
// 等 agent 起来、健康检查跑完再开始
|
|
435
|
+
setTimeout(() => void sweepOnce(), 60_000).unref();
|
|
436
|
+
setInterval(() => void sweepOnce(), SWEEP_MS).unref();
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function withTimeout<T>(p: Promise<T>, ms: number): Promise<T> {
|
|
440
|
+
let timer: NodeJS.Timeout | undefined;
|
|
441
|
+
return Promise.race([
|
|
442
|
+
p,
|
|
443
|
+
new Promise<never>((_, reject) => {
|
|
444
|
+
timer = setTimeout(() => reject(new Error("超时")), ms);
|
|
445
|
+
}),
|
|
446
|
+
]).finally(() => clearTimeout(timer));
|
|
447
|
+
}
|