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/main.ts
CHANGED
|
@@ -9,6 +9,8 @@ import { agentHealth } from "./agents/health.js";
|
|
|
9
9
|
import { startFeishu, stopFeishu } from "./channels/feishu/index.js";
|
|
10
10
|
import { startWeb, stopWeb } from "./channels/web/server.js";
|
|
11
11
|
import { home } from "./home.js";
|
|
12
|
+
import { startMemoryCurator } from "./core/memory-curator.js";
|
|
13
|
+
import { startScheduler, stopScheduler } from "./core/scheduled.js";
|
|
12
14
|
|
|
13
15
|
async function startDriver(id: "claude" | "codex"): Promise<void> {
|
|
14
16
|
const driver = hub.driver(id);
|
|
@@ -39,8 +41,17 @@ async function main(): Promise<void> {
|
|
|
39
41
|
return !!conv && !isExpired(conv);
|
|
40
42
|
});
|
|
41
43
|
|
|
44
|
+
// 聊天记录索引和聊完后的记忆整理
|
|
45
|
+
startMemoryCurator();
|
|
46
|
+
|
|
42
47
|
startFeishu();
|
|
43
48
|
if (config().web.enabled) await startWeb();
|
|
49
|
+
// 定时发送:等 agent 和渠道都起来了再开始,服务停着时到点的这时补发
|
|
50
|
+
startScheduler((m) => {
|
|
51
|
+
if (!getConversation(m.key)) return;
|
|
52
|
+
hub.send(m.key, { text: m.text, attachments: [] }, { channel: "schedule", channelId: "schedule", name: "定时发送" });
|
|
53
|
+
hub.emitEvent(m.key, { type: "meta" });
|
|
54
|
+
});
|
|
44
55
|
// 先查一遍 agent 装没装、登没登录,网页打开时新对话里就只列出能用的
|
|
45
56
|
void agentHealth().catch(() => {});
|
|
46
57
|
|
|
@@ -56,6 +67,7 @@ async function main(): Promise<void> {
|
|
|
56
67
|
console.log(`[退出] 收到 ${signal},正在收尾…`);
|
|
57
68
|
const force = setTimeout(() => process.exit(0), 8000);
|
|
58
69
|
force.unref();
|
|
70
|
+
stopScheduler();
|
|
59
71
|
stopFeishu();
|
|
60
72
|
await stopWeb();
|
|
61
73
|
await hub.shutdown().catch(() => {});
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
// 记忆工具的 MCP 服务(stdio):给 agent 用的 remember / forget。
|
|
2
|
+
//
|
|
3
|
+
// Claude 和 Codex 都按「一个会话一个子进程」拉起它(见 core/memory-mcp.ts),记给谁由拉起时的
|
|
4
|
+
// 环境变量 AGENT_LOFT_MEMORY_USER 定死 —— 工具参数里没有用户,模型想写别人的记忆也无从写起。
|
|
5
|
+
//
|
|
6
|
+
// 只实现 MCP 里用得到的那一小块:initialize、tools/list、tools/call、ping。
|
|
7
|
+
// 传输是一行一条 JSON(MCP stdio 的规定),stdout 只能写协议消息,日志一律走 stderr。
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import { addMemoryEntry, localDate, MEMORY_LIMITS, removeMemoryItem } from "../core/memory.js";
|
|
10
|
+
import { searchHistory } from "../core/chat-history.js";
|
|
11
|
+
import { dataDir, readJson } from "../home.js";
|
|
12
|
+
|
|
13
|
+
const userId = process.env.AGENT_LOFT_MEMORY_USER ?? "";
|
|
14
|
+
const via = process.env.AGENT_LOFT_MEMORY_VIA ?? "";
|
|
15
|
+
/** 开着「参考以前的聊天」才有搜索工具 */
|
|
16
|
+
const searchEnabled = process.env.AGENT_LOFT_MEMORY_SEARCH === "1";
|
|
17
|
+
/** 当前这条对话:它本来就在上下文里,搜的时候跳过 */
|
|
18
|
+
const currentConv = process.env.AGENT_LOFT_MEMORY_CONV ?? "";
|
|
19
|
+
|
|
20
|
+
type Request = { jsonrpc: "2.0"; id?: string | number | null; method: string; params?: Record<string, unknown> };
|
|
21
|
+
|
|
22
|
+
const TOOLS = [
|
|
23
|
+
{
|
|
24
|
+
name: "remember",
|
|
25
|
+
description:
|
|
26
|
+
"记住一件关于用户的、以后长期有用的事(身份、偏好、习惯、长期在做的事、重要决定)。一句话,写清主语和背景。不要记密码、密钥等敏感信息。",
|
|
27
|
+
inputSchema: {
|
|
28
|
+
type: "object",
|
|
29
|
+
properties: { text: { type: "string", description: `要记住的内容,一句话,最多 ${MEMORY_LIMITS.item} 字` } },
|
|
30
|
+
required: ["text"],
|
|
31
|
+
additionalProperties: false,
|
|
32
|
+
},
|
|
33
|
+
annotations: { title: "记住", readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
name: "forget",
|
|
37
|
+
description: "删掉一条过时、错误或者用户要求忘掉的记忆。id 是系统提示里「记住的事」每条前面方括号里的那个。",
|
|
38
|
+
inputSchema: {
|
|
39
|
+
type: "object",
|
|
40
|
+
properties: { id: { type: "string", description: "记忆的 id,形如 m_1a2b3c" } },
|
|
41
|
+
required: ["id"],
|
|
42
|
+
additionalProperties: false,
|
|
43
|
+
},
|
|
44
|
+
annotations: { title: "忘掉", readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
name: "search_past_chats",
|
|
48
|
+
description:
|
|
49
|
+
"搜用户以前的聊天记录(只有他自己的对话,不含当前这条)。关键词匹配,不是语义搜索:用对话里可能出现的原词,搜不到就换个说法再搜。返回相关的问答片段、日期和对话标题。",
|
|
50
|
+
inputSchema: {
|
|
51
|
+
type: "object",
|
|
52
|
+
properties: {
|
|
53
|
+
query: { type: "string", description: "关键词,多个词用空格隔开,比如「软路由 DNS 污染」" },
|
|
54
|
+
limit: { type: "number", description: "最多返回几段,默认 6,最多 15" },
|
|
55
|
+
},
|
|
56
|
+
required: ["query"],
|
|
57
|
+
additionalProperties: false,
|
|
58
|
+
},
|
|
59
|
+
annotations: { title: "搜以前的聊天", readOnlyHint: true, openWorldHint: false },
|
|
60
|
+
},
|
|
61
|
+
].filter((t) => t.name !== "search_past_chats" || searchEnabled);
|
|
62
|
+
|
|
63
|
+
type ConvMeta = { title?: string; hidden?: boolean; agent?: string };
|
|
64
|
+
|
|
65
|
+
function search(query: string, limit: number) {
|
|
66
|
+
if (!searchEnabled) return text("没有开启「参考以前的聊天」。", true);
|
|
67
|
+
if (!query.trim()) return text("要给关键词。", true);
|
|
68
|
+
// 每次现读:用户在 Web 上删掉的对话(隐藏)立刻搜不到
|
|
69
|
+
const convs = readJson<Record<string, ConvMeta>>(path.join(dataDir, "conversations.json"), {});
|
|
70
|
+
const hits = searchHistory(userId, query, {
|
|
71
|
+
limit: Math.max(1, Math.min(15, Math.round(limit) || 6)),
|
|
72
|
+
skip: (k) => k === currentConv || !!convs[k]?.hidden,
|
|
73
|
+
});
|
|
74
|
+
if (hits.length === 0) return text(`以前的聊天里没找到「${query}」相关的内容。可以换个说法再搜。`);
|
|
75
|
+
const blocks = hits.map((h, i) => {
|
|
76
|
+
const title = convs[h.key]?.title ?? (h.key.startsWith("feishu:") ? "飞书私聊" : "未命名对话");
|
|
77
|
+
return [
|
|
78
|
+
`[${i + 1}] ${localDate(h.ts)} · 「${title}」`,
|
|
79
|
+
...(h.user ? [`用户:${h.user}`] : []),
|
|
80
|
+
...(h.assistant ? [`助手:${h.assistant}`] : []),
|
|
81
|
+
].join("\n");
|
|
82
|
+
});
|
|
83
|
+
return text(`找到 ${hits.length} 段相关的聊天(越相关越靠前):\n\n${blocks.join("\n\n")}`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function text(t: string, isError = false) {
|
|
87
|
+
return { content: [{ type: "text", text: t }], ...(isError ? { isError: true } : {}) };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function callTool(name: string, args: Record<string, unknown>) {
|
|
91
|
+
if (!userId) return text("记忆没有开启(没有指定用户)。", true);
|
|
92
|
+
try {
|
|
93
|
+
if (name === "remember") {
|
|
94
|
+
const { item, created, evicted } = addMemoryEntry(userId, String(args.text ?? ""), "agent");
|
|
95
|
+
console.error(`[记忆] ${via} 记下 ${item.id}`);
|
|
96
|
+
if (!created) return text(`本来就记着(${item.id}):${item.text}`);
|
|
97
|
+
return text(`已记住(${item.id}):${item.text}${evicted ? `\n(记忆满了,挤掉了最久没用的一条:${evicted.text})` : ""}`);
|
|
98
|
+
}
|
|
99
|
+
if (name === "search_past_chats") return search(String(args.query ?? ""), Number(args.limit ?? 6));
|
|
100
|
+
if (name === "forget") {
|
|
101
|
+
const removed = removeMemoryItem(userId, String(args.id ?? "").trim());
|
|
102
|
+
return removed ? text(`已忘掉(${removed.id}):${removed.text}`) : text(`没有 id 为 ${String(args.id)} 的记忆。`, true);
|
|
103
|
+
}
|
|
104
|
+
return text(`没有工具 ${name}`, true);
|
|
105
|
+
} catch (err) {
|
|
106
|
+
return text(err instanceof Error ? err.message : String(err), true);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function handle(req: Request): unknown {
|
|
111
|
+
switch (req.method) {
|
|
112
|
+
case "initialize":
|
|
113
|
+
return {
|
|
114
|
+
// 对端要哪个版本就回哪个:这里用到的几个方法在各版本里都没变
|
|
115
|
+
protocolVersion: typeof req.params?.protocolVersion === "string" ? req.params.protocolVersion : "2025-06-18",
|
|
116
|
+
capabilities: { tools: {} },
|
|
117
|
+
serverInfo: { name: "agent-loft-memory", version: "1.0.0" },
|
|
118
|
+
};
|
|
119
|
+
case "ping":
|
|
120
|
+
return {};
|
|
121
|
+
case "tools/list":
|
|
122
|
+
return { tools: TOOLS };
|
|
123
|
+
case "tools/call": {
|
|
124
|
+
const params = req.params ?? {};
|
|
125
|
+
return callTool(String(params.name ?? ""), (params.arguments as Record<string, unknown> | undefined) ?? {});
|
|
126
|
+
}
|
|
127
|
+
default:
|
|
128
|
+
throw Object.assign(new Error(`不支持 ${req.method}`), { code: -32601 });
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function reply(message: unknown): void {
|
|
133
|
+
process.stdout.write(`${JSON.stringify(message)}\n`);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function onLine(line: string): void {
|
|
137
|
+
if (!line.trim()) return;
|
|
138
|
+
let req: Request;
|
|
139
|
+
try {
|
|
140
|
+
req = JSON.parse(line) as Request;
|
|
141
|
+
} catch {
|
|
142
|
+
reply({ jsonrpc: "2.0", id: null, error: { code: -32700, message: "JSON 解析失败" } });
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
// 通知(没有 id)不回
|
|
146
|
+
const isRequest = req.id !== undefined && req.id !== null;
|
|
147
|
+
try {
|
|
148
|
+
const result = handle(req);
|
|
149
|
+
if (isRequest) reply({ jsonrpc: "2.0", id: req.id, result });
|
|
150
|
+
} catch (err) {
|
|
151
|
+
if (!isRequest) return;
|
|
152
|
+
const code = (err as { code?: number }).code ?? -32603;
|
|
153
|
+
reply({ jsonrpc: "2.0", id: req.id, error: { code, message: err instanceof Error ? err.message : String(err) } });
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
let buf = "";
|
|
158
|
+
process.stdin.setEncoding("utf8");
|
|
159
|
+
process.stdin.on("data", (chunk: string) => {
|
|
160
|
+
buf += chunk;
|
|
161
|
+
let n: number;
|
|
162
|
+
while ((n = buf.indexOf("\n")) >= 0) {
|
|
163
|
+
const line = buf.slice(0, n);
|
|
164
|
+
buf = buf.slice(n + 1);
|
|
165
|
+
onLine(line);
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
process.stdin.on("end", () => process.exit(0));
|