agent-loft 0.0.0-stage → 0.2.8
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/LICENSE +21 -0
- package/README.md +110 -2
- package/bin/cli.js +52 -0
- package/package.json +64 -4
- package/src/agents/claude/account.ts +122 -0
- package/src/agents/claude/driver.ts +216 -0
- package/src/agents/claude/errors.ts +129 -0
- package/src/agents/claude/permissions.ts +260 -0
- package/src/agents/claude/probe.ts +54 -0
- package/src/agents/claude/session.ts +487 -0
- package/src/agents/codex/account.ts +122 -0
- package/src/agents/codex/app-server.ts +346 -0
- package/src/agents/codex/driver.ts +210 -0
- package/src/agents/codex/errors.ts +158 -0
- package/src/agents/codex/protocol.ts +284 -0
- package/src/agents/codex/refusals.ts +62 -0
- package/src/agents/codex/session.ts +773 -0
- package/src/channels/feishu/bot.ts +669 -0
- package/src/channels/feishu/cards.ts +360 -0
- package/src/channels/feishu/client.ts +185 -0
- package/src/channels/feishu/envelope.ts +117 -0
- package/src/channels/feishu/index.ts +62 -0
- package/src/channels/feishu/message.ts +115 -0
- package/src/channels/feishu/register.ts +171 -0
- package/src/channels/feishu/slash-sync.ts +192 -0
- package/src/channels/feishu/turn-stream.ts +98 -0
- package/src/channels/web/api.ts +645 -0
- package/src/channels/web/auth.ts +204 -0
- package/src/channels/web/http.ts +118 -0
- package/src/channels/web/push.ts +218 -0
- package/src/channels/web/server.ts +446 -0
- package/src/cli/doctor.ts +39 -0
- package/src/cli/import.ts +155 -0
- package/src/cli/pair.ts +35 -0
- package/src/cli/passwd.ts +15 -0
- package/src/cli/service.ts +137 -0
- package/src/core/agent-env.ts +11 -0
- package/src/core/command-catalog.ts +112 -0
- package/src/core/commands.ts +104 -0
- package/src/core/config.ts +285 -0
- package/src/core/dedup.ts +16 -0
- package/src/core/footers.ts +88 -0
- package/src/core/hub.ts +1152 -0
- package/src/core/interactions.ts +273 -0
- package/src/core/pairing.ts +156 -0
- package/src/core/store.ts +178 -0
- package/src/core/types.ts +213 -0
- package/src/core/uploads.ts +200 -0
- package/src/home.ts +43 -0
- package/src/main.ts +68 -0
- package/web/app.js +3731 -0
- package/web/apple-touch-icon.png +0 -0
- package/web/icon-maskable.svg +13 -0
- package/web/icon.svg +12 -0
- package/web/index.html +23 -0
- package/web/manifest.webmanifest +15 -0
- package/web/style.css +2604 -0
- package/web/sw.js +76 -0
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
// 交互中枢:agent 发起的每一次审批 / 提问,都在这里变成一条「待处理」和一个 Promise。
|
|
2
|
+
//
|
|
3
|
+
// 和旧桥接器的区别在于它不认识任何渠道:飞书、Web 都只是订阅者。同一条请求可以
|
|
4
|
+
// 同时显示在飞书卡片和 Web 页面上,哪边先处理算哪边,其余订阅者收到 settled 事件
|
|
5
|
+
// 后把自己那份改成「已处理」。
|
|
6
|
+
//
|
|
7
|
+
// 继承下来的几条硬规矩:
|
|
8
|
+
// - 超时一律按「没放行」收束,绝不能是放行;
|
|
9
|
+
// - 选项用「设成」语义而不是「切换」,渠道重投回调时结果不变;
|
|
10
|
+
// - 丢弃 / 中断会话时必须取消它挂着的所有请求,否则阻塞的那一轮永远走不完。
|
|
11
|
+
import crypto from "node:crypto";
|
|
12
|
+
import { EventEmitter } from "node:events";
|
|
13
|
+
import type { InteractionOutcome, InteractionSpec, QuestionSpec, Settlement } from "./types.js";
|
|
14
|
+
|
|
15
|
+
export type Interaction = {
|
|
16
|
+
id: string;
|
|
17
|
+
convKey: string;
|
|
18
|
+
spec: InteractionSpec;
|
|
19
|
+
createdAt: number;
|
|
20
|
+
expiresAt: number;
|
|
21
|
+
/** 仅提问:与 questions 等长,每问已选中的选项下标 */
|
|
22
|
+
selections: number[][];
|
|
23
|
+
/** 仅提问:自由作答,按问题下标存 */
|
|
24
|
+
freeText: Record<number, string>;
|
|
25
|
+
settled?: { settlement: Settlement; by: string; at: number };
|
|
26
|
+
/** 发起它的会话进程编号:只有这个进程还活着时,普通消息才能拿来作答 */
|
|
27
|
+
owner?: number;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
type Entry = Interaction & {
|
|
31
|
+
resolve: (outcome: InteractionOutcome) => void;
|
|
32
|
+
timer: NodeJS.Timeout;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export type InteractionEvents = {
|
|
36
|
+
created: [Interaction];
|
|
37
|
+
updated: [Interaction];
|
|
38
|
+
settled: [Interaction];
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
class InteractionCenter extends EventEmitter<InteractionEvents> {
|
|
42
|
+
private pending = new Map<string, Entry>();
|
|
43
|
+
/** 已收束的留一小段时间,供渠道回调时给出准确的「已由 xx 处理」提示 */
|
|
44
|
+
private recent = new Map<string, Interaction>();
|
|
45
|
+
|
|
46
|
+
create(convKey: string, spec: InteractionSpec, timeoutMs: number, owner?: number): Promise<InteractionOutcome> {
|
|
47
|
+
const id = `req_${crypto.randomBytes(4).toString("hex")}`;
|
|
48
|
+
let resolve!: (outcome: InteractionOutcome) => void;
|
|
49
|
+
const promise = new Promise<InteractionOutcome>((r) => {
|
|
50
|
+
resolve = r;
|
|
51
|
+
});
|
|
52
|
+
const timer = setTimeout(() => this.settle(id, "timeout", "无人响应"), timeoutMs);
|
|
53
|
+
timer.unref?.();
|
|
54
|
+
const now = Date.now();
|
|
55
|
+
const entry: Entry = {
|
|
56
|
+
id,
|
|
57
|
+
convKey,
|
|
58
|
+
spec,
|
|
59
|
+
createdAt: now,
|
|
60
|
+
expiresAt: now + timeoutMs,
|
|
61
|
+
selections: spec.kind === "question" ? spec.questions.map(() => []) : [],
|
|
62
|
+
freeText: {},
|
|
63
|
+
...(owner !== undefined ? { owner } : {}),
|
|
64
|
+
resolve,
|
|
65
|
+
timer,
|
|
66
|
+
};
|
|
67
|
+
this.pending.set(id, entry);
|
|
68
|
+
this.emit("created", publicView(entry));
|
|
69
|
+
return promise;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
get(id: string): Interaction | undefined {
|
|
73
|
+
const entry = this.pending.get(id);
|
|
74
|
+
return entry ? publicView(entry) : this.recent.get(id);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
isPending(id: string): boolean {
|
|
78
|
+
return this.pending.has(id);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** 某会话当前挂着的全部请求(Web 端打开会话时要补画出来)。 */
|
|
82
|
+
listPending(convKey?: string): Interaction[] {
|
|
83
|
+
return [...this.pending.values()]
|
|
84
|
+
.filter((e) => !convKey || e.convKey === convKey)
|
|
85
|
+
.map(publicView);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** 审批决定。返回 undefined 表示请求已不在(超时、已处理、或进程重启过)。 */
|
|
89
|
+
decide(id: string, decision: Settlement, by: string): Interaction | undefined {
|
|
90
|
+
const entry = this.pending.get(id);
|
|
91
|
+
if (!entry || entry.spec.kind !== "approval") return undefined;
|
|
92
|
+
// 卡上没有这个按钮(比如批准计划)就不认:不能靠手打 /approve 绕过去
|
|
93
|
+
if (decision === "allow-session" && !entry.spec.allowSession) return undefined;
|
|
94
|
+
const settled = this.settle(id, decision, by);
|
|
95
|
+
if (decision === "allow-session") {
|
|
96
|
+
// 并行的工具调用会同时挂着好几张卡:同一个会话进程里能一并放行的,一起放掉
|
|
97
|
+
for (const other of [...this.pending.values()]) {
|
|
98
|
+
if (other.convKey !== entry.convKey || other.owner !== entry.owner) continue;
|
|
99
|
+
if (other.spec.kind !== "approval" || !other.spec.allowSession) continue;
|
|
100
|
+
this.settle(other.id, "allow-session", `${by}(随「本会话全部允许」)`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return settled;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** 把某个选项设成选中 / 未选中。全单选且都答完时自动收束。 */
|
|
107
|
+
select(
|
|
108
|
+
id: string,
|
|
109
|
+
questionIndex: number,
|
|
110
|
+
optionIndex: number,
|
|
111
|
+
selected: boolean,
|
|
112
|
+
by: string,
|
|
113
|
+
): { interaction: Interaction; submitted: boolean } | undefined {
|
|
114
|
+
const entry = this.pending.get(id);
|
|
115
|
+
if (!entry || entry.spec.kind !== "question") return undefined;
|
|
116
|
+
const spec = entry.spec.questions[questionIndex];
|
|
117
|
+
const picked = entry.selections[questionIndex];
|
|
118
|
+
if (!spec || !picked || !spec.options[optionIndex]) return undefined;
|
|
119
|
+
|
|
120
|
+
if (spec.multiSelect) {
|
|
121
|
+
const at = picked.indexOf(optionIndex);
|
|
122
|
+
if (selected && at < 0) picked.push(optionIndex);
|
|
123
|
+
if (!selected && at >= 0) picked.splice(at, 1);
|
|
124
|
+
} else {
|
|
125
|
+
entry.selections[questionIndex] = selected ? [optionIndex] : [];
|
|
126
|
+
delete entry.freeText[questionIndex];
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const allSingle = entry.spec.questions.every((q) => !q.multiSelect);
|
|
130
|
+
if (allSingle && unansweredCount(entry) === 0) {
|
|
131
|
+
return { interaction: this.settle(id, "answered", by)!, submitted: true };
|
|
132
|
+
}
|
|
133
|
+
this.emit("updated", publicView(entry));
|
|
134
|
+
return { interaction: publicView(entry), submitted: false };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** 点「提交」。还有没答的问题时返回 { missing }。 */
|
|
138
|
+
submit(id: string, by: string): Interaction | { missing: number } | undefined {
|
|
139
|
+
const entry = this.pending.get(id);
|
|
140
|
+
if (!entry || entry.spec.kind !== "question") return undefined;
|
|
141
|
+
const missing = unansweredCount(entry);
|
|
142
|
+
if (missing > 0) return { missing };
|
|
143
|
+
return this.settle(id, "answered", by);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
skip(id: string, by: string): Interaction | undefined {
|
|
147
|
+
const entry = this.pending.get(id);
|
|
148
|
+
if (!entry || entry.spec.kind !== "question") return undefined;
|
|
149
|
+
entry.selections = entry.selections.map(() => []);
|
|
150
|
+
entry.freeText = {};
|
|
151
|
+
return this.settle(id, "skipped", by);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* 用一条普通消息回答提问 —— 等价于官方 UI 里的「Other」自由输入。
|
|
156
|
+
* 写进第一个还没作答的问题;返回 true 表示这条消息已被消费,不该再送进会话。
|
|
157
|
+
*/
|
|
158
|
+
answerWithText(id: string, text: string, by: string): boolean {
|
|
159
|
+
const entry = this.pending.get(id);
|
|
160
|
+
if (!entry || entry.spec.kind !== "question") return false;
|
|
161
|
+
const index = entry.selections.findIndex((picked, i) => picked.length === 0 && !entry.freeText[i]);
|
|
162
|
+
if (index < 0) {
|
|
163
|
+
// 都答过了(多选还没点提交):这条消息不是作答,交给会话照常处理
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
166
|
+
entry.freeText[index] = text;
|
|
167
|
+
// 还有没答的问题:留着让人继续答(下一条消息或点按钮),不能提前交卷
|
|
168
|
+
if (unansweredCount(entry) > 0) {
|
|
169
|
+
this.emit("updated", publicView(entry));
|
|
170
|
+
return true;
|
|
171
|
+
}
|
|
172
|
+
this.settle(id, "answered", by);
|
|
173
|
+
return true;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** 这条会话里挂着的提问(正常同时只会有一个)。 */
|
|
177
|
+
findQuestion(convKey: string): Interaction | undefined {
|
|
178
|
+
let oldest: Entry | undefined;
|
|
179
|
+
for (const entry of this.pending.values()) {
|
|
180
|
+
if (entry.spec.kind !== "question" || entry.convKey !== convKey) continue;
|
|
181
|
+
if (!oldest || entry.createdAt < oldest.createdAt) oldest = entry;
|
|
182
|
+
}
|
|
183
|
+
return oldest ? publicView(oldest) : undefined;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** 取消某会话上所有挂着的请求。中断、切目录、开新会话时必须调用。 */
|
|
187
|
+
cancelAll(convKey: string, note: string): Interaction[] {
|
|
188
|
+
const out: Interaction[] = [];
|
|
189
|
+
for (const entry of [...this.pending.values()]) {
|
|
190
|
+
if (entry.convKey !== convKey) continue;
|
|
191
|
+
const settled = this.settle(entry.id, "cancelled", note);
|
|
192
|
+
if (settled) out.push(settled);
|
|
193
|
+
}
|
|
194
|
+
return out;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
private settle(id: string, settlement: Settlement, by: string): Interaction | undefined {
|
|
198
|
+
const entry = this.pending.get(id);
|
|
199
|
+
if (!entry) return undefined;
|
|
200
|
+
clearTimeout(entry.timer);
|
|
201
|
+
this.pending.delete(id);
|
|
202
|
+
entry.settled = { settlement, by, at: Date.now() };
|
|
203
|
+
const view = publicView(entry);
|
|
204
|
+
this.recent.set(id, view);
|
|
205
|
+
if (this.recent.size > 200) this.recent.delete(this.recent.keys().next().value!);
|
|
206
|
+
entry.resolve({ settlement, answers: answersOf(entry) });
|
|
207
|
+
this.emit("settled", view);
|
|
208
|
+
return view;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function publicView(entry: Entry | Interaction): Interaction {
|
|
213
|
+
const { id, convKey, spec, createdAt, expiresAt, selections, freeText, settled, owner } = entry;
|
|
214
|
+
return {
|
|
215
|
+
id,
|
|
216
|
+
convKey,
|
|
217
|
+
spec,
|
|
218
|
+
createdAt,
|
|
219
|
+
expiresAt,
|
|
220
|
+
selections: selections.map((s) => [...s]),
|
|
221
|
+
freeText: { ...freeText },
|
|
222
|
+
...(settled ? { settled } : {}),
|
|
223
|
+
...(owner !== undefined ? { owner } : {}),
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export function unansweredCount(entry: Interaction): number {
|
|
228
|
+
return entry.selections.filter((picked, i) => picked.length === 0 && !entry.freeText[i]).length;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** 问题 id → 答案。没作答的问题不出现在表里 —— 不替人编答案。 */
|
|
232
|
+
export function answersOf(entry: Interaction): Record<string, string[]> {
|
|
233
|
+
const answers: Record<string, string[]> = {};
|
|
234
|
+
if (entry.spec.kind !== "question") return answers;
|
|
235
|
+
if (entry.settled && entry.settled.settlement !== "answered") return answers;
|
|
236
|
+
entry.spec.questions.forEach((spec: QuestionSpec, i) => {
|
|
237
|
+
const free = entry.freeText[i];
|
|
238
|
+
if (free) {
|
|
239
|
+
answers[spec.id] = [free];
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
const labels = (entry.selections[i] ?? [])
|
|
243
|
+
.map((o) => spec.options[o]?.label)
|
|
244
|
+
.filter((l): l is string => !!l);
|
|
245
|
+
if (labels.length > 0) answers[spec.id] = labels;
|
|
246
|
+
});
|
|
247
|
+
return answers;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** 给人看的:问题原文 → 答案文字。 */
|
|
251
|
+
export function answerTextOf(entry: Interaction): Record<string, string> {
|
|
252
|
+
const shown: Record<string, string> = {};
|
|
253
|
+
if (entry.spec.kind !== "question") return shown;
|
|
254
|
+
const answers = answersOf(entry);
|
|
255
|
+
for (const spec of entry.spec.questions) {
|
|
256
|
+
const picked = answers[spec.id];
|
|
257
|
+
if (picked) shown[spec.question] = picked.join(", ");
|
|
258
|
+
}
|
|
259
|
+
return shown;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export const SETTLEMENT_LABEL: Record<Settlement, string> = {
|
|
263
|
+
"allow-once": "已允许(本次)",
|
|
264
|
+
"allow-always": "已允许",
|
|
265
|
+
"allow-session": "已允许(本会话全部放行)",
|
|
266
|
+
deny: "已拒绝",
|
|
267
|
+
timeout: "已超时,按拒绝处理",
|
|
268
|
+
cancelled: "已取消",
|
|
269
|
+
answered: "已回答",
|
|
270
|
+
skipped: "已跳过",
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
export const interactions = new InteractionCenter();
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// 配对:陌生人给机器人发消息时拿到一个短码,消息本身不被处理;
|
|
2
|
+
// 在 Web 设置页或终端批准之后他才能用。替代手工抄 open_id。
|
|
3
|
+
//
|
|
4
|
+
// 按渠道实例分开记(channelId = 飞书机器人 id / 以后的 Telegram 机器人 id):
|
|
5
|
+
// 在 A 机器人上被批准,不等于能用 B 机器人。
|
|
6
|
+
import crypto from "node:crypto";
|
|
7
|
+
import fs from "node:fs";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import { EventEmitter } from "node:events";
|
|
10
|
+
import { dataDir, readJson, writeJsonAtomic } from "../home.js";
|
|
11
|
+
|
|
12
|
+
/** 去掉 0/O/1/I,避免念错抄错 */
|
|
13
|
+
const CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
|
14
|
+
const CODE_LENGTH = 8;
|
|
15
|
+
const CODE_TTL_MS = 60 * 60 * 1000;
|
|
16
|
+
/** 同时最多挂 3 个待批请求,防止陌生人刷屏 */
|
|
17
|
+
const MAX_PENDING = 3;
|
|
18
|
+
|
|
19
|
+
export type PairRequest = {
|
|
20
|
+
code: string;
|
|
21
|
+
userId: string;
|
|
22
|
+
chatId: string;
|
|
23
|
+
name?: string;
|
|
24
|
+
createdAt: number;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
type ChannelPairs = { pending: PairRequest[]; approved: string[] };
|
|
28
|
+
type Store = Record<string, ChannelPairs>;
|
|
29
|
+
|
|
30
|
+
const storePath = path.join(dataDir, "pairing.json");
|
|
31
|
+
export const pairingEvents = new EventEmitter<{ approved: [string, PairRequest]; requested: [string, PairRequest] }>();
|
|
32
|
+
|
|
33
|
+
function read(): Store {
|
|
34
|
+
return readJson<Store>(storePath, {});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function channel(store: Store, channelId: string, now = Date.now()): ChannelPairs {
|
|
38
|
+
const entry = (store[channelId] ??= { pending: [], approved: [] });
|
|
39
|
+
entry.pending = entry.pending.filter((p) => now - p.createdAt < CODE_TTL_MS);
|
|
40
|
+
return entry;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function newCode(): string {
|
|
44
|
+
return Array.from(crypto.randomBytes(CODE_LENGTH), (b) => CODE_ALPHABET[b % CODE_ALPHABET.length]).join("");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function isPaired(channelId: string, userId: string | undefined): boolean {
|
|
48
|
+
if (!userId) return false;
|
|
49
|
+
return (read()[channelId]?.approved ?? []).includes(userId.trim().toLowerCase());
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export type PairOutcome =
|
|
53
|
+
| { kind: "already_pending"; request: PairRequest }
|
|
54
|
+
| { kind: "created"; request: PairRequest }
|
|
55
|
+
| { kind: "rejected_full" };
|
|
56
|
+
|
|
57
|
+
export function requestPairing(channelId: string, params: { userId: string; chatId: string; name?: string }): PairOutcome {
|
|
58
|
+
const store = read();
|
|
59
|
+
const ch = channel(store, channelId);
|
|
60
|
+
const userId = params.userId.trim().toLowerCase();
|
|
61
|
+
const existing = ch.pending.find((p) => p.userId === userId);
|
|
62
|
+
if (existing) return { kind: "already_pending", request: existing };
|
|
63
|
+
if (ch.pending.length >= MAX_PENDING) return { kind: "rejected_full" };
|
|
64
|
+
const request: PairRequest = {
|
|
65
|
+
code: newCode(),
|
|
66
|
+
userId,
|
|
67
|
+
chatId: params.chatId,
|
|
68
|
+
...(params.name ? { name: params.name } : {}),
|
|
69
|
+
createdAt: Date.now(),
|
|
70
|
+
};
|
|
71
|
+
ch.pending.push(request);
|
|
72
|
+
writeJsonAtomic(storePath, store);
|
|
73
|
+
pairingEvents.emit("requested", channelId, request);
|
|
74
|
+
return { kind: "created", request };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** 所有渠道的待批请求 */
|
|
78
|
+
export function listPending(): { channelId: string; request: PairRequest }[] {
|
|
79
|
+
const store = read();
|
|
80
|
+
return Object.keys(store).flatMap((id) => channel(store, id).pending.map((request) => ({ channelId: id, request })));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function listApproved(channelId?: string): { channelId: string; userId: string }[] {
|
|
84
|
+
const store = read();
|
|
85
|
+
return Object.entries(store)
|
|
86
|
+
.filter(([id]) => !channelId || id === channelId)
|
|
87
|
+
.flatMap(([id, ch]) => ch.approved.map((userId) => ({ channelId: id, userId })));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** 按配对码批准。码在所有渠道里找,找到哪个算哪个。 */
|
|
91
|
+
export function approve(code: string): { ok: true; channelId: string; request: PairRequest } | { ok: false } {
|
|
92
|
+
const store = read();
|
|
93
|
+
const normalized = code.trim().toUpperCase();
|
|
94
|
+
for (const channelId of Object.keys(store)) {
|
|
95
|
+
const ch = channel(store, channelId);
|
|
96
|
+
const index = ch.pending.findIndex((p) => p.code === normalized);
|
|
97
|
+
if (index === -1) continue;
|
|
98
|
+
const [request] = ch.pending.splice(index, 1);
|
|
99
|
+
if (!request) continue;
|
|
100
|
+
if (!ch.approved.includes(request.userId)) ch.approved.push(request.userId);
|
|
101
|
+
writeJsonAtomic(storePath, store);
|
|
102
|
+
pairingEvents.emit("approved", channelId, request);
|
|
103
|
+
return { ok: true, channelId, request };
|
|
104
|
+
}
|
|
105
|
+
return { ok: false };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function revoke(channelId: string, userId: string): boolean {
|
|
109
|
+
const store = read();
|
|
110
|
+
const ch = channel(store, channelId);
|
|
111
|
+
const normalized = userId.trim().toLowerCase();
|
|
112
|
+
const before = ch.approved.length;
|
|
113
|
+
ch.approved = ch.approved.filter((id) => id !== normalized);
|
|
114
|
+
writeJsonAtomic(storePath, store);
|
|
115
|
+
return ch.approved.length !== before;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* 终端里另一个进程批准的,本进程靠监听文件得知。
|
|
120
|
+
* 回调收到的是「新出现的已批准用户」。
|
|
121
|
+
*/
|
|
122
|
+
export function watchPairing(onApproved: (channelId: string, userId: string, chatId?: string) => void): void {
|
|
123
|
+
let known = new Set(listApproved().map((a) => `${a.channelId}|${a.userId}`));
|
|
124
|
+
// 批准时 pending 里的那条已经被删了,chatId 得提前记下来
|
|
125
|
+
const chats = new Map<string, string>();
|
|
126
|
+
for (const { channelId, request } of listPending()) chats.set(`${channelId}|${request.userId}`, request.chatId);
|
|
127
|
+
pairingEvents.on("requested", (channelId, request) => chats.set(`${channelId}|${request.userId}`, request.chatId));
|
|
128
|
+
pairingEvents.on("approved", (channelId, request) => {
|
|
129
|
+
known.add(`${channelId}|${request.userId}`);
|
|
130
|
+
onApproved(channelId, request.userId, request.chatId);
|
|
131
|
+
});
|
|
132
|
+
try {
|
|
133
|
+
fs.mkdirSync(dataDir, { recursive: true });
|
|
134
|
+
if (!fs.existsSync(storePath)) writeJsonAtomic(storePath, {});
|
|
135
|
+
fs.watch(dataDir, { persistent: false }, (_event, file) => {
|
|
136
|
+
if (file !== "pairing.json") return;
|
|
137
|
+
const current = listApproved();
|
|
138
|
+
for (const a of current) {
|
|
139
|
+
const id = `${a.channelId}|${a.userId}`;
|
|
140
|
+
if (!known.has(id)) onApproved(a.channelId, a.userId, chats.get(id));
|
|
141
|
+
}
|
|
142
|
+
known = new Set(current.map((a) => `${a.channelId}|${a.userId}`));
|
|
143
|
+
});
|
|
144
|
+
} catch {
|
|
145
|
+
// 监听不了就只认本进程里的批准
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** 直接加入已批准名单(迁移旧数据用)。 */
|
|
150
|
+
export function addApproved(channelId: string, userId: string): void {
|
|
151
|
+
const store = read();
|
|
152
|
+
const ch = channel(store, channelId);
|
|
153
|
+
const normalized = userId.trim().toLowerCase();
|
|
154
|
+
if (!ch.approved.includes(normalized)) ch.approved.push(normalized);
|
|
155
|
+
writeJsonAtomic(storePath, store);
|
|
156
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// 会话(conversation)的持久化。
|
|
2
|
+
//
|
|
3
|
+
// 一个 conversation 是「一个聊天上下文」:飞书的一个私聊 / 群 / 话题,或者 Web 上
|
|
4
|
+
// 的一条对话。它记着当前用哪个 agent、在哪个目录,以及**每个 agent 各自**的会话 id
|
|
5
|
+
// 和模型 —— 在同一个群里从 Claude 切到 Codex 再切回来,Claude 那条会话还在。
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { dataDir, readJson, writeJsonAtomic } from "../home.js";
|
|
8
|
+
import { config } from "./config.js";
|
|
9
|
+
|
|
10
|
+
export type AgentBinding = {
|
|
11
|
+
/** agent 侧的会话 id(Claude 的 sessionId / Codex 的 threadId) */
|
|
12
|
+
sessionId?: string;
|
|
13
|
+
/** 用户显式选的模型,开新会话清掉 */
|
|
14
|
+
model?: string;
|
|
15
|
+
effort?: string;
|
|
16
|
+
/**
|
|
17
|
+
* 待分叉:/fork 之后、新会话真正建立之前,记着要从哪个会话分叉。
|
|
18
|
+
* 必须落盘:期间切了 agent、或者 agent-loft 重启了,下一条消息仍要分叉,而不是续写原会话。
|
|
19
|
+
* 拿到不同于它的新会话 id 后清掉。
|
|
20
|
+
*/
|
|
21
|
+
forkFrom?: string;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export type Conversation = {
|
|
25
|
+
key: string;
|
|
26
|
+
/** 渠道类型:feishu / web / telegram… */
|
|
27
|
+
channel: string;
|
|
28
|
+
/** 渠道实例:飞书是机器人 id,Web 固定 web */
|
|
29
|
+
channelId: string;
|
|
30
|
+
title?: string;
|
|
31
|
+
agent: string;
|
|
32
|
+
cwd: string;
|
|
33
|
+
/** cwd 是否用户显式指定。没指定过的跟随 workspaceRoot 变化 */
|
|
34
|
+
cwdExplicit?: boolean;
|
|
35
|
+
perAgent: Record<string, AgentBinding>;
|
|
36
|
+
/** 最后一次有人说话的时间,用于闲置过期 */
|
|
37
|
+
activeAt: number;
|
|
38
|
+
createdAt: number;
|
|
39
|
+
/** Web 列表里隐藏(Web 上删除的会话只是隐藏,agent 侧的历史还在) */
|
|
40
|
+
hidden?: boolean;
|
|
41
|
+
/** /autoedit:这条对话里 agent 在工作目录内改文件不再询问(跨 /new 保留,属于对话不属于某个会话) */
|
|
42
|
+
autoEdit?: boolean;
|
|
43
|
+
/** 从哪条对话分叉出来的(对话 key),列表里据此显示关联 */
|
|
44
|
+
forkOf?: string;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
type StoreShape = Record<string, Conversation>;
|
|
48
|
+
|
|
49
|
+
const storePath = path.join(dataDir, "conversations.json");
|
|
50
|
+
let cache: StoreShape | null = null;
|
|
51
|
+
let persistTimer: NodeJS.Timeout | null = null;
|
|
52
|
+
|
|
53
|
+
function load(): StoreShape {
|
|
54
|
+
cache ??= readJson<StoreShape>(storePath, {});
|
|
55
|
+
return cache;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** 合并写盘:一轮对话里会频繁 touch,没必要每次都落盘。 */
|
|
59
|
+
function persist(immediate = false): void {
|
|
60
|
+
if (immediate) {
|
|
61
|
+
if (persistTimer) clearTimeout(persistTimer);
|
|
62
|
+
persistTimer = null;
|
|
63
|
+
writeJsonAtomic(storePath, cache ?? {});
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
persistTimer ??= setTimeout(() => {
|
|
67
|
+
persistTimer = null;
|
|
68
|
+
writeJsonAtomic(storePath, cache ?? {});
|
|
69
|
+
}, 500);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function flushStore(): void {
|
|
73
|
+
if (persistTimer) persist(true);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function insideRoot(dir: string): boolean {
|
|
77
|
+
const root = config().workspaceRoot;
|
|
78
|
+
const rel = path.relative(root, dir);
|
|
79
|
+
return dir === root || (rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** 目录跟随配置:没显式指定过的跟着 workspaceRoot 走,显式指定的只在越界时回退。 */
|
|
83
|
+
function reconcileCwd(conv: Conversation): Conversation {
|
|
84
|
+
const root = config().workspaceRoot;
|
|
85
|
+
const needsReset = conv.cwdExplicit ? !insideRoot(conv.cwd) : conv.cwd !== root;
|
|
86
|
+
if (!needsReset) return conv;
|
|
87
|
+
console.warn(`[store] 会话 ${conv.key} 的工作目录由 ${conv.cwd} 重置为 ${root}`);
|
|
88
|
+
conv.cwd = root;
|
|
89
|
+
conv.cwdExplicit = false;
|
|
90
|
+
// 目录变了,旧会话的上下文已经没意义
|
|
91
|
+
for (const binding of Object.values(conv.perAgent)) {
|
|
92
|
+
delete binding.sessionId;
|
|
93
|
+
delete binding.forkFrom;
|
|
94
|
+
}
|
|
95
|
+
persist();
|
|
96
|
+
return conv;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function getConversation(key: string): Conversation | undefined {
|
|
100
|
+
const conv = load()[key];
|
|
101
|
+
return conv ? reconcileCwd(conv) : undefined;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function ensureConversation(params: {
|
|
105
|
+
key: string;
|
|
106
|
+
channel: string;
|
|
107
|
+
channelId: string;
|
|
108
|
+
agent: string;
|
|
109
|
+
title?: string;
|
|
110
|
+
}): Conversation {
|
|
111
|
+
const existing = getConversation(params.key);
|
|
112
|
+
if (existing) return existing;
|
|
113
|
+
const now = Date.now();
|
|
114
|
+
const conv: Conversation = {
|
|
115
|
+
key: params.key,
|
|
116
|
+
channel: params.channel,
|
|
117
|
+
channelId: params.channelId,
|
|
118
|
+
agent: params.agent,
|
|
119
|
+
cwd: config().workspaceRoot,
|
|
120
|
+
perAgent: {},
|
|
121
|
+
activeAt: now,
|
|
122
|
+
createdAt: now,
|
|
123
|
+
...(params.title ? { title: params.title } : {}),
|
|
124
|
+
};
|
|
125
|
+
load()[params.key] = conv;
|
|
126
|
+
persist(true);
|
|
127
|
+
return conv;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function updateConversation(key: string, patch: Partial<Conversation>): Conversation {
|
|
131
|
+
const conv = getConversation(key);
|
|
132
|
+
if (!conv) throw new Error(`会话不存在:${key}`);
|
|
133
|
+
Object.assign(conv, patch);
|
|
134
|
+
persist();
|
|
135
|
+
return conv;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function agentBinding(conv: Conversation, agent = conv.agent): AgentBinding {
|
|
139
|
+
return (conv.perAgent[agent] ??= {});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function updateAgentBinding(key: string, agent: string, patch: Partial<AgentBinding>): void {
|
|
143
|
+
const conv = getConversation(key);
|
|
144
|
+
if (!conv) return;
|
|
145
|
+
const binding = agentBinding(conv, agent);
|
|
146
|
+
for (const [k, v] of Object.entries(patch)) {
|
|
147
|
+
if (v === undefined) delete (binding as Record<string, unknown>)[k];
|
|
148
|
+
else (binding as Record<string, unknown>)[k] = v;
|
|
149
|
+
}
|
|
150
|
+
persist();
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function touchConversation(key: string): void {
|
|
154
|
+
const conv = getConversation(key);
|
|
155
|
+
if (!conv) return;
|
|
156
|
+
conv.activeAt = Date.now();
|
|
157
|
+
persist();
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function isExpired(conv: Conversation, now = Date.now()): boolean {
|
|
161
|
+
const ttl = config().sessionTtlMs;
|
|
162
|
+
return ttl > 0 && now - conv.activeAt > ttl;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function listConversations(): Conversation[] {
|
|
166
|
+
return Object.values(load()).map(reconcileCwd);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function deleteConversation(key: string): void {
|
|
170
|
+
delete load()[key];
|
|
171
|
+
persist(true);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** 迁移旧数据用:整条写入。 */
|
|
175
|
+
export function putConversation(conv: Conversation): void {
|
|
176
|
+
load()[conv.key] = conv;
|
|
177
|
+
persist(true);
|
|
178
|
+
}
|