agent-tool-forge 0.3.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.
- package/LICENSE +21 -0
- package/README.md +209 -0
- package/lib/agent-registry.js +170 -0
- package/lib/api-client.js +792 -0
- package/lib/api-loader.js +260 -0
- package/lib/auth.d.ts +25 -0
- package/lib/auth.js +158 -0
- package/lib/checks/check-adapter.js +172 -0
- package/lib/checks/compose.js +42 -0
- package/lib/checks/content-match.js +14 -0
- package/lib/checks/cost-budget.js +11 -0
- package/lib/checks/index.js +18 -0
- package/lib/checks/json-valid.js +15 -0
- package/lib/checks/latency.js +11 -0
- package/lib/checks/length-bounds.js +17 -0
- package/lib/checks/negative-match.js +14 -0
- package/lib/checks/no-hallucinated-numbers.js +63 -0
- package/lib/checks/non-empty.js +34 -0
- package/lib/checks/regex-match.js +12 -0
- package/lib/checks/run-checks.js +84 -0
- package/lib/checks/schema-match.js +26 -0
- package/lib/checks/tool-call-count.js +16 -0
- package/lib/checks/tool-selection.js +34 -0
- package/lib/checks/types.js +45 -0
- package/lib/comparison/compare.js +86 -0
- package/lib/comparison/format.js +104 -0
- package/lib/comparison/index.js +6 -0
- package/lib/comparison/statistics.js +59 -0
- package/lib/comparison/types.js +41 -0
- package/lib/config-schema.js +200 -0
- package/lib/config.d.ts +66 -0
- package/lib/conversation-store.d.ts +77 -0
- package/lib/conversation-store.js +443 -0
- package/lib/db.d.ts +6 -0
- package/lib/db.js +1112 -0
- package/lib/dep-check.js +99 -0
- package/lib/drift-background.js +61 -0
- package/lib/drift-monitor.js +187 -0
- package/lib/eval-runner.js +566 -0
- package/lib/fixtures/fixture-store.js +161 -0
- package/lib/fixtures/index.js +11 -0
- package/lib/forge-engine.js +982 -0
- package/lib/forge-eval-generator.js +417 -0
- package/lib/forge-file-writer.js +386 -0
- package/lib/forge-service-client.js +190 -0
- package/lib/forge-service.d.ts +4 -0
- package/lib/forge-service.js +655 -0
- package/lib/forge-verifier-generator.js +271 -0
- package/lib/handlers/admin.js +151 -0
- package/lib/handlers/agents.js +229 -0
- package/lib/handlers/chat-resume.js +334 -0
- package/lib/handlers/chat-sync.js +320 -0
- package/lib/handlers/chat.js +320 -0
- package/lib/handlers/conversations.js +92 -0
- package/lib/handlers/preferences.js +88 -0
- package/lib/handlers/tools-list.js +58 -0
- package/lib/hitl-engine.d.ts +60 -0
- package/lib/hitl-engine.js +261 -0
- package/lib/http-utils.js +92 -0
- package/lib/index.d.ts +20 -0
- package/lib/index.js +141 -0
- package/lib/init.js +636 -0
- package/lib/manual-entry.js +59 -0
- package/lib/mcp-server.js +252 -0
- package/lib/output-groups.js +54 -0
- package/lib/postgres-store.d.ts +31 -0
- package/lib/postgres-store.js +465 -0
- package/lib/preference-store.d.ts +47 -0
- package/lib/preference-store.js +79 -0
- package/lib/prompt-store.d.ts +42 -0
- package/lib/prompt-store.js +60 -0
- package/lib/rate-limiter.d.ts +30 -0
- package/lib/rate-limiter.js +104 -0
- package/lib/react-engine.d.ts +110 -0
- package/lib/react-engine.js +337 -0
- package/lib/runner/cli.js +156 -0
- package/lib/runner/cost-estimator.js +71 -0
- package/lib/runner/gate.js +46 -0
- package/lib/runner/index.js +165 -0
- package/lib/sidecar.d.ts +83 -0
- package/lib/sidecar.js +161 -0
- package/lib/sse.d.ts +15 -0
- package/lib/sse.js +30 -0
- package/lib/tools-scanner.js +91 -0
- package/lib/tui.js +253 -0
- package/lib/verifier-report.js +78 -0
- package/lib/verifier-runner.js +338 -0
- package/lib/verifier-scanner.js +70 -0
- package/lib/verifier-worker-pool.js +196 -0
- package/lib/views/chat.js +340 -0
- package/lib/views/endpoints.js +203 -0
- package/lib/views/eval-run.js +206 -0
- package/lib/views/forge-agent.js +538 -0
- package/lib/views/forge.js +410 -0
- package/lib/views/main-menu.js +275 -0
- package/lib/views/mediation.js +381 -0
- package/lib/views/model-compare.js +430 -0
- package/lib/views/model-comparison.js +333 -0
- package/lib/views/onboarding.js +470 -0
- package/lib/views/performance.js +237 -0
- package/lib/views/run-evals.js +205 -0
- package/lib/views/settings.js +829 -0
- package/lib/views/tools-evals.js +514 -0
- package/lib/views/verifier-coverage.js +617 -0
- package/lib/workers/verifier-worker.js +52 -0
- package/package.json +123 -0
- package/widget/forge-chat.js +789 -0
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ConversationStore — pluggable persistence for forge-agent conversation history.
|
|
3
|
+
*
|
|
4
|
+
* Two adapters:
|
|
5
|
+
* SqliteConversationStore — default, wraps the existing conversations table in db.js
|
|
6
|
+
* RedisConversationStore — optional, requires the `redis` npm package to be installed
|
|
7
|
+
*
|
|
8
|
+
* Factory:
|
|
9
|
+
* makeConversationStore(config, db?)
|
|
10
|
+
* config.conversation.store === 'redis' → RedisConversationStore
|
|
11
|
+
* anything else (or absent) → SqliteConversationStore
|
|
12
|
+
*
|
|
13
|
+
* Both adapters expose the same async interface so forge-agent.js never
|
|
14
|
+
* needs to know which backend is in use.
|
|
15
|
+
*
|
|
16
|
+
* Redis key schema:
|
|
17
|
+
* forge:conv:<sessionId>:msgs — List of JSON-serialised message rows
|
|
18
|
+
* forge:sessions:active — Set of sessionIds without a [COMPLETE] marker
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { randomUUID } from 'crypto';
|
|
22
|
+
|
|
23
|
+
// ── Shared interface (JSDoc, not enforced at runtime) ──────────────────────
|
|
24
|
+
//
|
|
25
|
+
// interface ConversationStore {
|
|
26
|
+
// createSession(): string
|
|
27
|
+
// persistMessage(sessionId, stage, role, content): Promise<void>
|
|
28
|
+
// getHistory(sessionId): Promise<MessageRow[]>
|
|
29
|
+
// getIncompleteSessions(): Promise<SessionSummary[]>
|
|
30
|
+
// close(): Promise<void>
|
|
31
|
+
// }
|
|
32
|
+
|
|
33
|
+
// ── SQLite adapter ─────────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
export class SqliteConversationStore {
|
|
36
|
+
/** @param {import('better-sqlite3').Database} db */
|
|
37
|
+
constructor(db) {
|
|
38
|
+
this._db = db;
|
|
39
|
+
|
|
40
|
+
// Prepare statements once to avoid repeated compilation
|
|
41
|
+
this._stmtInsert = db.prepare(`
|
|
42
|
+
INSERT INTO conversations (session_id, stage, role, content, agent_id, user_id, created_at)
|
|
43
|
+
VALUES (@session_id, @stage, @role, @content, @agent_id, @user_id, @created_at)
|
|
44
|
+
`);
|
|
45
|
+
this._stmtHistory = db.prepare(`
|
|
46
|
+
SELECT * FROM conversations WHERE session_id = ? ORDER BY created_at ASC
|
|
47
|
+
`);
|
|
48
|
+
this._stmtIncomplete = db.prepare(`
|
|
49
|
+
SELECT
|
|
50
|
+
c.session_id,
|
|
51
|
+
c.stage,
|
|
52
|
+
MAX(c.created_at) AS last_updated
|
|
53
|
+
FROM conversations c
|
|
54
|
+
WHERE c.session_id NOT IN (
|
|
55
|
+
SELECT DISTINCT session_id FROM conversations
|
|
56
|
+
WHERE role = 'system' AND content = '[COMPLETE]'
|
|
57
|
+
)
|
|
58
|
+
GROUP BY c.session_id
|
|
59
|
+
ORDER BY last_updated DESC
|
|
60
|
+
`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
createSession() {
|
|
64
|
+
return randomUUID();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async persistMessage(sessionId, stage, role, content, agentId = null, userId = null) {
|
|
68
|
+
this._stmtInsert.run({
|
|
69
|
+
session_id: sessionId,
|
|
70
|
+
stage,
|
|
71
|
+
role,
|
|
72
|
+
content,
|
|
73
|
+
agent_id: agentId ?? null,
|
|
74
|
+
user_id: userId ?? null,
|
|
75
|
+
created_at: new Date().toISOString()
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async getHistory(sessionId) {
|
|
80
|
+
return this._stmtHistory.all(sessionId);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async getIncompleteSessions() {
|
|
84
|
+
return this._stmtIncomplete.all();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async listSessions(userId) {
|
|
88
|
+
const rows = this._db.prepare(
|
|
89
|
+
`SELECT session_id, agent_id, user_id,
|
|
90
|
+
MAX(created_at) AS last_updated,
|
|
91
|
+
MIN(created_at) AS started_at
|
|
92
|
+
FROM conversations
|
|
93
|
+
WHERE user_id = ?
|
|
94
|
+
GROUP BY session_id
|
|
95
|
+
ORDER BY last_updated DESC`
|
|
96
|
+
).all(userId ?? null);
|
|
97
|
+
return rows.map(r => ({
|
|
98
|
+
sessionId: r.session_id,
|
|
99
|
+
agentId: r.agent_id ?? null,
|
|
100
|
+
userId: r.user_id ?? null,
|
|
101
|
+
startedAt: r.started_at,
|
|
102
|
+
lastUpdated: r.last_updated
|
|
103
|
+
}));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async deleteSession(sessionId, userId) {
|
|
107
|
+
const result = this._db.prepare(
|
|
108
|
+
'DELETE FROM conversations WHERE session_id = ? AND user_id = ?'
|
|
109
|
+
).run(sessionId, userId ?? null);
|
|
110
|
+
return result.changes > 0;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async getSessionUserId(sessionId) {
|
|
114
|
+
const row = this._db.prepare(
|
|
115
|
+
'SELECT user_id FROM conversations WHERE session_id = ? ORDER BY created_at ASC LIMIT 1'
|
|
116
|
+
).get(sessionId);
|
|
117
|
+
if (!row) return undefined;
|
|
118
|
+
return row.user_id ?? null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async close() {
|
|
122
|
+
// SQLite connection managed externally — nothing to tear down here
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ── Redis adapter ──────────────────────────────────────────────────────────
|
|
127
|
+
|
|
128
|
+
const ACTIVE_SET_KEY = 'forge:sessions:active';
|
|
129
|
+
const DEFAULT_TTL_SECONDS = 30 * 24 * 60 * 60; // 30 days
|
|
130
|
+
|
|
131
|
+
export class RedisConversationStore {
|
|
132
|
+
/**
|
|
133
|
+
* @param {object} redisConfig — shape: { url, ttlSeconds? }
|
|
134
|
+
* url e.g. 'redis://localhost:6379' or 'rediss://...' for TLS
|
|
135
|
+
* ttlSeconds message list TTL (default 30 days); refreshed on each write
|
|
136
|
+
*/
|
|
137
|
+
constructor(redisConfig = {}) {
|
|
138
|
+
this._url = redisConfig.url || 'redis://localhost:6379';
|
|
139
|
+
this._ttl = redisConfig.ttlSeconds ?? DEFAULT_TTL_SECONDS;
|
|
140
|
+
this._client = null;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async _connect() {
|
|
144
|
+
if (this._client) return this._client;
|
|
145
|
+
|
|
146
|
+
let createClient;
|
|
147
|
+
try {
|
|
148
|
+
({ createClient } = await import('redis'));
|
|
149
|
+
} catch {
|
|
150
|
+
throw new Error(
|
|
151
|
+
'Redis store requires the "redis" package: run `npm install redis`'
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
this._client = createClient({ url: this._url });
|
|
156
|
+
this._client.on('error', (err) => {
|
|
157
|
+
// Non-fatal — log but don't crash the forge-agent session
|
|
158
|
+
console.error('[conversation-store] Redis error:', err.message);
|
|
159
|
+
});
|
|
160
|
+
await this._client.connect();
|
|
161
|
+
return this._client;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
createSession() {
|
|
165
|
+
return randomUUID();
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async persistMessage(sessionId, stage, role, content, agentId = null, userId = null) {
|
|
169
|
+
const client = await this._connect();
|
|
170
|
+
const msgKey = `forge:conv:${sessionId}:msgs`;
|
|
171
|
+
|
|
172
|
+
const row = JSON.stringify({
|
|
173
|
+
session_id: sessionId,
|
|
174
|
+
stage,
|
|
175
|
+
role,
|
|
176
|
+
content,
|
|
177
|
+
agent_id: agentId ?? null,
|
|
178
|
+
user_id: userId ?? null,
|
|
179
|
+
created_at: new Date().toISOString()
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
// Atomic pipeline — rPush + expire + active-set update in one round-trip
|
|
183
|
+
const pl = client.multi();
|
|
184
|
+
pl.rPush(msgKey, row);
|
|
185
|
+
pl.expire(msgKey, this._ttl);
|
|
186
|
+
if (role === 'system' && content === '[COMPLETE]') {
|
|
187
|
+
pl.sRem(ACTIVE_SET_KEY, sessionId);
|
|
188
|
+
} else {
|
|
189
|
+
pl.sAdd(ACTIVE_SET_KEY, sessionId);
|
|
190
|
+
}
|
|
191
|
+
await pl.exec();
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async getHistory(sessionId) {
|
|
195
|
+
const client = await this._connect();
|
|
196
|
+
const raw = await client.lRange(`forge:conv:${sessionId}:msgs`, 0, -1);
|
|
197
|
+
return raw.map((s) => {
|
|
198
|
+
try { return JSON.parse(s); } catch { return null; }
|
|
199
|
+
}).filter(Boolean);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async getIncompleteSessions() {
|
|
203
|
+
const client = await this._connect();
|
|
204
|
+
const sessionIds = await client.sMembers(ACTIVE_SET_KEY);
|
|
205
|
+
if (sessionIds.length === 0) return [];
|
|
206
|
+
|
|
207
|
+
// For each active session, fetch the last message to get stage + timestamp
|
|
208
|
+
const summaries = await Promise.all(
|
|
209
|
+
sessionIds.map(async (sessionId) => {
|
|
210
|
+
const last = await client.lIndex(`forge:conv:${sessionId}:msgs`, -1);
|
|
211
|
+
if (!last) return null;
|
|
212
|
+
try {
|
|
213
|
+
const row = JSON.parse(last);
|
|
214
|
+
return { session_id: sessionId, stage: row.stage, last_updated: row.created_at };
|
|
215
|
+
} catch {
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
})
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
return summaries
|
|
222
|
+
.filter(Boolean)
|
|
223
|
+
.sort((a, b) => b.last_updated.localeCompare(a.last_updated));
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async listSessions(userId) {
|
|
227
|
+
const client = await this._connect();
|
|
228
|
+
const sessionIds = await client.sMembers(ACTIVE_SET_KEY);
|
|
229
|
+
|
|
230
|
+
const sessionData = await Promise.all(sessionIds.map(async (sessionId) => {
|
|
231
|
+
const [firstMsgRaw, lastMsgRaw] = await Promise.all([
|
|
232
|
+
client.lIndex(`forge:conv:${sessionId}:msgs`, 0),
|
|
233
|
+
client.lIndex(`forge:conv:${sessionId}:msgs`, -1)
|
|
234
|
+
]);
|
|
235
|
+
return { sessionId, firstMsgRaw, lastMsgRaw };
|
|
236
|
+
}));
|
|
237
|
+
|
|
238
|
+
const result = [];
|
|
239
|
+
for (const { sessionId, firstMsgRaw, lastMsgRaw } of sessionData) {
|
|
240
|
+
if (!firstMsgRaw) {
|
|
241
|
+
// stale entry — clean it up from the active set (best-effort, ignore transient errors)
|
|
242
|
+
try { await client.sRem(ACTIVE_SET_KEY, sessionId); } catch { /* ignore */ }
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
try {
|
|
246
|
+
const msg = JSON.parse(firstMsgRaw);
|
|
247
|
+
if (msg.user_id !== userId) continue;
|
|
248
|
+
const last = lastMsgRaw ? JSON.parse(lastMsgRaw) : msg;
|
|
249
|
+
result.push({
|
|
250
|
+
sessionId,
|
|
251
|
+
agentId: msg.agent_id ?? null,
|
|
252
|
+
userId: msg.user_id ?? null,
|
|
253
|
+
startedAt: msg.created_at,
|
|
254
|
+
lastUpdated: last.created_at
|
|
255
|
+
});
|
|
256
|
+
} catch { /* skip malformed */ }
|
|
257
|
+
}
|
|
258
|
+
return result.sort((a, b) => b.lastUpdated.localeCompare(a.lastUpdated));
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
async deleteSession(sessionId, userId) {
|
|
262
|
+
const client = await this._connect();
|
|
263
|
+
// Verify ownership via first message
|
|
264
|
+
// Note: there is a TOCTOU window between the ownership lIndex check and
|
|
265
|
+
// the multi().exec() delete. In practice, message lists are append-only
|
|
266
|
+
// so the first element never changes after creation. Atomic ownership-
|
|
267
|
+
// gated delete would require a Lua EVAL script; accepted as-is.
|
|
268
|
+
const firstMsg = await client.lIndex(`forge:conv:${sessionId}:msgs`, 0);
|
|
269
|
+
if (!firstMsg) return false;
|
|
270
|
+
try {
|
|
271
|
+
const msg = JSON.parse(firstMsg);
|
|
272
|
+
if (msg.user_id !== userId) return false;
|
|
273
|
+
} catch { return false; }
|
|
274
|
+
const pl = client.multi();
|
|
275
|
+
pl.del(`forge:conv:${sessionId}:msgs`);
|
|
276
|
+
pl.sRem(ACTIVE_SET_KEY, sessionId);
|
|
277
|
+
await pl.exec();
|
|
278
|
+
return true;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
async getSessionUserId(sessionId) {
|
|
282
|
+
const client = await this._connect();
|
|
283
|
+
const firstMsg = await client.lIndex(`forge:conv:${sessionId}:msgs`, 0);
|
|
284
|
+
if (!firstMsg) return undefined;
|
|
285
|
+
try {
|
|
286
|
+
const msg = JSON.parse(firstMsg);
|
|
287
|
+
return msg.user_id ?? null;
|
|
288
|
+
} catch { return undefined; }
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
async close() {
|
|
292
|
+
if (this._client) {
|
|
293
|
+
await this._client.quit();
|
|
294
|
+
this._client = null;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// ── Postgres adapter ──────────────────────────────────────────────────────
|
|
300
|
+
|
|
301
|
+
export class PostgresConversationStore {
|
|
302
|
+
/**
|
|
303
|
+
* @param {import('pg').Pool} pgPool — shared Pool instance (not owned by this store)
|
|
304
|
+
*/
|
|
305
|
+
constructor(pgPool) {
|
|
306
|
+
this._pool = pgPool;
|
|
307
|
+
this._tableReady = false;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
async _ensureTable() {
|
|
311
|
+
if (this._tableReady) return;
|
|
312
|
+
await this._pool.query(`
|
|
313
|
+
CREATE TABLE IF NOT EXISTS conversations (
|
|
314
|
+
id SERIAL PRIMARY KEY,
|
|
315
|
+
session_id TEXT NOT NULL,
|
|
316
|
+
stage TEXT,
|
|
317
|
+
role TEXT NOT NULL,
|
|
318
|
+
content TEXT NOT NULL,
|
|
319
|
+
agent_id TEXT,
|
|
320
|
+
user_id TEXT,
|
|
321
|
+
created_at TEXT NOT NULL
|
|
322
|
+
)
|
|
323
|
+
`);
|
|
324
|
+
this._tableReady = true;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
createSession() {
|
|
328
|
+
return randomUUID();
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
async persistMessage(sessionId, stage, role, content, agentId = null, userId = null) {
|
|
332
|
+
await this._ensureTable();
|
|
333
|
+
await this._pool.query(
|
|
334
|
+
`INSERT INTO conversations (session_id, stage, role, content, agent_id, user_id, created_at)
|
|
335
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
|
|
336
|
+
[sessionId, stage, role, content, agentId ?? null, userId ?? null, new Date().toISOString()]
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
async getHistory(sessionId) {
|
|
341
|
+
await this._ensureTable();
|
|
342
|
+
const { rows } = await this._pool.query(
|
|
343
|
+
'SELECT * FROM conversations WHERE session_id = $1 ORDER BY created_at ASC',
|
|
344
|
+
[sessionId]
|
|
345
|
+
);
|
|
346
|
+
return rows;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
async getIncompleteSessions() {
|
|
350
|
+
await this._ensureTable();
|
|
351
|
+
const { rows } = await this._pool.query(`
|
|
352
|
+
SELECT
|
|
353
|
+
c.session_id,
|
|
354
|
+
c.stage,
|
|
355
|
+
MAX(c.created_at) AS last_updated
|
|
356
|
+
FROM conversations c
|
|
357
|
+
WHERE c.session_id NOT IN (
|
|
358
|
+
SELECT DISTINCT session_id FROM conversations
|
|
359
|
+
WHERE role = 'system' AND content = '[COMPLETE]'
|
|
360
|
+
)
|
|
361
|
+
GROUP BY c.session_id, c.stage
|
|
362
|
+
ORDER BY last_updated DESC
|
|
363
|
+
`);
|
|
364
|
+
return rows;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
async listSessions(userId) {
|
|
368
|
+
await this._ensureTable();
|
|
369
|
+
const result = await this._pool.query(
|
|
370
|
+
`SELECT session_id, agent_id, user_id,
|
|
371
|
+
MAX(created_at) AS last_updated,
|
|
372
|
+
MIN(created_at) AS started_at
|
|
373
|
+
FROM conversations
|
|
374
|
+
WHERE user_id = $1
|
|
375
|
+
GROUP BY session_id, agent_id, user_id
|
|
376
|
+
ORDER BY last_updated DESC`,
|
|
377
|
+
[userId ?? null]
|
|
378
|
+
);
|
|
379
|
+
return result.rows.map(r => ({
|
|
380
|
+
sessionId: r.session_id,
|
|
381
|
+
agentId: r.agent_id ?? null,
|
|
382
|
+
userId: r.user_id ?? null,
|
|
383
|
+
startedAt: r.started_at,
|
|
384
|
+
lastUpdated: r.last_updated
|
|
385
|
+
}));
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
async deleteSession(sessionId, userId) {
|
|
389
|
+
await this._ensureTable();
|
|
390
|
+
const result = await this._pool.query(
|
|
391
|
+
'DELETE FROM conversations WHERE session_id = $1 AND user_id = $2',
|
|
392
|
+
[sessionId, userId ?? null]
|
|
393
|
+
);
|
|
394
|
+
return (result.rowCount ?? 0) > 0;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
async getSessionUserId(sessionId) {
|
|
398
|
+
await this._ensureTable();
|
|
399
|
+
const result = await this._pool.query(
|
|
400
|
+
'SELECT user_id FROM conversations WHERE session_id = $1 LIMIT 1',
|
|
401
|
+
[sessionId]
|
|
402
|
+
);
|
|
403
|
+
if (result.rows.length === 0) return undefined;
|
|
404
|
+
return result.rows[0].user_id ?? null;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
async close() {
|
|
408
|
+
// Pool is shared — not owned by this store, so don't close it here
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// ── Factory ────────────────────────────────────────────────────────────────
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Build the appropriate ConversationStore from forge config.
|
|
416
|
+
*
|
|
417
|
+
* @param {object} config — forge.config.json contents
|
|
418
|
+
* @param {import('better-sqlite3').Database|null} db — required for SQLite store
|
|
419
|
+
* @param {import('pg').Pool|null} pgPool — required for Postgres store
|
|
420
|
+
* @returns {SqliteConversationStore|RedisConversationStore|PostgresConversationStore}
|
|
421
|
+
*/
|
|
422
|
+
export function makeConversationStore(config, db = null, pgPool = null) {
|
|
423
|
+
const storeType = config?.conversation?.store ?? 'sqlite';
|
|
424
|
+
|
|
425
|
+
if (storeType === 'redis') {
|
|
426
|
+
const redisConfig = config?.conversation?.redis ?? {};
|
|
427
|
+
return new RedisConversationStore(redisConfig);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
if (storeType === 'postgres') {
|
|
431
|
+
if (!pgPool) {
|
|
432
|
+
throw new Error('makeConversationStore: Postgres store requires a pgPool instance');
|
|
433
|
+
}
|
|
434
|
+
return new PostgresConversationStore(pgPool);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
if (!db) {
|
|
438
|
+
throw new Error(
|
|
439
|
+
'makeConversationStore: SQLite store requires a db instance'
|
|
440
|
+
);
|
|
441
|
+
}
|
|
442
|
+
return new SqliteConversationStore(db);
|
|
443
|
+
}
|
package/lib/db.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// Database handle — better-sqlite3 synchronous API.
|
|
2
|
+
// Typed as `object` to avoid requiring @types/better-sqlite3 as a peer dep.
|
|
3
|
+
export type Db = object;
|
|
4
|
+
|
|
5
|
+
/** Open (or create) the SQLite database at `path` and run the full schema migration. */
|
|
6
|
+
export function getDb(path: string): Db;
|