@chenacc/agentbridge 0.0.0-stage → 0.1.0

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,283 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { mkdir, open, readFile, rename } from "node:fs/promises";
3
+ import path from "node:path";
4
+
5
+ const SCHEMA_VERSION = 1;
6
+ const LIFECYCLES = new Set(["offline", "starting", "ready", "running", "failed", "closed"]);
7
+
8
+ function copy(record) {
9
+ return record ? { ...record } : null;
10
+ }
11
+
12
+ function validTimestamp(value, fallback) {
13
+ if (typeof value !== "string" || Number.isNaN(Date.parse(value))) return fallback;
14
+ return new Date(value).toISOString();
15
+ }
16
+
17
+ export class ConversationCatalog {
18
+ #items = new Map();
19
+ #writes = Promise.resolve();
20
+ #revision = 0;
21
+
22
+ constructor({ filePath, now = () => new Date().toISOString() }) {
23
+ this.filePath = filePath;
24
+ this.now = now;
25
+ }
26
+
27
+ async init() {
28
+ await mkdir(path.dirname(this.filePath), { recursive: true });
29
+ let parsed;
30
+ try {
31
+ parsed = JSON.parse(await readFile(this.filePath, "utf8"));
32
+ } catch (error) {
33
+ if (error.code === "ENOENT") return this;
34
+ throw Object.assign(new Error(`Conversation catalog could not be read: ${error.message}`), { cause: error });
35
+ }
36
+ if (parsed?.schemaVersion !== SCHEMA_VERSION || !Array.isArray(parsed.items)) {
37
+ throw new Error("Conversation catalog has an unsupported schema");
38
+ }
39
+ this.#revision = Number.isSafeInteger(parsed.revision) ? parsed.revision : 0;
40
+ let changed = false;
41
+ for (const item of parsed.items) {
42
+ const record = this.#validate(item);
43
+ if (record.lifecycle !== "closed" && record.lifecycle !== "offline") {
44
+ record.lifecycle = "offline";
45
+ record.updatedAt = this.now();
46
+ changed = true;
47
+ }
48
+ this.#items.set(record.id, record);
49
+ }
50
+ if (changed) {
51
+ this.#revision += 1;
52
+ await this.#persist();
53
+ }
54
+ return this;
55
+ }
56
+
57
+ get revision() {
58
+ return this.#revision;
59
+ }
60
+
61
+ list() {
62
+ return [...this.#items.values()]
63
+ .map(copy)
64
+ .sort((left, right) => (right.lastActiveAt ?? right.updatedAt).localeCompare(left.lastActiveAt ?? left.updatedAt));
65
+ }
66
+
67
+ get(id) {
68
+ return copy(this.#items.get(id));
69
+ }
70
+
71
+ findByProvider(adapterId, providerConversationId) {
72
+ if (!providerConversationId) return null;
73
+ return copy([...this.#items.values()].find((record) => (
74
+ record.adapterId === adapterId && record.providerConversationId === providerConversationId
75
+ )));
76
+ }
77
+
78
+ async create(input) {
79
+ const timestamp = this.now();
80
+ const record = this.#validate({
81
+ id: `bs_${randomUUID()}`,
82
+ workspaceId: input.workspaceId ?? null,
83
+ projectAlias: input.projectAlias,
84
+ adapterId: input.adapterId,
85
+ providerConversationId: input.providerConversationId ?? null,
86
+ title: input.title,
87
+ origin: input.origin ?? "phone-created",
88
+ lifecycle: input.lifecycle ?? "starting",
89
+ resumable: input.resumable === true,
90
+ attached: input.attached === true,
91
+ lastError: input.lastError ?? null,
92
+ modelDirectory: input.modelDirectory ?? null,
93
+ lastActiveAt: input.lastActiveAt ?? timestamp,
94
+ createdAt: input.createdAt ?? timestamp,
95
+ updatedAt: timestamp,
96
+ });
97
+ this.#items.set(record.id, record);
98
+ this.#revision += 1;
99
+ await this.#persist();
100
+ return copy(record);
101
+ }
102
+
103
+ async update(id, patch) {
104
+ const current = this.#items.get(id);
105
+ if (!current) return null;
106
+ const record = this.#validate({
107
+ ...current,
108
+ ...patch,
109
+ id: current.id,
110
+ createdAt: current.createdAt,
111
+ updatedAt: this.now(),
112
+ });
113
+ this.#items.set(id, record);
114
+ this.#revision += 1;
115
+ await this.#persist();
116
+ return copy(record);
117
+ }
118
+
119
+ async mergeDiscovered({ workspaceId, projectAlias, adapterId, conversations, resumable = true }) {
120
+ if (!Array.isArray(conversations)) throw new Error("Discovered conversations must be an array");
121
+ const createdIds = [];
122
+ const updatedIds = [];
123
+ const merged = [];
124
+ const providerIds = new Set();
125
+ let changed = false;
126
+
127
+ for (const conversation of conversations) {
128
+ const providerConversationId = typeof conversation?.providerConversationId === "string"
129
+ ? conversation.providerConversationId.slice(0, 200)
130
+ : "";
131
+ if (!providerConversationId || providerIds.has(providerConversationId)) continue;
132
+ providerIds.add(providerConversationId);
133
+ const existing = [...this.#items.values()].find((record) => (
134
+ record.adapterId === adapterId && record.providerConversationId === providerConversationId
135
+ ));
136
+ const timestamp = this.now();
137
+ const title = String(conversation.title || "Agent conversation").slice(0, 100);
138
+ const discoveredLastActiveAt = validTimestamp(conversation.lastActiveAt, existing?.lastActiveAt ?? timestamp);
139
+
140
+ if (!existing) {
141
+ const record = this.#validate({
142
+ id: `bs_${randomUUID()}`,
143
+ workspaceId,
144
+ projectAlias,
145
+ adapterId,
146
+ providerConversationId,
147
+ title,
148
+ origin: "provider-discovered",
149
+ lifecycle: "offline",
150
+ resumable,
151
+ attached: false,
152
+ lastError: null,
153
+ lastActiveAt: discoveredLastActiveAt,
154
+ createdAt: validTimestamp(conversation.createdAt, timestamp),
155
+ updatedAt: timestamp,
156
+ });
157
+ this.#items.set(record.id, record);
158
+ createdIds.push(record.id);
159
+ merged.push(copy(record));
160
+ changed = true;
161
+ continue;
162
+ }
163
+
164
+ const lastActiveAt = discoveredLastActiveAt > existing.lastActiveAt
165
+ ? discoveredLastActiveAt
166
+ : existing.lastActiveAt;
167
+ const nextTitle = existing.origin === "provider-discovered" ? title : existing.title;
168
+ const needsUpdate = existing.workspaceId !== workspaceId
169
+ || existing.projectAlias !== projectAlias
170
+ || existing.resumable !== resumable
171
+ || existing.lastActiveAt !== lastActiveAt
172
+ || existing.title !== nextTitle;
173
+ if (!needsUpdate) {
174
+ merged.push(copy(existing));
175
+ continue;
176
+ }
177
+ const record = this.#validate({
178
+ ...existing,
179
+ workspaceId,
180
+ projectAlias,
181
+ title: nextTitle,
182
+ resumable,
183
+ lastActiveAt,
184
+ updatedAt: timestamp,
185
+ });
186
+ this.#items.set(record.id, record);
187
+ updatedIds.push(record.id);
188
+ merged.push(copy(record));
189
+ changed = true;
190
+ }
191
+
192
+ if (changed) {
193
+ this.#revision += 1;
194
+ await this.#persist();
195
+ }
196
+ return { items: merged, createdIds, updatedIds };
197
+ }
198
+
199
+ #validate(input) {
200
+ if (!input || typeof input.id !== "string" || !input.id.startsWith("bs_")) throw new Error("Conversation catalog contains an invalid id");
201
+ if (typeof input.adapterId !== "string" || !input.adapterId) throw new Error("Conversation catalog contains an invalid adapter id");
202
+ if (typeof input.projectAlias !== "string" || !input.projectAlias) throw new Error("Conversation catalog contains an invalid project alias");
203
+ const lifecycle = LIFECYCLES.has(input.lifecycle) ? input.lifecycle : "offline";
204
+ return {
205
+ id: input.id,
206
+ workspaceId: typeof input.workspaceId === "string" ? input.workspaceId : null,
207
+ projectAlias: input.projectAlias,
208
+ adapterId: input.adapterId,
209
+ providerConversationId: typeof input.providerConversationId === "string" ? input.providerConversationId : null,
210
+ title: String(input.title || "Agent conversation").slice(0, 100),
211
+ origin: String(input.origin || "phone-created").slice(0, 40),
212
+ lifecycle,
213
+ resumable: input.resumable === true,
214
+ attached: input.attached === true,
215
+ lastError: typeof input.lastError === "string" ? input.lastError.slice(-2000) : null,
216
+ modelDirectory: this.#validateModelDirectory(input.modelDirectory),
217
+ lastActiveAt: validTimestamp(input.lastActiveAt, validTimestamp(input.updatedAt, this.now())),
218
+ createdAt: validTimestamp(input.createdAt, this.now()),
219
+ updatedAt: validTimestamp(input.updatedAt, validTimestamp(input.createdAt, this.now())),
220
+ };
221
+ }
222
+
223
+ // Persisted so a conversation whose Agent is offline still describes the model
224
+ // it was last on. Shape check only: the session manager writes a compact form
225
+ // and a hand-edited catalog must not be able to blow it up.
226
+ #validateModelDirectory(value) {
227
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
228
+ const groups = Array.isArray(value.groups) ? value.groups : [];
229
+ const current = value.current && typeof value.current === "object" ? value.current : null;
230
+ const failures = Array.isArray(value.failures) ? value.failures : [];
231
+ if (!groups.length && !current && !failures.length) return null;
232
+ return {
233
+ current: current
234
+ ? {
235
+ provider: String(current.provider ?? "").slice(0, 100),
236
+ model: String(current.model ?? "").slice(0, 160),
237
+ ...(typeof current.reasoningEffort === "string" ? { reasoningEffort: current.reasoningEffort.slice(0, 100) } : {}),
238
+ }
239
+ : null,
240
+ routable: value.routable === true,
241
+ groups: groups.slice(0, 20).map((group) => ({
242
+ id: String(group?.id ?? "").slice(0, 100),
243
+ name: String(group?.name ?? group?.id ?? "").slice(0, 160),
244
+ models: (Array.isArray(group?.models) ? group.models : []).slice(0, 40).map((model) => ({
245
+ id: String(model?.id ?? "").slice(0, 160),
246
+ name: String(model?.name ?? model?.id ?? "").slice(0, 160),
247
+ ...(Array.isArray(model?.reasoning?.efforts) && model.reasoning.efforts.length
248
+ ? { reasoning: { efforts: model.reasoning.efforts.slice(0, 8).map((effort) => ({
249
+ id: String(effort?.id ?? "").slice(0, 100),
250
+ name: String(effort?.name ?? effort?.id ?? "").slice(0, 160),
251
+ })) } }
252
+ : {}),
253
+ })).filter((model) => model.id),
254
+ })).filter((group) => group.id),
255
+ failures: failures.slice(0, 5).map((failure) => ({
256
+ id: String(failure?.id ?? "").slice(0, 100),
257
+ name: String(failure?.name ?? failure?.id ?? "").slice(0, 160),
258
+ message: String(failure?.message ?? "").slice(0, 300),
259
+ })),
260
+ };
261
+ }
262
+
263
+ #persist() {
264
+ const payload = JSON.stringify({
265
+ schemaVersion: SCHEMA_VERSION,
266
+ revision: this.#revision,
267
+ items: this.list(),
268
+ });
269
+ const operation = this.#writes.catch(() => undefined).then(async () => {
270
+ const temporaryPath = `${this.filePath}.tmp`;
271
+ const handle = await open(temporaryPath, "w");
272
+ try {
273
+ await handle.writeFile(payload, "utf8");
274
+ await handle.sync();
275
+ } finally {
276
+ await handle.close();
277
+ }
278
+ await rename(temporaryPath, this.filePath);
279
+ });
280
+ this.#writes = operation;
281
+ return operation;
282
+ }
283
+ }