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
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// 怎么拉起记忆工具的 MCP 服务(mcp/memory-server.ts):Claude 和 Codex 的配置共用这一份。
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { home } from "../home.js";
|
|
5
|
+
|
|
6
|
+
/** MCP 服务名。Claude 里工具全名是 mcp__<名>__remember,Codex 里按它自己的规则拼 */
|
|
7
|
+
export const MEMORY_MCP_NAME = "agent_loft_memory";
|
|
8
|
+
|
|
9
|
+
const serverPath = fileURLToPath(new URL("../mcp/memory-server.ts", import.meta.url));
|
|
10
|
+
|
|
11
|
+
/** tsx 的 ESM loader:源码是 TypeScript,子进程和主进程一样靠它直接跑 */
|
|
12
|
+
const tsxLoader = (() => {
|
|
13
|
+
const require = createRequire(import.meta.url);
|
|
14
|
+
// tsx/esm 的导出只有 import 条件,require.resolve 解不到;从包目录拼出来
|
|
15
|
+
const pkg = require.resolve("tsx/package.json");
|
|
16
|
+
return new URL("./dist/esm/index.mjs", `file://${pkg}`).href;
|
|
17
|
+
})();
|
|
18
|
+
|
|
19
|
+
export type StdioSpawn = { command: string; args: string[]; env: Record<string, string> };
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* via:日志里标一下是哪个 agent 在写(方便排查),不影响写给谁。
|
|
23
|
+
* search:给不给 search_past_chats;convKey:当前这条对话,搜索时跳过它(它本来就在上下文里)。
|
|
24
|
+
*/
|
|
25
|
+
export function memoryMcpSpawn(userId: string, via: string, opts: { search?: boolean; convKey?: string } = {}): StdioSpawn {
|
|
26
|
+
return {
|
|
27
|
+
command: process.execPath,
|
|
28
|
+
args: ["--import", tsxLoader, serverPath],
|
|
29
|
+
env: {
|
|
30
|
+
AGENT_LOFT_HOME: home,
|
|
31
|
+
AGENT_LOFT_MEMORY_USER: userId,
|
|
32
|
+
AGENT_LOFT_MEMORY_VIA: via,
|
|
33
|
+
...(opts.search ? { AGENT_LOFT_MEMORY_SEARCH: "1" } : {}),
|
|
34
|
+
...(opts.convKey ? { AGENT_LOFT_MEMORY_CONV: opts.convKey } : {}),
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
}
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
// 记忆:agent 对「这个人」的长期了解,像 ChatGPT 的记忆那样。
|
|
2
|
+
//
|
|
3
|
+
// 每个用户一份,存在 data/memory/<用户 id>.json:
|
|
4
|
+
// - about:用户自己写的「关于我」;
|
|
5
|
+
// - items:一条条记住的事,用户在设置里加的,或者 agent 在对话里用 remember 工具记下的。
|
|
6
|
+
// 开会话时拼成一段文字放进系统提示(见 memoryPrompt),Claude / Codex、Web / 飞书共用同一份。
|
|
7
|
+
//
|
|
8
|
+
// 归属跟着对话的主人走(见 memoryOwnerOf):别人的对话里绝不注入你的记忆,也不往你的记忆里写。
|
|
9
|
+
// 记忆隔离和多用户一样是「信任型」的:agent 都以同一个系统用户运行,工作模式下能读到这些文件。
|
|
10
|
+
//
|
|
11
|
+
// 不做内存缓存,每次都读文件:agent 的记忆工具跑在单独的进程里(见 mcp/memory-server.ts),
|
|
12
|
+
// 两边都会写,读文件最简单也不会读到过期的内容。文件很小。
|
|
13
|
+
import crypto from "node:crypto";
|
|
14
|
+
import fs from "node:fs";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
import { dataDir, readJson, writeJsonAtomic } from "../home.js";
|
|
17
|
+
|
|
18
|
+
export type MemoryItem = {
|
|
19
|
+
id: string;
|
|
20
|
+
text: string;
|
|
21
|
+
createdAt: number;
|
|
22
|
+
updatedAt: number;
|
|
23
|
+
/** 谁记的:用户在设置里加的、agent 在对话里记的、聊完后自动整理出来的(见 memory-curator.ts) */
|
|
24
|
+
source: "user" | "agent" | "auto";
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export type UserMemory = {
|
|
28
|
+
/** 关掉后不注入、不给记忆工具、不记聊天记录、不整理,已有的内容保留 */
|
|
29
|
+
enabled: boolean;
|
|
30
|
+
about: string;
|
|
31
|
+
items: MemoryItem[];
|
|
32
|
+
/** 什么时候、用谁整理记忆(见 memory-curator.ts) */
|
|
33
|
+
curate: CurateSettings;
|
|
34
|
+
/** 给 agent search_past_chats,能查以前的聊天 */
|
|
35
|
+
chatHistory: boolean;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* 整理记忆的时机:
|
|
40
|
+
* idle:每段对话停了 idleMinutes 分钟就整理这一段,改了什么在那条对话里说一声;
|
|
41
|
+
* daily:每天 dailyHour 点(服务器所在时区)把当天聊过的合起来整理一次,省额度,也更容易合并重复;
|
|
42
|
+
* manual:只在用户点「现在整理」时;
|
|
43
|
+
* off:不整理,聊过的也不攒着。
|
|
44
|
+
*/
|
|
45
|
+
export type CurateWhen = "idle" | "daily" | "manual" | "off";
|
|
46
|
+
|
|
47
|
+
export type CurateSettings = {
|
|
48
|
+
when: CurateWhen;
|
|
49
|
+
idleMinutes: number;
|
|
50
|
+
dailyHour: number;
|
|
51
|
+
/** auto:Claude 能用就用 Claude,否则 Codex */
|
|
52
|
+
agent: "auto" | "claude" | "codex";
|
|
53
|
+
/** 空 = 这个 agent 最省的默认(Claude 用 Haiku,Codex 用默认模型、思考程度 low) */
|
|
54
|
+
model: string;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export const DEFAULT_CURATE: CurateSettings = { when: "daily", idleMinutes: 30, dailyHour: 4, agent: "auto", model: "" };
|
|
58
|
+
|
|
59
|
+
function cleanCurate(raw: Partial<CurateSettings> | undefined, base: CurateSettings): CurateSettings {
|
|
60
|
+
const r = raw ?? {};
|
|
61
|
+
const when = (["idle", "daily", "manual", "off"] as const).find((w) => w === r.when) ?? base.when;
|
|
62
|
+
const idle = Math.round(Number(r.idleMinutes ?? base.idleMinutes));
|
|
63
|
+
const hour = Math.round(Number(r.dailyHour ?? base.dailyHour));
|
|
64
|
+
const agent = (["auto", "claude", "codex"] as const).find((a) => a === r.agent) ?? base.agent;
|
|
65
|
+
const model = typeof r.model === "string" ? r.model.trim().slice(0, 100) : base.model;
|
|
66
|
+
return {
|
|
67
|
+
when,
|
|
68
|
+
idleMinutes: Number.isFinite(idle) ? Math.max(5, Math.min(24 * 60, idle)) : base.idleMinutes,
|
|
69
|
+
dailyHour: Number.isFinite(hour) ? Math.max(0, Math.min(23, hour)) : base.dailyHour,
|
|
70
|
+
agent,
|
|
71
|
+
// 自动选 agent 时模型跟着 agent 走,不能指定
|
|
72
|
+
model: agent === "auto" ? "" : model,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export const MEMORY_LIMITS = {
|
|
77
|
+
about: 2000,
|
|
78
|
+
item: 500,
|
|
79
|
+
items: 200,
|
|
80
|
+
/** 注入系统提示的记忆条目最多这么多字(新的优先),控制每轮的固定开销 */
|
|
81
|
+
promptItems: 4000,
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const memoryDir = path.join(dataDir, "memory");
|
|
85
|
+
|
|
86
|
+
/** 用户 id 只会是 users.ts 生成的那种;校验一下,免得拼出目录外的路径 */
|
|
87
|
+
function fileOf(userId: string): string {
|
|
88
|
+
if (!/^[A-Za-z0-9_]{1,64}$/.test(userId)) throw new Error("用户 id 不合法");
|
|
89
|
+
return path.join(memoryDir, `${userId}.json`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function empty(): UserMemory {
|
|
93
|
+
return { enabled: true, about: "", items: [], curate: { ...DEFAULT_CURATE }, chatHistory: true };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function readMemory(userId: string): UserMemory {
|
|
97
|
+
const raw = readJson<Partial<UserMemory> & { autoExtract?: boolean }>(fileOf(userId), {});
|
|
98
|
+
const items = Array.isArray(raw.items)
|
|
99
|
+
? raw.items.filter((i): i is MemoryItem => !!i && typeof i.id === "string" && typeof i.text === "string")
|
|
100
|
+
: [];
|
|
101
|
+
return {
|
|
102
|
+
enabled: raw.enabled !== false,
|
|
103
|
+
// 早先的版本只有一个「自动整理」开关:关着的就是不整理
|
|
104
|
+
curate: cleanCurate(raw.curate, raw.autoExtract === false ? { ...DEFAULT_CURATE, when: "off" } : DEFAULT_CURATE),
|
|
105
|
+
chatHistory: raw.chatHistory !== false,
|
|
106
|
+
about: typeof raw.about === "string" ? raw.about : "",
|
|
107
|
+
items: items.map((i) => ({
|
|
108
|
+
id: i.id,
|
|
109
|
+
text: i.text,
|
|
110
|
+
createdAt: Number(i.createdAt) || 0,
|
|
111
|
+
updatedAt: Number(i.updatedAt) || Number(i.createdAt) || 0,
|
|
112
|
+
source: i.source === "user" || i.source === "auto" ? i.source : "agent",
|
|
113
|
+
})),
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function write(userId: string, memory: UserMemory): void {
|
|
118
|
+
fs.mkdirSync(memoryDir, { recursive: true, mode: 0o700 });
|
|
119
|
+
writeJsonAtomic(fileOf(userId), memory);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const sleepSync = (ms: number) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* 读—改—写之间加一把跨进程的锁:主进程(设置页、自动整理)和每个会话的记忆工具子进程都会写同一个文件,
|
|
126
|
+
* 不锁的话两边同时改,后写的那个会把先写的冲掉(新记的一条、刚保存的「关于我」悄悄没了)。
|
|
127
|
+
* 用 O_EXCL 建锁文件;持锁的进程被杀了留下的锁,5 秒后当作废。改动都是毫秒级的,等不到 3 秒就报错。
|
|
128
|
+
*/
|
|
129
|
+
function locked<T>(userId: string, fn: (memory: UserMemory) => T): T {
|
|
130
|
+
const lock = `${fileOf(userId)}.lock`;
|
|
131
|
+
fs.mkdirSync(memoryDir, { recursive: true, mode: 0o700 });
|
|
132
|
+
const deadline = Date.now() + 3000;
|
|
133
|
+
for (;;) {
|
|
134
|
+
try {
|
|
135
|
+
fs.writeFileSync(lock, String(process.pid), { flag: "wx", mode: 0o600 });
|
|
136
|
+
break;
|
|
137
|
+
} catch (err) {
|
|
138
|
+
if ((err as NodeJS.ErrnoException).code !== "EEXIST") throw err;
|
|
139
|
+
try {
|
|
140
|
+
if (Date.now() - fs.statSync(lock).mtimeMs > 5000) {
|
|
141
|
+
fs.rmSync(lock, { force: true });
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
} catch {
|
|
145
|
+
continue; // 刚好被释放了
|
|
146
|
+
}
|
|
147
|
+
if (Date.now() > deadline) throw new Error("记忆文件正被别处修改,稍后再试");
|
|
148
|
+
sleepSync(15);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
try {
|
|
152
|
+
return fn(readMemory(userId));
|
|
153
|
+
} finally {
|
|
154
|
+
fs.rmSync(lock, { force: true });
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function cleanText(text: unknown, max: number, what: string): string {
|
|
159
|
+
const t = typeof text === "string" ? text.trim() : "";
|
|
160
|
+
if (t.length > max) throw new Error(`${what}最多 ${max} 个字`);
|
|
161
|
+
return t;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const normalize = (t: string) => t.replace(/\s+/g, " ").trim().toLowerCase();
|
|
165
|
+
|
|
166
|
+
function newItemId(): string {
|
|
167
|
+
return `m_${crypto.randomBytes(3).toString("hex")}`;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function setMemoryOptions(
|
|
171
|
+
userId: string,
|
|
172
|
+
patch: { enabled?: boolean; about?: string; curate?: Partial<CurateSettings>; chatHistory?: boolean },
|
|
173
|
+
): UserMemory {
|
|
174
|
+
// 先校验再上锁:内容不合规直接报错,不白占锁
|
|
175
|
+
const about = patch.about !== undefined ? cleanText(patch.about, MEMORY_LIMITS.about, "「关于我」") : undefined;
|
|
176
|
+
return locked(userId, (memory) => {
|
|
177
|
+
if (typeof patch.enabled === "boolean") memory.enabled = patch.enabled;
|
|
178
|
+
if (patch.curate) memory.curate = cleanCurate(patch.curate, memory.curate);
|
|
179
|
+
if (typeof patch.chatHistory === "boolean") memory.chatHistory = patch.chatHistory;
|
|
180
|
+
if (about !== undefined) memory.about = about;
|
|
181
|
+
write(userId, memory);
|
|
182
|
+
return memory;
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export type AddResult = { item: MemoryItem; created: boolean; evicted?: MemoryItem };
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* 记一条。内容一样的(忽略空白和大小写)不重复记,只更新时间(created: false)。
|
|
190
|
+
* 满了就挤掉最久没更新的那条不是用户自己加的(agent 记的、自动整理的),挤掉的那条在 evicted 里;
|
|
191
|
+
* 全是用户自己加的就报错。
|
|
192
|
+
*/
|
|
193
|
+
export function addMemoryEntry(userId: string, text: string, source: MemoryItem["source"]): AddResult {
|
|
194
|
+
const t = cleanText(text, MEMORY_LIMITS.item, "一条记忆");
|
|
195
|
+
if (!t) throw new Error("记忆内容不能为空");
|
|
196
|
+
return locked(userId, (memory) => {
|
|
197
|
+
const now = Date.now();
|
|
198
|
+
const same = memory.items.find((i) => normalize(i.text) === normalize(t));
|
|
199
|
+
if (same) {
|
|
200
|
+
same.updatedAt = now;
|
|
201
|
+
write(userId, memory);
|
|
202
|
+
return { item: same, created: false };
|
|
203
|
+
}
|
|
204
|
+
let evicted: MemoryItem | undefined;
|
|
205
|
+
if (memory.items.length >= MEMORY_LIMITS.items) {
|
|
206
|
+
evicted = memory.items.filter((i) => i.source !== "user").sort((a, b) => a.updatedAt - b.updatedAt)[0];
|
|
207
|
+
if (!evicted) throw new Error(`记忆已经有 ${MEMORY_LIMITS.items} 条了,先删掉一些`);
|
|
208
|
+
memory.items = memory.items.filter((i) => i !== evicted);
|
|
209
|
+
}
|
|
210
|
+
const item: MemoryItem = { id: newItemId(), text: t, createdAt: now, updatedAt: now, source };
|
|
211
|
+
memory.items.push(item);
|
|
212
|
+
write(userId, memory);
|
|
213
|
+
return { item, created: true, ...(evicted ? { evicted } : {}) };
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** 记一条,只要结果 */
|
|
218
|
+
export function addMemoryItem(userId: string, text: string, source: MemoryItem["source"]): MemoryItem {
|
|
219
|
+
return addMemoryEntry(userId, text, source).item;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export function updateMemoryItem(userId: string, id: string, text: string): MemoryItem {
|
|
223
|
+
const t = cleanText(text, MEMORY_LIMITS.item, "一条记忆");
|
|
224
|
+
if (!t) throw new Error("记忆内容不能为空");
|
|
225
|
+
return locked(userId, (memory) => {
|
|
226
|
+
const item = memory.items.find((i) => i.id === id);
|
|
227
|
+
if (!item) throw new Error("这条记忆不存在");
|
|
228
|
+
item.text = t;
|
|
229
|
+
item.updatedAt = Date.now();
|
|
230
|
+
write(userId, memory);
|
|
231
|
+
return item;
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** 删一条。返回删掉的那条,没有就返回 undefined */
|
|
236
|
+
export function removeMemoryItem(userId: string, id: string): MemoryItem | undefined {
|
|
237
|
+
return locked(userId, (memory) => {
|
|
238
|
+
const item = memory.items.find((i) => i.id === id);
|
|
239
|
+
if (!item) return undefined;
|
|
240
|
+
memory.items = memory.items.filter((i) => i !== item);
|
|
241
|
+
write(userId, memory);
|
|
242
|
+
return item;
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* 清空:「关于我」和所有条目都删掉,开关和整理设置保留 —— 关着记忆的人清空之后不能变成开着。
|
|
248
|
+
* 管理员清空成员的记忆也走这里。
|
|
249
|
+
*/
|
|
250
|
+
export function clearMemory(userId: string): void {
|
|
251
|
+
locked(userId, (memory) => write(userId, { ...memory, about: "", items: [] }));
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** 删用户时:整个文件删掉 */
|
|
255
|
+
export function deleteMemory(userId: string): void {
|
|
256
|
+
fs.rmSync(fileOf(userId), { force: true });
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// ---------- 注入系统提示 ----------
|
|
260
|
+
|
|
261
|
+
/** 本地日期 YYYY-MM-DD(记忆条目、整理、搜索结果都用它) */
|
|
262
|
+
export function localDate(ts: number): string {
|
|
263
|
+
const d = new Date(ts);
|
|
264
|
+
const pad = (n: number) => String(n).padStart(2, "0");
|
|
265
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** 系统提示里怎么称呼记忆工具:两个 agent 给 MCP 工具起的全名不一样,只说短名 */
|
|
269
|
+
export const MEMORY_TOOL_NAMES = { remember: "remember", forget: "forget", search: "search_past_chats" } as const;
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* 拼进系统提示的一段文字。记忆关着就返回 undefined(也不该给记忆工具)。
|
|
273
|
+
* 条目按最近更新排,超出字数预算的旧条目不放进来(它们还在,只是这次没带上)。
|
|
274
|
+
*/
|
|
275
|
+
export function memoryPrompt(userId: string): string | undefined {
|
|
276
|
+
const memory = readMemory(userId);
|
|
277
|
+
if (!memory.enabled) return undefined;
|
|
278
|
+
const lines: string[] = [
|
|
279
|
+
"# 关于这位用户",
|
|
280
|
+
"",
|
|
281
|
+
"下面是你对这位用户的长期了解:有的是他自己写的,有的是之前的对话里记下的。自然地用上,让回答更贴合他;不要逐条复述,也不要每次都提「我记得」。和当前对话冲突时以当前对话为准。",
|
|
282
|
+
];
|
|
283
|
+
if (memory.about.trim()) lines.push("", "## 他的自我介绍", "", memory.about.trim());
|
|
284
|
+
const sorted = [...memory.items].sort((a, b) => b.updatedAt - a.updatedAt);
|
|
285
|
+
const kept: MemoryItem[] = [];
|
|
286
|
+
let budget = MEMORY_LIMITS.promptItems;
|
|
287
|
+
for (const item of sorted) {
|
|
288
|
+
if (item.text.length > budget) break;
|
|
289
|
+
budget -= item.text.length;
|
|
290
|
+
kept.push(item);
|
|
291
|
+
}
|
|
292
|
+
if (kept.length > 0) {
|
|
293
|
+
lines.push("", "## 记住的事", "");
|
|
294
|
+
for (const item of kept) lines.push(`- [${item.id}] ${localDate(item.updatedAt)}:${item.text}`);
|
|
295
|
+
if (kept.length < sorted.length) lines.push(`- (还有 ${sorted.length - kept.length} 条较早的记忆这次没有带上)`);
|
|
296
|
+
}
|
|
297
|
+
lines.push(
|
|
298
|
+
"",
|
|
299
|
+
"## 记忆工具",
|
|
300
|
+
"",
|
|
301
|
+
`- 用户让你记住什么,或者你发现了以后长期有用的信息(身份、偏好、习惯、长期在做的事、做过的重要决定),用 ${MEMORY_TOOL_NAMES.remember} 记一条:一句话、写清主语和时间背景。记完在回复里顺口说一句「已记住:…」。`,
|
|
302
|
+
`- 记忆过时了、记错了,或者用户让你忘掉,用 ${MEMORY_TOOL_NAMES.forget}(给上面方括号里的 id)。更新一件事就先 forget 旧的再 remember 新的。`,
|
|
303
|
+
"- 不要记密码、密钥、验证码、证件号这类敏感信息,也不要记一次性的琐事和当前任务的细节。",
|
|
304
|
+
...(memory.chatHistory
|
|
305
|
+
? [
|
|
306
|
+
`- 用户提到以前聊过的事(「上次说的那个……」「我之前问过……」),或者你需要他以前说过的细节,用 ${MEMORY_TOOL_NAMES.search} 搜他以前的聊天(关键词搜索,换几个说法多搜几次)。`,
|
|
307
|
+
]
|
|
308
|
+
: []),
|
|
309
|
+
);
|
|
310
|
+
return lines.join("\n");
|
|
311
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// 「这条对话是谁的」在个人数据(记忆、飞书绑定)上的落点。
|
|
2
|
+
import { deleteMemory, memoryPrompt, readMemory } from "./memory.js";
|
|
3
|
+
import { purgeHistory } from "./chat-history.js";
|
|
4
|
+
import { unlinkUser } from "./feishu-links.js";
|
|
5
|
+
import type { Conversation } from "./store.js";
|
|
6
|
+
import { getUser, legacyOwnerId } from "./users.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* 这条对话该用谁的记忆。比会话列表的归属规则(users.ts 的 ownedBy)更严:
|
|
10
|
+
* - 有主人的,就是主人 —— 主人被删了、停用了,谁的都不用(不像会话那样转给管理员:
|
|
11
|
+
* 别人的个人档案混进管理员的记忆里,agent 会突然以为自己在跟另一个人说话);
|
|
12
|
+
* - 升级前就有的 Web 会话没记主人,算迁移出来的那个管理员的(和推送订阅同一个规则);
|
|
13
|
+
* - 飞书群聊、没绑定账号的飞书私聊:没有主人,不用记忆。群里不止一个人,没绑定的人身份不明。
|
|
14
|
+
*/
|
|
15
|
+
export function memoryOwnerOf(conv: Pick<Conversation, "owner" | "channel">): string | undefined {
|
|
16
|
+
if (conv.owner) {
|
|
17
|
+
const user = getUser(conv.owner);
|
|
18
|
+
return user && !user.disabled ? user.id : undefined;
|
|
19
|
+
}
|
|
20
|
+
return conv.channel === "web" ? legacyOwnerId() : undefined;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** 开会话时交给 agent 的记忆(SessionOptions.memory)。没主人或者主人关了记忆就是 undefined */
|
|
24
|
+
export function sessionMemory(
|
|
25
|
+
conv: Pick<Conversation, "owner" | "channel"> & { key?: string },
|
|
26
|
+
): { userId: string; prompt: string; search?: boolean; convKey?: string } | undefined {
|
|
27
|
+
const userId = memoryOwnerOf(conv);
|
|
28
|
+
if (!userId) return undefined;
|
|
29
|
+
const prompt = memoryPrompt(userId);
|
|
30
|
+
if (!prompt) return undefined;
|
|
31
|
+
return {
|
|
32
|
+
userId,
|
|
33
|
+
prompt,
|
|
34
|
+
...(readMemory(userId).chatHistory ? { search: true } : {}),
|
|
35
|
+
...(conv.key ? { convKey: conv.key } : {}),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** 删用户时一起删掉的个人数据:记忆、聊天记录索引和飞书绑定 */
|
|
40
|
+
export function purgeUserData(userId: string): void {
|
|
41
|
+
deleteMemory(userId);
|
|
42
|
+
purgeHistory(userId);
|
|
43
|
+
unlinkUser(userId);
|
|
44
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// 定时发送:额度用完了、要等刷新才能接着干活时,先把下一条消息排好,到点由服务端替用户发出去。
|
|
2
|
+
//
|
|
3
|
+
// 放在服务端而不是页面里:到点时浏览器可能早关了。落盘在 data/scheduled.json,重启后接着等;
|
|
4
|
+
// 服务停着的那段时间里到点的,启动后立刻补发。只支持纯文字(附件是临时上传的,放不了那么久)。
|
|
5
|
+
import crypto from "node:crypto";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { dataDir, readJson, writeJsonAtomic } from "../home.js";
|
|
8
|
+
|
|
9
|
+
export type ScheduledMessage = {
|
|
10
|
+
id: string;
|
|
11
|
+
key: string;
|
|
12
|
+
text: string;
|
|
13
|
+
/** 什么时候发(毫秒时间戳) */
|
|
14
|
+
at: number;
|
|
15
|
+
createdAt: number;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/** 每条对话最多排几条 */
|
|
19
|
+
export const SCHEDULED_MAX = 20;
|
|
20
|
+
|
|
21
|
+
const storePath = path.join(dataDir, "scheduled.json");
|
|
22
|
+
let cache: ScheduledMessage[] | null = null;
|
|
23
|
+
let timer: NodeJS.Timeout | null = null;
|
|
24
|
+
let fire: ((m: ScheduledMessage) => void) | null = null;
|
|
25
|
+
|
|
26
|
+
function load(): ScheduledMessage[] {
|
|
27
|
+
cache ??= readJson<ScheduledMessage[]>(storePath, []).filter((m) => m && typeof m.text === "string" && Number.isFinite(m.at));
|
|
28
|
+
return cache;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function save(): void {
|
|
32
|
+
writeJsonAtomic(storePath, load());
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function listScheduled(key: string): ScheduledMessage[] {
|
|
36
|
+
return load()
|
|
37
|
+
.filter((m) => m.key === key)
|
|
38
|
+
.sort((a, b) => a.at - b.at);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function addScheduled(key: string, text: string, at: number): ScheduledMessage {
|
|
42
|
+
if (!text.trim()) throw new Error("定时发送的内容不能为空");
|
|
43
|
+
if (!Number.isFinite(at)) throw new Error("时间不对");
|
|
44
|
+
if (listScheduled(key).length >= SCHEDULED_MAX) throw new Error(`这条对话最多排 ${SCHEDULED_MAX} 条定时消息`);
|
|
45
|
+
const item: ScheduledMessage = { id: crypto.randomBytes(6).toString("hex"), key, text, at, createdAt: Date.now() };
|
|
46
|
+
load().push(item);
|
|
47
|
+
save();
|
|
48
|
+
arm();
|
|
49
|
+
return item;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function removeScheduled(key: string, id: string): boolean {
|
|
53
|
+
const list = load();
|
|
54
|
+
const i = list.findIndex((m) => m.id === id && m.key === key);
|
|
55
|
+
if (i < 0) return false;
|
|
56
|
+
list.splice(i, 1);
|
|
57
|
+
save();
|
|
58
|
+
arm();
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** 对话被删 / 换了主人之类:把它排着的全清掉 */
|
|
63
|
+
export function clearScheduled(key: string): void {
|
|
64
|
+
const list = load();
|
|
65
|
+
const kept = list.filter((m) => m.key !== key);
|
|
66
|
+
if (kept.length === list.length) return;
|
|
67
|
+
cache = kept;
|
|
68
|
+
save();
|
|
69
|
+
arm();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** 定时器只挂最近的那一条;setTimeout 最长约 24.8 天,更远的先睡到上限再重新算 */
|
|
73
|
+
function arm(): void {
|
|
74
|
+
if (timer) clearTimeout(timer);
|
|
75
|
+
timer = null;
|
|
76
|
+
if (!fire) return;
|
|
77
|
+
const next = load().reduce<number | null>((min, m) => (min === null || m.at < min ? m.at : min), null);
|
|
78
|
+
if (next === null) return;
|
|
79
|
+
const delay = Math.min(Math.max(next - Date.now(), 0), 2 ** 31 - 1);
|
|
80
|
+
timer = setTimeout(tick, delay);
|
|
81
|
+
timer.unref();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function tick(): void {
|
|
85
|
+
timer = null;
|
|
86
|
+
const now = Date.now();
|
|
87
|
+
const list = load();
|
|
88
|
+
const due = list.filter((m) => m.at <= now).sort((a, b) => a.at - b.at);
|
|
89
|
+
if (due.length) {
|
|
90
|
+
cache = list.filter((m) => m.at > now);
|
|
91
|
+
save();
|
|
92
|
+
for (const m of due) {
|
|
93
|
+
try {
|
|
94
|
+
fire?.(m);
|
|
95
|
+
} catch (err) {
|
|
96
|
+
console.error(`[定时发送] ${m.key} 发送失败:`, err instanceof Error ? err.message : err);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
arm();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** 启动调度。onFire 负责真正把消息发出去(由 main.ts 接到 hub 上,这里不依赖 hub) */
|
|
104
|
+
export function startScheduler(onFire: (m: ScheduledMessage) => void): void {
|
|
105
|
+
fire = onFire;
|
|
106
|
+
arm();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function stopScheduler(): void {
|
|
110
|
+
fire = null;
|
|
111
|
+
if (timer) clearTimeout(timer);
|
|
112
|
+
timer = null;
|
|
113
|
+
}
|
package/src/core/store.ts
CHANGED
|
@@ -19,6 +19,11 @@ export type AgentBinding = {
|
|
|
19
19
|
* 拿到不同于它的新会话 id 后清掉。
|
|
20
20
|
*/
|
|
21
21
|
forkFrom?: string;
|
|
22
|
+
/**
|
|
23
|
+
* 上次告诉这个 agent 会话的是哪种模式(没有 = 工作模式,agent 原装的样子)。和对话现在的模式不一样,
|
|
24
|
+
* 就要在下一条消息里说一声(Codex 换不了系统提示,见 core/chat-mode.ts)。
|
|
25
|
+
*/
|
|
26
|
+
announcedMode?: "chat" | "work";
|
|
22
27
|
};
|
|
23
28
|
|
|
24
29
|
export type Conversation = {
|
|
@@ -48,10 +53,12 @@ export type Conversation = {
|
|
|
48
53
|
/** 从哪条对话分叉出来的(对话 key),列表里据此显示关联 */
|
|
49
54
|
forkOf?: string;
|
|
50
55
|
/**
|
|
51
|
-
* 谁的对话(用户 id,见 core/users.ts)。Web 上新建 /
|
|
52
|
-
*
|
|
56
|
+
* 谁的对话(用户 id,见 core/users.ts)。Web 上新建 / 分叉时记下;飞书私聊在发消息的人绑定了
|
|
57
|
+
* 账号时记下(见 core/feishu-links.ts)。飞书群聊、没绑定的私聊和升级前就有的 Web 会话没有,归管理员。
|
|
53
58
|
*/
|
|
54
59
|
owner?: string;
|
|
60
|
+
/** chat:聊天模式(/chat)。没有就是工作模式。属于对话,跨 /new 和切 agent 保留 */
|
|
61
|
+
mode?: "chat";
|
|
55
62
|
};
|
|
56
63
|
|
|
57
64
|
type StoreShape = Record<string, Conversation>;
|
package/src/core/types.ts
CHANGED
|
@@ -172,8 +172,26 @@ export type SessionOptions = {
|
|
|
172
172
|
uploadsDir: string;
|
|
173
173
|
/** 工作目录内改文件不再询问(/autoedit)。Bash 等照常询问 */
|
|
174
174
|
autoEdit?: boolean;
|
|
175
|
+
/**
|
|
176
|
+
* chat:聊天模式 —— 换成精简的系统提示,只留联网搜索,不加载技能 / MCP / 插件,不能执行命令、
|
|
177
|
+
* 不能读写本地文件,每轮的固定开销小得多。不给就是 work(agent 原装的样子)。
|
|
178
|
+
*/
|
|
179
|
+
mode?: ConversationMode;
|
|
180
|
+
/** 这条对话主人的记忆:prompt 拼进系统提示,userId 交给记忆工具(记给谁由这里定死) */
|
|
181
|
+
memory?: {
|
|
182
|
+
userId: string;
|
|
183
|
+
prompt: string;
|
|
184
|
+
/** 给不给搜以前聊天的工具(用户在记忆设置里开着「参考以前的聊天」) */
|
|
185
|
+
search?: boolean;
|
|
186
|
+
/** 当前对话,搜索时跳过 */
|
|
187
|
+
convKey?: string;
|
|
188
|
+
};
|
|
189
|
+
/** 模式换过、这个 agent 会话还不知道:换不了系统提示的 agent(Codex)要在第一条消息里说一声 */
|
|
190
|
+
announceMode?: boolean;
|
|
175
191
|
};
|
|
176
192
|
|
|
193
|
+
export type ConversationMode = "chat" | "work";
|
|
194
|
+
|
|
177
195
|
export interface AgentSession {
|
|
178
196
|
readonly sessionId: string | undefined;
|
|
179
197
|
send(input: UserInput): void;
|
|
@@ -227,4 +245,20 @@ export interface AgentDriver {
|
|
|
227
245
|
/** 账号额度,侧栏显示用;读不到 / 没有订阅额度时返回 null */
|
|
228
246
|
quota?(): Promise<QuotaInfo | null>;
|
|
229
247
|
diagnose(err: unknown): Diagnosis;
|
|
248
|
+
/**
|
|
249
|
+
* 一次性的后台调用(聊完后整理记忆用):不留会话记录、不用任何工具,按 schema 返回 JSON。
|
|
250
|
+
* 用最便宜的模型。不支持的 agent 不实现。
|
|
251
|
+
*/
|
|
252
|
+
complete?(req: CompleteRequest): Promise<unknown>;
|
|
230
253
|
}
|
|
254
|
+
|
|
255
|
+
export type CompleteRequest = {
|
|
256
|
+
system: string;
|
|
257
|
+
prompt: string;
|
|
258
|
+
/** JSON Schema(顶层是 object,所有对象都 additionalProperties: false、字段全部 required —— 两家都能吃) */
|
|
259
|
+
schema: Record<string, unknown>;
|
|
260
|
+
/** 用哪个模型;不给就用这个 agent 最省的(Claude 用 Haiku,Codex 用默认模型、思考程度 low) */
|
|
261
|
+
model?: string;
|
|
262
|
+
/** 超时,毫秒 */
|
|
263
|
+
timeoutMs?: number;
|
|
264
|
+
};
|
package/src/core/uploads.ts
CHANGED
|
@@ -242,10 +242,14 @@ export function uploadKeyOf(file: string): string | undefined {
|
|
|
242
242
|
}
|
|
243
243
|
}
|
|
244
244
|
|
|
245
|
-
/**
|
|
245
|
+
/**
|
|
246
|
+
* 附件清单的文字说明,两个 agent 都用它告诉模型文件在哪。
|
|
247
|
+
* chat:聊天模式,agent 没有读文件的工具 —— 没能放进消息里的附件要说清楚读不了。
|
|
248
|
+
*/
|
|
246
249
|
export function describeAttachments(
|
|
247
250
|
saved: SavedAttachment[],
|
|
248
251
|
inlined: (a: SavedAttachment) => boolean,
|
|
252
|
+
chat = false,
|
|
249
253
|
): string {
|
|
250
254
|
const counters: Partial<Record<AttachmentKind, number>> = {};
|
|
251
255
|
const lines = saved.map((a) => {
|
|
@@ -253,9 +257,58 @@ export function describeAttachments(
|
|
|
253
257
|
const details = [
|
|
254
258
|
formatSize(a.size),
|
|
255
259
|
...(a.durationMs ? [`时长 ${Math.round(a.durationMs / 1000)} 秒`] : []),
|
|
256
|
-
...(inlined(a) ? ["内容已附在本消息中"] : []),
|
|
260
|
+
...(inlined(a) ? ["内容已附在本消息中"] : chat ? ["聊天模式下读不了这个文件"] : []),
|
|
257
261
|
];
|
|
258
262
|
return `- ${KIND_LABEL[a.kind]}${n}:${a.path}(${details.join(",")})`;
|
|
259
263
|
});
|
|
260
|
-
|
|
264
|
+
const head = chat
|
|
265
|
+
? "[用户发来了以下附件。现在是聊天模式,没有读文件的工具:读不了的附件请告诉用户发 /work 切到工作模式再处理]"
|
|
266
|
+
: "[用户发来了以下附件,已保存到本地,需要时可以直接读取或处理这些文件]";
|
|
267
|
+
return [head, ...lines].join("\n");
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// ---------- 聊天模式:文本附件直接放进消息 ----------
|
|
271
|
+
//
|
|
272
|
+
// 聊天模式不给 agent 读本地文件的工具(它能联网,能读文件就能被网页里的提示注入骗着把文件内容带出去),
|
|
273
|
+
// 所以文本类的附件由我们读出来,跟着消息一起发过去。
|
|
274
|
+
|
|
275
|
+
const TEXT_EXT =
|
|
276
|
+
/\.(txt|md|markdown|csv|tsv|json|jsonl|log|ya?ml|toml|ini|conf|cfg|xml|html?|css|js|mjs|cjs|ts|tsx|jsx|py|rb|go|rs|java|kt|swift|c|h|cc|cpp|hpp|cs|php|sh|bash|zsh|ps1|sql|srt|vtt|tex)$/i;
|
|
277
|
+
const INLINE_TEXT_MAX = 200 * 1024;
|
|
278
|
+
|
|
279
|
+
/** 能直接放进消息的文本附件,读出内容;不是文本、太大、读不了(比如已被清理)的返回 undefined,不抛错 */
|
|
280
|
+
export function readTextAttachment(a: SavedAttachment): string | undefined {
|
|
281
|
+
if (a.kind !== "file" || a.size > INLINE_TEXT_MAX) return undefined;
|
|
282
|
+
if (!a.mediaType?.startsWith("text/") && !TEXT_EXT.test(a.name)) return undefined;
|
|
283
|
+
let buf: Buffer;
|
|
284
|
+
try {
|
|
285
|
+
buf = fs.readFileSync(a.path);
|
|
286
|
+
} catch {
|
|
287
|
+
return undefined;
|
|
288
|
+
}
|
|
289
|
+
// 有 NUL 字节的多半是二进制,扩展名骗人
|
|
290
|
+
if (buf.includes(0)) return undefined;
|
|
291
|
+
return buf.toString("utf8");
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** 一条消息里所有能放进来的文本附件:路径 → 内容。每个文件只读一次 */
|
|
295
|
+
export function readTextAttachments(saved: SavedAttachment[]): Map<string, string> {
|
|
296
|
+
const out = new Map<string, string>();
|
|
297
|
+
for (const a of saved) {
|
|
298
|
+
const content = readTextAttachment(a);
|
|
299
|
+
if (content !== undefined) out.set(a.path, content);
|
|
300
|
+
}
|
|
301
|
+
return out;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** 文本附件的内容,接在附件清单后面。没有就返回空串 */
|
|
305
|
+
export function inlineTextBlocks(saved: SavedAttachment[], texts: Map<string, string>): string {
|
|
306
|
+
const parts: string[] = [];
|
|
307
|
+
for (const a of saved) {
|
|
308
|
+
const content = texts.get(a.path);
|
|
309
|
+
if (content === undefined) continue;
|
|
310
|
+
const fence = content.includes("```") ? "~~~~" : "```";
|
|
311
|
+
parts.push(`附件「${a.name}」的内容:\n${fence}\n${content}\n${fence}`);
|
|
312
|
+
}
|
|
313
|
+
return parts.join("\n\n");
|
|
261
314
|
}
|