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