agent-loft 0.2.24 → 0.2.25
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 +22 -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 +183 -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 +131 -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/store.ts +9 -2
- package/src/core/types.ts +34 -0
- package/src/core/uploads.ts +56 -3
- package/src/main.ts +4 -0
- package/src/mcp/memory-server.ts +168 -0
- package/web/app.js +441 -5
- package/web/style.css +21 -0
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
// 聊天记录索引:给「参考以前的聊天」(search_past_chats)和「聊完自动整理记忆」用。
|
|
2
|
+
//
|
|
3
|
+
// 每个用户一份,data/history/<用户 id>.jsonl,一行一句(用户说的 / agent 的最终回复)。
|
|
4
|
+
// 只收有主人的对话(见 personal.ts 的 memoryOwnerOf),按主人分开存 —— 搜索只在自己的里面搜。
|
|
5
|
+
// 来源两个:
|
|
6
|
+
// - 实时:hub 的事件流(见 startHistoryCapture),从现在起说的话;
|
|
7
|
+
// - 回填:第一次用时把已有对话从 agent 的记录里读一遍(见 memory-curator.ts),只做一次。
|
|
8
|
+
//
|
|
9
|
+
// 为什么不用 SQLite 全文索引:FTS5 的 trigram 分词搜不到两个字的中文词(「豆豆」),
|
|
10
|
+
// 个人的聊天量直接扫文件就够快(几万句也是毫秒级),也省得 MCP 子进程每次打实验性 API 的警告。
|
|
11
|
+
import fs from "node:fs";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
import { dataDir, readJson, writeJsonAtomic } from "../home.js";
|
|
14
|
+
|
|
15
|
+
export type HistoryRecord = {
|
|
16
|
+
/** 对话 key */
|
|
17
|
+
k: string;
|
|
18
|
+
/** agent */
|
|
19
|
+
a: string;
|
|
20
|
+
/** u:用户说的;a:agent 的回复 */
|
|
21
|
+
r: "u" | "a";
|
|
22
|
+
t: string;
|
|
23
|
+
ts: number;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export type CurationLog = {
|
|
27
|
+
at: number;
|
|
28
|
+
/** 整理了几段对话 */
|
|
29
|
+
conversations: number;
|
|
30
|
+
added: string[];
|
|
31
|
+
updated: string[];
|
|
32
|
+
removed: number;
|
|
33
|
+
/** 用的哪个 agent / 模型 */
|
|
34
|
+
by?: string;
|
|
35
|
+
error?: string;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export type Meta = {
|
|
39
|
+
/** 已有对话回填过了 */
|
|
40
|
+
backfilled?: boolean;
|
|
41
|
+
/** 每条对话整理记忆整理到了哪个时刻(这之后的话还没整理过) */
|
|
42
|
+
extracted: Record<string, number>;
|
|
43
|
+
/** 「每天一次」上次跑的是哪天(本地日期 YYYY-MM-DD) */
|
|
44
|
+
lastDaily?: string;
|
|
45
|
+
/** 上次整理的结果,设置页显示 */
|
|
46
|
+
lastCuration?: CurationLog;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const historyDir = path.join(dataDir, "history");
|
|
50
|
+
/** 一句话最多存这么多字:长篇代码、整页网页没必要全存 */
|
|
51
|
+
const TEXT_MAX = 4000;
|
|
52
|
+
|
|
53
|
+
function base(userId: string): string {
|
|
54
|
+
if (!/^[A-Za-z0-9_]{1,64}$/.test(userId)) throw new Error("用户 id 不合法");
|
|
55
|
+
return path.join(historyDir, userId);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function appendRecords(userId: string, records: HistoryRecord[]): void {
|
|
59
|
+
if (records.length === 0) return;
|
|
60
|
+
fs.mkdirSync(historyDir, { recursive: true, mode: 0o700 });
|
|
61
|
+
const lines = records.map((r) => JSON.stringify({ ...r, t: r.t.slice(0, TEXT_MAX) })).join("\n");
|
|
62
|
+
fs.appendFileSync(`${base(userId)}.jsonl`, `${lines}\n`, { mode: 0o600 });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function readRecords(userId: string): HistoryRecord[] {
|
|
66
|
+
let raw: string;
|
|
67
|
+
try {
|
|
68
|
+
raw = fs.readFileSync(`${base(userId)}.jsonl`, "utf8");
|
|
69
|
+
} catch {
|
|
70
|
+
return [];
|
|
71
|
+
}
|
|
72
|
+
const out: HistoryRecord[] = [];
|
|
73
|
+
for (const line of raw.split("\n")) {
|
|
74
|
+
if (!line) continue;
|
|
75
|
+
try {
|
|
76
|
+
const r = JSON.parse(line) as HistoryRecord;
|
|
77
|
+
if (typeof r.k === "string" && typeof r.t === "string" && (r.r === "u" || r.r === "a")) out.push(r);
|
|
78
|
+
} catch {
|
|
79
|
+
// 写到一半被打断的行:跳过
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function readMeta(userId: string): Meta {
|
|
86
|
+
const m = readJson<Partial<Meta>>(`${base(userId)}.meta.json`, {});
|
|
87
|
+
return { ...m, extracted: m.extracted && typeof m.extracted === "object" ? m.extracted : {} };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function patchMeta(userId: string, patch: Partial<Meta>): void {
|
|
91
|
+
writeMeta(userId, { ...readMeta(userId), ...patch });
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function writeMeta(userId: string, meta: Meta): void {
|
|
95
|
+
fs.mkdirSync(historyDir, { recursive: true, mode: 0o700 });
|
|
96
|
+
writeJsonAtomic(`${base(userId)}.meta.json`, meta);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function markExtracted(userId: string, key: string, upTo: number): void {
|
|
100
|
+
const meta = readMeta(userId);
|
|
101
|
+
meta.extracted[key] = Math.max(meta.extracted[key] ?? 0, upTo);
|
|
102
|
+
writeMeta(userId, meta);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** 删用户时:整个删掉 */
|
|
106
|
+
export function purgeHistory(userId: string): void {
|
|
107
|
+
fs.rmSync(`${base(userId)}.jsonl`, { force: true });
|
|
108
|
+
fs.rmSync(`${base(userId)}.meta.json`, { force: true });
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* 还没整理过的对话:最后一句已经过去 idleMs 了(聊完了),而且在上次整理之后又说过话。
|
|
113
|
+
* 返回每条对话上次整理之后的那些句子。
|
|
114
|
+
*/
|
|
115
|
+
export function pendingConversations(
|
|
116
|
+
userId: string,
|
|
117
|
+
idleMs: number,
|
|
118
|
+
now = Date.now(),
|
|
119
|
+
): { key: string; records: HistoryRecord[]; last: number }[] {
|
|
120
|
+
const meta = readMeta(userId);
|
|
121
|
+
const byKey = new Map<string, HistoryRecord[]>();
|
|
122
|
+
for (const r of readRecords(userId)) {
|
|
123
|
+
if (r.ts <= (meta.extracted[r.k] ?? 0)) continue;
|
|
124
|
+
let list = byKey.get(r.k);
|
|
125
|
+
if (!list) byKey.set(r.k, (list = []));
|
|
126
|
+
list.push(r);
|
|
127
|
+
}
|
|
128
|
+
const out: { key: string; records: HistoryRecord[]; last: number }[] = [];
|
|
129
|
+
for (const [key, records] of byKey) {
|
|
130
|
+
const last = Math.max(...records.map((r) => r.ts));
|
|
131
|
+
if (now - last >= idleMs) out.push({ key, records, last });
|
|
132
|
+
}
|
|
133
|
+
return out;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ---------- 搜索 ----------
|
|
137
|
+
|
|
138
|
+
/** 把查询拆成词:按空白和标点切开。中文不切词,长的中文词另外拆成两字一组做模糊匹配 */
|
|
139
|
+
function terms(query: string): string[] {
|
|
140
|
+
return query
|
|
141
|
+
.toLowerCase()
|
|
142
|
+
.split(/[\s,,。.!!??、;;::"'“”‘’()()[\]【】<>《》/\\|]+/)
|
|
143
|
+
.map((t) => t.trim())
|
|
144
|
+
.filter(Boolean);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const isCjk = (s: string) => /[㐀-鿿]/.test(s);
|
|
148
|
+
|
|
149
|
+
function bigrams(s: string): string[] {
|
|
150
|
+
const out: string[] = [];
|
|
151
|
+
for (let i = 0; i + 2 <= s.length; i++) out.push(s.slice(i, i + 2));
|
|
152
|
+
return out;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function occurrences(hay: string, needle: string, cap = 5): number {
|
|
156
|
+
let n = 0;
|
|
157
|
+
for (let i = hay.indexOf(needle); i >= 0 && n < cap; i = hay.indexOf(needle, i + needle.length)) n++;
|
|
158
|
+
return n;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** 一句话和查询的相关程度,0 是不相关 */
|
|
162
|
+
export function scoreText(text: string, query: string): number {
|
|
163
|
+
const hay = text.toLowerCase();
|
|
164
|
+
const ts = terms(query);
|
|
165
|
+
if (ts.length === 0) return 0;
|
|
166
|
+
let score = 0;
|
|
167
|
+
let hit = 0;
|
|
168
|
+
for (const term of ts) {
|
|
169
|
+
const n = occurrences(hay, term);
|
|
170
|
+
if (n > 0) {
|
|
171
|
+
hit++;
|
|
172
|
+
score += n * Math.min(term.length, 8);
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
// 长的中文没整个出现:看两字组合命中了多少(「软路由防污染」能搜到「软路由怎么防 DNS 污染」)
|
|
176
|
+
if (isCjk(term) && term.length >= 4) {
|
|
177
|
+
const grams = bigrams(term);
|
|
178
|
+
const found = grams.filter((g) => hay.includes(g)).length;
|
|
179
|
+
if (found / grams.length >= 0.5) {
|
|
180
|
+
hit += found / grams.length;
|
|
181
|
+
score += found;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
if (score === 0) return 0;
|
|
186
|
+
// 所有词都命中的排前面
|
|
187
|
+
return score * (hit >= ts.length ? 2 : 1);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** 截一段命中位置附近的文字 */
|
|
191
|
+
function snippet(text: string, query: string, max: number): string {
|
|
192
|
+
const flat = text.replace(/\s+/g, " ").trim();
|
|
193
|
+
if (flat.length <= max) return flat;
|
|
194
|
+
const lower = flat.toLowerCase();
|
|
195
|
+
const first = terms(query)
|
|
196
|
+
.map((t) => lower.indexOf(t))
|
|
197
|
+
.filter((i) => i >= 0)
|
|
198
|
+
.sort((a, b) => a - b)[0];
|
|
199
|
+
const start = Math.max(0, Math.min((first ?? 0) - Math.floor(max / 3), flat.length - max));
|
|
200
|
+
return `${start > 0 ? "…" : ""}${flat.slice(start, start + max)}${start + max < flat.length ? "…" : ""}`;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export type SearchHit = { key: string; ts: number; user?: string; assistant?: string; score: number };
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* 在这个用户的聊天记录里搜。结果是一问一答为单位,相关的在前(同样相关的新的在前)。
|
|
207
|
+
* skip:不要的对话(被删掉的、当前这条)。
|
|
208
|
+
*/
|
|
209
|
+
export function searchHistory(
|
|
210
|
+
userId: string,
|
|
211
|
+
query: string,
|
|
212
|
+
opts: { limit?: number; skip?: (key: string) => boolean; now?: number } = {},
|
|
213
|
+
): SearchHit[] {
|
|
214
|
+
const records = readRecords(userId);
|
|
215
|
+
const now = opts.now ?? Date.now();
|
|
216
|
+
const scored: { i: number; score: number }[] = [];
|
|
217
|
+
records.forEach((r, i) => {
|
|
218
|
+
if (opts.skip?.(r.k)) return;
|
|
219
|
+
const s = scoreText(r.t, query);
|
|
220
|
+
if (s === 0) return;
|
|
221
|
+
// 新的稍微靠前:一个月内的最多加 30%
|
|
222
|
+
const age = Math.max(0, now - r.ts) / (30 * 86400_000);
|
|
223
|
+
scored.push({ i, score: s * (1 + 0.3 * Math.exp(-age)) });
|
|
224
|
+
});
|
|
225
|
+
scored.sort((a, b) => b.score - a.score || records[b.i]!.ts - records[a.i]!.ts);
|
|
226
|
+
const out: SearchHit[] = [];
|
|
227
|
+
const used = new Set<number>();
|
|
228
|
+
const limit = opts.limit ?? 6;
|
|
229
|
+
for (const { i, score } of scored) {
|
|
230
|
+
if (out.length >= limit) break;
|
|
231
|
+
if (used.has(i)) continue;
|
|
232
|
+
const r = records[i]!;
|
|
233
|
+
// 配上同一条对话里紧挨着的另一半:问配答,答配问
|
|
234
|
+
const pairIndex = r.r === "u" ? i + 1 : i - 1;
|
|
235
|
+
const pair = records[pairIndex];
|
|
236
|
+
const partner = pair && pair.k === r.k && pair.r !== r.r ? pair : undefined;
|
|
237
|
+
used.add(i);
|
|
238
|
+
if (partner) used.add(pairIndex);
|
|
239
|
+
const user = r.r === "u" ? r : partner;
|
|
240
|
+
const assistant = r.r === "a" ? r : partner;
|
|
241
|
+
out.push({
|
|
242
|
+
key: r.k,
|
|
243
|
+
ts: r.ts,
|
|
244
|
+
...(user ? { user: snippet(user.t, query, 300) } : {}),
|
|
245
|
+
...(assistant ? { assistant: snippet(assistant.t, query, 600) } : {}),
|
|
246
|
+
score,
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
return out;
|
|
250
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// 聊天模式要告诉 agent 的话,以及怎么告诉。
|
|
2
|
+
//
|
|
3
|
+
// 两个 agent 的「换系统提示」能力不一样(都实测过):
|
|
4
|
+
// - Claude:系统提示每次拉起进程时整个给,换模式 = 带着新的系统提示 resume,干净利落。
|
|
5
|
+
// 聊天模式就用 chatSystemPrompt 换掉 Claude Code 原装的那一大段(约 1.6 万 token)。
|
|
6
|
+
// - Codex:thread 的基础指令一旦设了就钉在 thread 上(resume 时不传就沿用,传 null 也一样),
|
|
7
|
+
// 开发者指令只在 thread 开头写一次、resume 时给新的会被忽略。所以 Codex 不动它的指令,
|
|
8
|
+
// 模式和记忆用 contextBlock 附在「这个会话进程的第一条消息」后面 —— 它按时间顺序进历史,
|
|
9
|
+
// 模型以最新的为准。读历史、列会话时用 stripContext 把它剥掉。
|
|
10
|
+
//
|
|
11
|
+
// 日期写进去是因为模型不知道今天几号;它在一条会话里不变,不影响提示缓存。
|
|
12
|
+
|
|
13
|
+
function today(): string {
|
|
14
|
+
const d = new Date();
|
|
15
|
+
const pad = (n: number) => String(n).padStart(2, "0");
|
|
16
|
+
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
17
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}(${tz})`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** search:这个 agent 里联网搜索 / 读网页的工具怎么称呼 */
|
|
21
|
+
function chatRules(search: string): string[] {
|
|
22
|
+
return [
|
|
23
|
+
"- 用用户使用的语言回答,自然、直接、简洁;需要时用 Markdown 排版(列表、表格、代码块)。",
|
|
24
|
+
`- 涉及最新信息、新闻、价格、版本、事实核查时,先用${search}查,回答里附上来源链接;不要凭记忆编造。`,
|
|
25
|
+
"- 用户发来的图片、PDF 和文本文件内容会直接附在消息里。",
|
|
26
|
+
"- 现在是「聊天模式」:你不能执行命令,也不能读写这台机器上的文件。用户要你动手干活(写代码、改文件、跑命令、操作服务器)时,告诉他发 /work 切到工作模式,同一条对话会接着聊。",
|
|
27
|
+
];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Claude 聊天模式的整段系统提示 */
|
|
31
|
+
export function chatSystemPrompt(search: string, memory?: string): string {
|
|
32
|
+
const base = ["你是用户的私人 AI 助手,他通过 agent-loft 的网页或飞书和你聊天。", "", ...chatRules(search), "", `今天是 ${today()}。`].join("\n");
|
|
33
|
+
return memory ? `${base}\n\n${memory}` : base;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const OPEN = "<agent-loft-context>";
|
|
37
|
+
const CLOSE = "</agent-loft-context>";
|
|
38
|
+
|
|
39
|
+
/** Codex:附在会话进程第一条消息后面的背景(模式 + 记忆) */
|
|
40
|
+
export function contextBlock(mode: "chat" | "work", memory: string | undefined, search: string): string {
|
|
41
|
+
const head =
|
|
42
|
+
mode === "chat"
|
|
43
|
+
? ["当前是「聊天模式」,你作为用户的私人 AI 助手和他聊天:", ...chatRules(search)]
|
|
44
|
+
: ["当前是「工作模式」:可以执行命令、读写文件,按平常的方式干活。之前如果说过「聊天模式下不能做」,那个限制已经解除。"];
|
|
45
|
+
return [
|
|
46
|
+
OPEN,
|
|
47
|
+
"(这段是 agent-loft 自动附上的背景,不是用户写的。照着做,但不要在回复里提到它。)",
|
|
48
|
+
"",
|
|
49
|
+
...head,
|
|
50
|
+
...(memory ? ["", memory] : []),
|
|
51
|
+
CLOSE,
|
|
52
|
+
].join("\n");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** 剥掉 contextBlock(读历史、列会话标题时用) */
|
|
56
|
+
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();
|
|
62
|
+
}
|
|
@@ -22,6 +22,8 @@ export const BUILTIN_COMMANDS: BuiltinSpec[] = [
|
|
|
22
22
|
{ name: "stop", description: "中断当前执行" },
|
|
23
23
|
{ name: "model", args: "[名称]", description: "切换模型(不带参数弹选择)" },
|
|
24
24
|
{ name: "effort", args: "[程度]", description: "切换思考程度" },
|
|
25
|
+
{ name: "chat", description: "切到聊天模式:精简、只能联网搜索,每轮开销小很多" },
|
|
26
|
+
{ name: "work", description: "切到工作模式:agent 原装的全部能力" },
|
|
25
27
|
{ name: "status", description: "查看当前会话、模型、目录、剩余额度" },
|
|
26
28
|
{ name: "sessions", args: "[all]", description: "列出历史会话,点按钮恢复;加 all 列所有目录" },
|
|
27
29
|
{ name: "resume", args: "<会话ID>", description: "恢复指定会话" },
|
|
@@ -34,6 +36,7 @@ export const BUILTIN_COMMANDS: BuiltinSpec[] = [
|
|
|
34
36
|
{ name: "codex", description: "切换到 Codex", multiAgentOnly: true },
|
|
35
37
|
{ name: "web", description: "接上一条 Web 会话,在飞书里查看和回复", feishuOnly: true },
|
|
36
38
|
{ name: "detach", description: "不再接着 Web 会话,回到这个聊天自己的会话", feishuOnly: true },
|
|
39
|
+
{ name: "bind", args: "<绑定码>", description: "把这个飞书账号绑定到你的 agent-loft 用户(绑定码在 Web 的「设置 → 我的账号」里生成)", feishuOnly: true },
|
|
37
40
|
{ name: "help", description: "显示帮助" },
|
|
38
41
|
{ name: "approve", args: "<请求ID> allow-once|allow-always|allow-session|deny", description: "审批兜底(卡片失效时用)" },
|
|
39
42
|
];
|
package/src/core/commands.ts
CHANGED
|
@@ -18,6 +18,7 @@ export type ParsedCommand =
|
|
|
18
18
|
| { kind: "status" }
|
|
19
19
|
| { kind: "pwd" }
|
|
20
20
|
| { kind: "autoedit"; on?: boolean }
|
|
21
|
+
| { kind: "mode"; mode: "chat" | "work" }
|
|
21
22
|
| { kind: "help" }
|
|
22
23
|
/** 只发了一个「/」:列出可用命令 */
|
|
23
24
|
| { kind: "commands" }
|
|
@@ -69,6 +70,9 @@ export function parseCommand(raw: string): ParsedCommand {
|
|
|
69
70
|
if (["off", "关", "0", "false"].includes(v)) return { kind: "autoedit", on: false };
|
|
70
71
|
return { kind: "chat", text };
|
|
71
72
|
}
|
|
73
|
+
case "chat":
|
|
74
|
+
case "work":
|
|
75
|
+
return arg ? { kind: "chat", text } : { kind: "mode", mode: head.toLowerCase() as "chat" | "work" };
|
|
72
76
|
case "help":
|
|
73
77
|
return { kind: "help" };
|
|
74
78
|
case "approve": {
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// 飞书账号 ↔ agent-loft 用户的绑定。
|
|
2
|
+
//
|
|
3
|
+
// 绑定之后,这个飞书账号和机器人的私聊就是这个用户的个人会话:归他所有(Web 上别人看不到),
|
|
4
|
+
// 用他的记忆,/sessions 只列他的会话,/web 只能接上他自己的 Web 会话。
|
|
5
|
+
// 群聊不受影响:群里不止一个人,照旧是管理员的地盘,也不用记忆。
|
|
6
|
+
//
|
|
7
|
+
// 绑定必须由用户本人完成:在 Web 上(已登录)生成绑定码,再用飞书在私聊里发 /bind 绑定码。
|
|
8
|
+
// 两边都证明了身份,才算数。不提供「管理员直接指定」—— 那样管理员把自己的飞书绑到成员名下,
|
|
9
|
+
// 就能在飞书里读到成员的记忆。管理员只能解除绑定(只会让人失去访问,不会多出访问)。
|
|
10
|
+
//
|
|
11
|
+
// open_id 是按飞书应用分的:同一个人在不同机器人上 open_id 不同,所以按 (机器人, open_id) 记。
|
|
12
|
+
import crypto from "node:crypto";
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
import { dataDir, readJson, writeJsonAtomic } from "../home.js";
|
|
15
|
+
import { getUser, type User } from "./users.js";
|
|
16
|
+
|
|
17
|
+
export type FeishuLink = { botId: string; openId: string; userId: string; boundAt: number };
|
|
18
|
+
|
|
19
|
+
type Store = Record<string, Record<string, { userId: string; boundAt: number }>>;
|
|
20
|
+
|
|
21
|
+
const storePath = path.join(dataDir, "feishu-links.json");
|
|
22
|
+
|
|
23
|
+
function read(): Store {
|
|
24
|
+
return readJson<Store>(storePath, {});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const norm = (openId: string) => openId.trim().toLowerCase();
|
|
28
|
+
|
|
29
|
+
/** 这个飞书账号绑定的用户(还在、没被停用的)。没绑定返回 undefined */
|
|
30
|
+
export function linkedUser(botId: string, openId: string | undefined): User | undefined {
|
|
31
|
+
if (!openId) return undefined;
|
|
32
|
+
const userId = read()[botId]?.[norm(openId)]?.userId;
|
|
33
|
+
const user = getUser(userId);
|
|
34
|
+
return user && !user.disabled ? user : undefined;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function listLinks(userId?: string): FeishuLink[] {
|
|
38
|
+
return Object.entries(read()).flatMap(([botId, links]) =>
|
|
39
|
+
Object.entries(links)
|
|
40
|
+
.filter(([, l]) => !userId || l.userId === userId)
|
|
41
|
+
.map(([openId, l]) => ({ botId, openId, userId: l.userId, boundAt: l.boundAt })),
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** 解除一个绑定。onlyUser 给了的话,只能解除属于他的 */
|
|
46
|
+
export function unlink(botId: string, openId: string, onlyUser?: string): boolean {
|
|
47
|
+
const store = read();
|
|
48
|
+
const id = norm(openId);
|
|
49
|
+
const link = store[botId]?.[id];
|
|
50
|
+
if (!link || (onlyUser && link.userId !== onlyUser)) return false;
|
|
51
|
+
delete store[botId]![id];
|
|
52
|
+
if (Object.keys(store[botId]!).length === 0) delete store[botId];
|
|
53
|
+
writeJsonAtomic(storePath, store);
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** 解除这个用户的所有绑定(删用户、管理员解除时) */
|
|
58
|
+
export function unlinkUser(userId: string): number {
|
|
59
|
+
const store = read();
|
|
60
|
+
let n = 0;
|
|
61
|
+
for (const [botId, links] of Object.entries(store)) {
|
|
62
|
+
for (const [openId, l] of Object.entries(links)) {
|
|
63
|
+
if (l.userId !== userId) continue;
|
|
64
|
+
delete links[openId];
|
|
65
|
+
n++;
|
|
66
|
+
}
|
|
67
|
+
if (Object.keys(links).length === 0) delete store[botId];
|
|
68
|
+
}
|
|
69
|
+
if (n > 0) writeJsonAtomic(storePath, store);
|
|
70
|
+
return n;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ---------- 绑定码 ----------
|
|
74
|
+
//
|
|
75
|
+
// 只在内存里:十分钟有效,服务重启就作废(重新生成一个就行)。
|
|
76
|
+
|
|
77
|
+
const CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
|
78
|
+
const CODE_TTL_MS = 10 * 60 * 1000;
|
|
79
|
+
const codes = new Map<string, { userId: string; expiresAt: number }>();
|
|
80
|
+
|
|
81
|
+
function sweep(now = Date.now()): void {
|
|
82
|
+
for (const [code, c] of codes) if (c.expiresAt <= now) codes.delete(code);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** 给这个用户生成一个绑定码。之前没用掉的作废,同一时间每人只有一个 */
|
|
86
|
+
export function createLinkCode(userId: string): { code: string; expiresAt: number } {
|
|
87
|
+
sweep();
|
|
88
|
+
for (const [code, c] of codes) if (c.userId === userId) codes.delete(code);
|
|
89
|
+
const code = Array.from(crypto.randomBytes(8), (b) => CODE_ALPHABET[b % CODE_ALPHABET.length]).join("");
|
|
90
|
+
const expiresAt = Date.now() + CODE_TTL_MS;
|
|
91
|
+
codes.set(code, { userId, expiresAt });
|
|
92
|
+
return { code, expiresAt };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* 飞书里发了 /bind 绑定码:认领它,把 (机器人, open_id) 绑到生成这个码的用户上。
|
|
97
|
+
* 一个飞书账号只能绑一个用户,再绑就是换人(码证明了新主人的身份)。
|
|
98
|
+
*/
|
|
99
|
+
export function redeemLinkCode(code: string, botId: string, openId: string): User {
|
|
100
|
+
sweep();
|
|
101
|
+
const key = code.trim().toUpperCase();
|
|
102
|
+
const entry = codes.get(key);
|
|
103
|
+
if (!entry) throw new Error("绑定码不对或已过期。请在 Web 上(设置 → 我的账号)重新生成一个。");
|
|
104
|
+
const user = getUser(entry.userId);
|
|
105
|
+
if (!user || user.disabled) {
|
|
106
|
+
codes.delete(key);
|
|
107
|
+
throw new Error("生成这个绑定码的用户已经不存在或被停用了。");
|
|
108
|
+
}
|
|
109
|
+
codes.delete(key);
|
|
110
|
+
const store = read();
|
|
111
|
+
(store[botId] ??= {})[norm(openId)] = { userId: user.id, boundAt: Date.now() };
|
|
112
|
+
writeJsonAtomic(storePath, store);
|
|
113
|
+
return user;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** 测试用 */
|
|
117
|
+
export function resetLinkCodesForTest(): void {
|
|
118
|
+
codes.clear();
|
|
119
|
+
}
|
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,
|
|
@@ -130,6 +134,10 @@ export type ConversationInfo = {
|
|
|
130
134
|
forkOf?: string;
|
|
131
135
|
/** 当前 agent 能不能从某条回复处分叉成新对话 */
|
|
132
136
|
canFork?: boolean;
|
|
137
|
+
/** 聊天模式还是工作模式 */
|
|
138
|
+
mode: ConversationMode;
|
|
139
|
+
/** 这条对话有主人、会用上主人的记忆(主人自己关了记忆的话实际不会注入,见 personal.ts) */
|
|
140
|
+
personal?: boolean;
|
|
133
141
|
};
|
|
134
142
|
|
|
135
143
|
/** 普通成员点了会落盘的「总是允许」 */
|
|
@@ -433,6 +441,8 @@ class Hub extends EventEmitter<{ event: [EventEnvelope] }> {
|
|
|
433
441
|
...(conv.hidden ? { hidden: true } : {}),
|
|
434
442
|
...(conv.forkOf ? { forkOf: conv.forkOf } : {}),
|
|
435
443
|
...(this.drivers.get(conv.agent)?.forkAt ? { canFork: true } : {}),
|
|
444
|
+
mode: conv.mode ?? "work",
|
|
445
|
+
...(memoryOwnerOf(conv) ? { personal: true } : {}),
|
|
436
446
|
};
|
|
437
447
|
}
|
|
438
448
|
|
|
@@ -478,6 +488,19 @@ class Hub extends EventEmitter<{ event: [EventEnvelope] }> {
|
|
|
478
488
|
const holder: { session?: AgentSession } = {};
|
|
479
489
|
const alive = () => this.live.get(key)?.session === holder.session;
|
|
480
490
|
|
|
491
|
+
// 记忆在拉起会话这一刻读一次:会话里 agent 自己记下的,上下文里本来就有;
|
|
492
|
+
// 用户在设置里改了,memoryChanged() 会让空闲的会话下次说话时带着新的重新拉起
|
|
493
|
+
const memory = sessionMemory(conv);
|
|
494
|
+
const mode = conv.mode ?? "work";
|
|
495
|
+
// 续着的会话上次被告知的模式和现在不一样(新会话没有历史,算原装的工作模式)
|
|
496
|
+
const announceMode = (resumeId ? (binding.announcedMode ?? "work") : "work") !== mode;
|
|
497
|
+
// 真开始一轮了才算告诉过它(/status 之类也会拉起会话,但什么都没发过去)
|
|
498
|
+
let announced = false;
|
|
499
|
+
const markAnnounced = () => {
|
|
500
|
+
if (announced) return;
|
|
501
|
+
announced = true;
|
|
502
|
+
updateAgentBinding(key, agent, { announcedMode: mode === "work" ? undefined : mode });
|
|
503
|
+
};
|
|
481
504
|
const session = driver.createSession(
|
|
482
505
|
{
|
|
483
506
|
cwd: conv.cwd,
|
|
@@ -487,9 +510,16 @@ class Hub extends EventEmitter<{ event: [EventEnvelope] }> {
|
|
|
487
510
|
...(resumeId ? { resumeId } : {}),
|
|
488
511
|
...(fork ? { fork: true } : {}),
|
|
489
512
|
...(conv.autoEdit ? { autoEdit: true } : {}),
|
|
513
|
+
...(conv.mode === "chat" ? { mode: "chat" as const } : {}),
|
|
514
|
+
...(memory ? { memory } : {}),
|
|
515
|
+
...(announceMode ? { announceMode: true } : {}),
|
|
490
516
|
},
|
|
491
517
|
{
|
|
492
|
-
onTurnStart: () =>
|
|
518
|
+
onTurnStart: () => {
|
|
519
|
+
if (!alive()) return;
|
|
520
|
+
markAnnounced();
|
|
521
|
+
this.emitEvent(key, { type: "turn_start" });
|
|
522
|
+
},
|
|
493
523
|
onStep: (kind, text) => alive() && this.emitEvent(key, { type: "step", kind, text }),
|
|
494
524
|
onTextDelta: (text) => alive() && this.emitEvent(key, { type: "delta", text }),
|
|
495
525
|
onText: (text) => alive() && this.emitEvent(key, { type: "text", text }),
|
|
@@ -511,6 +541,7 @@ class Hub extends EventEmitter<{ event: [EventEnvelope] }> {
|
|
|
511
541
|
this.setInflight(key, (this.inflight.get(key) ?? 1) - 1);
|
|
512
542
|
this.refreshRuntimeOnce(key, liveId);
|
|
513
543
|
this.syncTitle(key, agent);
|
|
544
|
+
this.dropIfStale(key);
|
|
514
545
|
},
|
|
515
546
|
onError: (err) => {
|
|
516
547
|
if (!alive()) return;
|
|
@@ -529,6 +560,7 @@ class Hub extends EventEmitter<{ event: [EventEnvelope] }> {
|
|
|
529
560
|
this.emitEvent(key, { type: "error", message: diagnosis.chatMessage });
|
|
530
561
|
}
|
|
531
562
|
this.setInflight(key, (this.inflight.get(key) ?? 1) - 1);
|
|
563
|
+
this.dropIfStale(key);
|
|
532
564
|
},
|
|
533
565
|
onContext: (ctx) => {
|
|
534
566
|
if (!alive()) return;
|
|
@@ -979,6 +1011,91 @@ class Hub extends EventEmitter<{ event: [EventEnvelope] }> {
|
|
|
979
1011
|
return this.serial(key, () => this.setAutoEditNow(key, on));
|
|
980
1012
|
}
|
|
981
1013
|
|
|
1014
|
+
// ---------- 聊天 / 工作模式 ----------
|
|
1015
|
+
|
|
1016
|
+
/**
|
|
1017
|
+
* /chat、/work。同一条对话、同一个 agent 会话接着聊:丢掉活着的进程,下一条消息带着新模式的
|
|
1018
|
+
* 系统提示和工具 resume 回来(Claude、Codex 都实测过能接上上下文)。正在回复时不切:
|
|
1019
|
+
* 丢进程会把这一轮打断。
|
|
1020
|
+
*/
|
|
1021
|
+
private async setModeNow(key: string, mode: ConversationMode): Promise<string> {
|
|
1022
|
+
const conv = getConversation(key)!;
|
|
1023
|
+
if ((conv.mode ?? "work") === mode) {
|
|
1024
|
+
return mode === "chat" ? "已经是💬 聊天模式了。发 /work 切到工作模式。" : "已经是🛠 工作模式了。发 /chat 切到聊天模式。";
|
|
1025
|
+
}
|
|
1026
|
+
if (this.isBusy(key)) return "正在回复,等这一轮结束(或者先 /stop)再切换模式。";
|
|
1027
|
+
// 聊过的聊天对话切到工作模式:之后再回聊天模式就省不了了(Web 上点按钮时会先问,命令直接切,提醒一句)
|
|
1028
|
+
const hadChat = mode === "work" && !!agentBinding(conv).sessionId;
|
|
1029
|
+
await this.drop(key, "已切换模式");
|
|
1030
|
+
updateConversation(key, { mode: mode === "chat" ? "chat" : undefined });
|
|
1031
|
+
this.emitEvent(key, { type: "meta" });
|
|
1032
|
+
return mode === "chat"
|
|
1033
|
+
? "已切到💬 聊天模式:精简的系统提示,只能联网搜索,不执行命令、不读写文件,每轮的开销小很多。上下文接着之前的。要动手干活时发 /work。"
|
|
1034
|
+
: `已切到🛠 工作模式:${this.label(conv.agent)} 原装的全部能力(执行命令、改文件、技能、MCP)。上下文接着之前的。发 /chat 切回聊天模式。${
|
|
1035
|
+
hadChat ? "\n\n提示:工作模式会在这条会话里留下一大段背景,之后再切回聊天模式就省不了多少了。干完活想回到省 token 的聊天,发 /new 开个新会话再发 /chat。" : ""
|
|
1036
|
+
}`;
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
setMode(key: string, mode: ConversationMode): Promise<string> {
|
|
1040
|
+
return this.serial(key, () => this.setModeNow(key, mode));
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
// ---------- 记忆变了 / 主人变了 ----------
|
|
1044
|
+
|
|
1045
|
+
/** 记忆过期了的会话进程(Live.id):空闲下来就丢掉,下次说话带着新记忆重新拉起 */
|
|
1046
|
+
private staleLive = new Set<number>();
|
|
1047
|
+
|
|
1048
|
+
private dropIfStale(key: string): void {
|
|
1049
|
+
const live = this.live.get(key);
|
|
1050
|
+
if (!live || !this.staleLive.has(live.id) || this.isBusy(key)) return;
|
|
1051
|
+
this.staleLive.delete(live.id);
|
|
1052
|
+
void this.serial(key, async () => {
|
|
1053
|
+
// 排队期间可能已经换了进程、或者又开始说话了:那就留到下一轮结束
|
|
1054
|
+
const now = this.live.get(key);
|
|
1055
|
+
if (now?.id !== live.id) return;
|
|
1056
|
+
if (this.isBusy(key)) {
|
|
1057
|
+
this.staleLive.add(live.id);
|
|
1058
|
+
return;
|
|
1059
|
+
}
|
|
1060
|
+
await this.drop(key, "记忆已更新");
|
|
1061
|
+
});
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
/**
|
|
1065
|
+
* 用户在设置里改了记忆(关于我、增删条目、开关):他名下活着的会话都要换上新的。
|
|
1066
|
+
* agent 自己用 remember / forget 改的不走这里 —— 那些本来就在上下文里,没必要重启进程、白白丢掉提示缓存。
|
|
1067
|
+
*/
|
|
1068
|
+
memoryChanged(userId: string): void {
|
|
1069
|
+
for (const [key, live] of this.live) {
|
|
1070
|
+
const conv = getConversation(key);
|
|
1071
|
+
if (!conv || memoryOwnerOf(conv) !== userId) continue;
|
|
1072
|
+
this.staleLive.add(live.id);
|
|
1073
|
+
this.dropIfStale(key);
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
/**
|
|
1078
|
+
* 对话换主人(飞书私聊绑定 / 解绑 / 换绑账号时)。返回有没有变。
|
|
1079
|
+
* 旧主人的 agent 会话不能交给新主人接着用(上下文里是旧主人的话和记忆),绑定清掉、下条消息开新的;
|
|
1080
|
+
* 唯一的例外是从「没主(归管理员)」变成某个管理员自己:那些会话本来就是他能看的。
|
|
1081
|
+
* 同步改记录(紧接着的消息要按新主人处理),丢进程排进串行队列。
|
|
1082
|
+
*/
|
|
1083
|
+
reassignOwner(key: string, owner: string | undefined): boolean {
|
|
1084
|
+
const conv = getConversation(key);
|
|
1085
|
+
if (!conv || conv.owner === owner) return false;
|
|
1086
|
+
const keep = !conv.owner && !!owner && getUser(owner)?.role === "admin";
|
|
1087
|
+
const perAgent = keep
|
|
1088
|
+
? conv.perAgent
|
|
1089
|
+
: Object.fromEntries(Object.entries(conv.perAgent).map(([a, b]) => [a, { ...b, sessionId: undefined, forkFrom: undefined }]));
|
|
1090
|
+
updateConversation(key, { owner, perAgent });
|
|
1091
|
+
void this.switchOp(key, async (op) => {
|
|
1092
|
+
await this.drop(key, "对话换了主人");
|
|
1093
|
+
this.markSwitched(key, op.seq);
|
|
1094
|
+
this.emitEvent(key, { type: "meta" });
|
|
1095
|
+
});
|
|
1096
|
+
return true;
|
|
1097
|
+
}
|
|
1098
|
+
|
|
982
1099
|
/** 切 agent:各 agent 的会话各自保留,切回来接着聊。 */
|
|
983
1100
|
private async setAgentNow(key: string, agent: string, allowed: string[], op: SwitchOp): Promise<string> {
|
|
984
1101
|
const conv = getConversation(key)!;
|
|
@@ -1026,7 +1143,17 @@ class Hub extends EventEmitter<{ event: [EventEnvelope] }> {
|
|
|
1026
1143
|
const driver = this.requireDriver(conv.agent);
|
|
1027
1144
|
const runtime = await this.runtimeNow(key);
|
|
1028
1145
|
const extra = await withTimeout(driver.statusLines(), "读取状态").catch(() => [] as string[]);
|
|
1146
|
+
const memoryOwner = memoryOwnerOf(conv);
|
|
1147
|
+
const memoryLine = !memoryOwner
|
|
1148
|
+
? conv.channel === "feishu"
|
|
1149
|
+
? "记忆:不用(群聊,或者没绑定账号的私聊)"
|
|
1150
|
+
: "记忆:不用(这条对话没有主人)"
|
|
1151
|
+
: readMemory(memoryOwner).enabled
|
|
1152
|
+
? `记忆:开(${getUser(memoryOwner)?.name ?? memoryOwner} 的)`
|
|
1153
|
+
: "记忆:主人关掉了";
|
|
1029
1154
|
return [
|
|
1155
|
+
`模式:${conv.mode === "chat" ? "💬 聊天(/work 切到工作模式)" : "🛠 工作(/chat 切到聊天模式)"}`,
|
|
1156
|
+
memoryLine,
|
|
1030
1157
|
`Agent:${driver.label}`,
|
|
1031
1158
|
`会话:${agentBinding(conv).sessionId ?? "(尚未建立)"}`,
|
|
1032
1159
|
`目录:${conv.cwd}`,
|
|
@@ -1090,6 +1217,7 @@ class Hub extends EventEmitter<{ event: [EventEnvelope] }> {
|
|
|
1090
1217
|
cwd: conv.cwd,
|
|
1091
1218
|
...(conv.cwdExplicit ? { cwdExplicit: true } : {}),
|
|
1092
1219
|
...(conv.autoEdit ? { autoEdit: true } : {}),
|
|
1220
|
+
...(conv.mode ? { mode: conv.mode } : {}),
|
|
1093
1221
|
forkOf: key,
|
|
1094
1222
|
perAgent: {
|
|
1095
1223
|
[conv.agent]: {
|
|
@@ -1170,6 +1298,8 @@ class Hub extends EventEmitter<{ event: [EventEnvelope] }> {
|
|
|
1170
1298
|
return text(getConversation(key)!.cwd);
|
|
1171
1299
|
case "autoedit":
|
|
1172
1300
|
return text(await this.setAutoEdit(key, cmd.on));
|
|
1301
|
+
case "mode":
|
|
1302
|
+
return text(await this.setMode(key, cmd.mode));
|
|
1173
1303
|
case "status":
|
|
1174
1304
|
return text(await this.status(key));
|
|
1175
1305
|
case "cd":
|