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.
@@ -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
  ];
@@ -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
+ }