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.
Files changed (107) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +209 -0
  3. package/lib/agent-registry.js +170 -0
  4. package/lib/api-client.js +792 -0
  5. package/lib/api-loader.js +260 -0
  6. package/lib/auth.d.ts +25 -0
  7. package/lib/auth.js +158 -0
  8. package/lib/checks/check-adapter.js +172 -0
  9. package/lib/checks/compose.js +42 -0
  10. package/lib/checks/content-match.js +14 -0
  11. package/lib/checks/cost-budget.js +11 -0
  12. package/lib/checks/index.js +18 -0
  13. package/lib/checks/json-valid.js +15 -0
  14. package/lib/checks/latency.js +11 -0
  15. package/lib/checks/length-bounds.js +17 -0
  16. package/lib/checks/negative-match.js +14 -0
  17. package/lib/checks/no-hallucinated-numbers.js +63 -0
  18. package/lib/checks/non-empty.js +34 -0
  19. package/lib/checks/regex-match.js +12 -0
  20. package/lib/checks/run-checks.js +84 -0
  21. package/lib/checks/schema-match.js +26 -0
  22. package/lib/checks/tool-call-count.js +16 -0
  23. package/lib/checks/tool-selection.js +34 -0
  24. package/lib/checks/types.js +45 -0
  25. package/lib/comparison/compare.js +86 -0
  26. package/lib/comparison/format.js +104 -0
  27. package/lib/comparison/index.js +6 -0
  28. package/lib/comparison/statistics.js +59 -0
  29. package/lib/comparison/types.js +41 -0
  30. package/lib/config-schema.js +200 -0
  31. package/lib/config.d.ts +66 -0
  32. package/lib/conversation-store.d.ts +77 -0
  33. package/lib/conversation-store.js +443 -0
  34. package/lib/db.d.ts +6 -0
  35. package/lib/db.js +1112 -0
  36. package/lib/dep-check.js +99 -0
  37. package/lib/drift-background.js +61 -0
  38. package/lib/drift-monitor.js +187 -0
  39. package/lib/eval-runner.js +566 -0
  40. package/lib/fixtures/fixture-store.js +161 -0
  41. package/lib/fixtures/index.js +11 -0
  42. package/lib/forge-engine.js +982 -0
  43. package/lib/forge-eval-generator.js +417 -0
  44. package/lib/forge-file-writer.js +386 -0
  45. package/lib/forge-service-client.js +190 -0
  46. package/lib/forge-service.d.ts +4 -0
  47. package/lib/forge-service.js +655 -0
  48. package/lib/forge-verifier-generator.js +271 -0
  49. package/lib/handlers/admin.js +151 -0
  50. package/lib/handlers/agents.js +229 -0
  51. package/lib/handlers/chat-resume.js +334 -0
  52. package/lib/handlers/chat-sync.js +320 -0
  53. package/lib/handlers/chat.js +320 -0
  54. package/lib/handlers/conversations.js +92 -0
  55. package/lib/handlers/preferences.js +88 -0
  56. package/lib/handlers/tools-list.js +58 -0
  57. package/lib/hitl-engine.d.ts +60 -0
  58. package/lib/hitl-engine.js +261 -0
  59. package/lib/http-utils.js +92 -0
  60. package/lib/index.d.ts +20 -0
  61. package/lib/index.js +141 -0
  62. package/lib/init.js +636 -0
  63. package/lib/manual-entry.js +59 -0
  64. package/lib/mcp-server.js +252 -0
  65. package/lib/output-groups.js +54 -0
  66. package/lib/postgres-store.d.ts +31 -0
  67. package/lib/postgres-store.js +465 -0
  68. package/lib/preference-store.d.ts +47 -0
  69. package/lib/preference-store.js +79 -0
  70. package/lib/prompt-store.d.ts +42 -0
  71. package/lib/prompt-store.js +60 -0
  72. package/lib/rate-limiter.d.ts +30 -0
  73. package/lib/rate-limiter.js +104 -0
  74. package/lib/react-engine.d.ts +110 -0
  75. package/lib/react-engine.js +337 -0
  76. package/lib/runner/cli.js +156 -0
  77. package/lib/runner/cost-estimator.js +71 -0
  78. package/lib/runner/gate.js +46 -0
  79. package/lib/runner/index.js +165 -0
  80. package/lib/sidecar.d.ts +83 -0
  81. package/lib/sidecar.js +161 -0
  82. package/lib/sse.d.ts +15 -0
  83. package/lib/sse.js +30 -0
  84. package/lib/tools-scanner.js +91 -0
  85. package/lib/tui.js +253 -0
  86. package/lib/verifier-report.js +78 -0
  87. package/lib/verifier-runner.js +338 -0
  88. package/lib/verifier-scanner.js +70 -0
  89. package/lib/verifier-worker-pool.js +196 -0
  90. package/lib/views/chat.js +340 -0
  91. package/lib/views/endpoints.js +203 -0
  92. package/lib/views/eval-run.js +206 -0
  93. package/lib/views/forge-agent.js +538 -0
  94. package/lib/views/forge.js +410 -0
  95. package/lib/views/main-menu.js +275 -0
  96. package/lib/views/mediation.js +381 -0
  97. package/lib/views/model-compare.js +430 -0
  98. package/lib/views/model-comparison.js +333 -0
  99. package/lib/views/onboarding.js +470 -0
  100. package/lib/views/performance.js +237 -0
  101. package/lib/views/run-evals.js +205 -0
  102. package/lib/views/settings.js +829 -0
  103. package/lib/views/tools-evals.js +514 -0
  104. package/lib/views/verifier-coverage.js +617 -0
  105. package/lib/workers/verifier-worker.js +52 -0
  106. package/package.json +123 -0
  107. 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;