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,465 @@
1
+ /**
2
+ * PostgresStore — Postgres-backed storage adapter for horizontal scaling.
3
+ *
4
+ * Mirrors the SQLite query function signatures from db.js but uses the `pg` Pool.
5
+ * Optional — only loaded when conversation.store === 'postgres' in config.
6
+ * Requires: npm install pg
7
+ */
8
+
9
+ const SCHEMA = `
10
+ CREATE TABLE IF NOT EXISTS agent_registry (
11
+ agent_id TEXT PRIMARY KEY,
12
+ display_name TEXT NOT NULL,
13
+ description TEXT,
14
+ system_prompt TEXT,
15
+ default_model TEXT,
16
+ default_hitl_level TEXT CHECK(default_hitl_level IN ('autonomous','cautious','standard','paranoid')),
17
+ allow_user_model_select INTEGER NOT NULL DEFAULT 0,
18
+ allow_user_hitl_config INTEGER NOT NULL DEFAULT 0,
19
+ tool_allowlist TEXT NOT NULL DEFAULT '*',
20
+ max_turns INTEGER,
21
+ max_tokens INTEGER,
22
+ is_default INTEGER NOT NULL DEFAULT 0,
23
+ enabled INTEGER NOT NULL DEFAULT 1,
24
+ seeded_from_config INTEGER NOT NULL DEFAULT 0,
25
+ created_at TIMESTAMPTZ DEFAULT NOW(),
26
+ updated_at TIMESTAMPTZ DEFAULT NOW()
27
+ );
28
+
29
+ CREATE TABLE IF NOT EXISTS prompt_versions (
30
+ id SERIAL PRIMARY KEY,
31
+ version TEXT NOT NULL,
32
+ content TEXT NOT NULL,
33
+ is_active INTEGER NOT NULL DEFAULT 0,
34
+ created_at TEXT NOT NULL,
35
+ activated_at TEXT,
36
+ notes TEXT
37
+ );
38
+
39
+ CREATE TABLE IF NOT EXISTS user_preferences (
40
+ user_id TEXT PRIMARY KEY,
41
+ model TEXT,
42
+ hitl_level TEXT CHECK(hitl_level IN ('autonomous','cautious','standard','paranoid')),
43
+ updated_at TEXT NOT NULL
44
+ );
45
+
46
+ CREATE TABLE IF NOT EXISTS tool_registry (
47
+ tool_name TEXT PRIMARY KEY,
48
+ spec_json TEXT,
49
+ lifecycle_state TEXT NOT NULL DEFAULT 'candidate',
50
+ promoted_at TEXT,
51
+ flagged_at TEXT,
52
+ retired_at TEXT,
53
+ version TEXT DEFAULT '1.0.0',
54
+ replaced_by TEXT,
55
+ baseline_pass_rate REAL
56
+ );
57
+
58
+ CREATE TABLE IF NOT EXISTS verifier_results (
59
+ id SERIAL PRIMARY KEY,
60
+ session_id TEXT,
61
+ tool_name TEXT NOT NULL,
62
+ verifier_name TEXT NOT NULL,
63
+ outcome TEXT NOT NULL CHECK(outcome IN ('pass','warn','block')),
64
+ message TEXT,
65
+ tool_call_input TEXT,
66
+ tool_call_output TEXT,
67
+ created_at TEXT NOT NULL
68
+ );
69
+
70
+ CREATE INDEX IF NOT EXISTS idx_verifier_results_tool
71
+ ON verifier_results(tool_name, created_at);
72
+ `;
73
+
74
+ export class PostgresStore {
75
+ /**
76
+ * @param {{ connectionString: string }} pgConfig
77
+ */
78
+ constructor(pgConfig) {
79
+ this._pgConfig = pgConfig;
80
+ this._pool = null;
81
+ }
82
+
83
+ async connect() {
84
+ let pg;
85
+ try {
86
+ pg = await import('pg');
87
+ } catch {
88
+ throw new Error('PostgresStore requires the "pg" package: run `npm install pg`');
89
+ }
90
+ const Pool = pg.default?.Pool ?? pg.Pool;
91
+ this._pool = new Pool({ connectionString: this._pgConfig.connectionString });
92
+ await this._pool.query(SCHEMA);
93
+ return this;
94
+ }
95
+
96
+ async close() {
97
+ if (this._pool) {
98
+ await this._pool.end();
99
+ this._pool = null;
100
+ }
101
+ }
102
+
103
+ // ── Prompt versions ───────────────────────────────────────────────────
104
+
105
+ async getActivePrompt() {
106
+ const { rows } = await this._pool.query(
107
+ 'SELECT * FROM prompt_versions WHERE is_active = 1 LIMIT 1'
108
+ );
109
+ return rows[0] ?? null;
110
+ }
111
+
112
+ async insertPromptVersion(row) {
113
+ const { rows } = await this._pool.query(
114
+ `INSERT INTO prompt_versions (version, content, is_active, created_at, notes)
115
+ VALUES ($1, $2, 0, $3, $4) RETURNING id`,
116
+ [row.version, row.content, new Date().toISOString(), row.notes ?? null]
117
+ );
118
+ return rows[0]?.id ?? null;
119
+ }
120
+
121
+ async activatePromptVersion(id) {
122
+ const client = await this._pool.connect();
123
+ try {
124
+ await client.query('BEGIN');
125
+ await client.query('UPDATE prompt_versions SET is_active = 0, activated_at = NULL WHERE is_active = 1');
126
+ await client.query('UPDATE prompt_versions SET is_active = 1, activated_at = $1 WHERE id = $2',
127
+ [new Date().toISOString(), id]);
128
+ await client.query('COMMIT');
129
+ } catch (err) {
130
+ await client.query('ROLLBACK');
131
+ throw err;
132
+ } finally {
133
+ client.release();
134
+ }
135
+ }
136
+
137
+ // ── User preferences ──────────────────────────────────────────────────
138
+
139
+ async getUserPreferences(userId) {
140
+ const { rows } = await this._pool.query(
141
+ 'SELECT * FROM user_preferences WHERE user_id = $1', [userId]
142
+ );
143
+ return rows[0] ?? null;
144
+ }
145
+
146
+ async upsertUserPreferences(userId, prefs) {
147
+ await this._pool.query(
148
+ `INSERT INTO user_preferences (user_id, model, hitl_level, updated_at)
149
+ VALUES ($1, $2, $3, $4)
150
+ ON CONFLICT (user_id) DO UPDATE SET
151
+ model = $2, hitl_level = $3, updated_at = $4`,
152
+ [userId, prefs.model ?? null, prefs.hitlLevel ?? null, new Date().toISOString()]
153
+ );
154
+ }
155
+
156
+ // ── Tool registry (read-only from sidecar) ────────────────────────────
157
+
158
+ async getPromotedTools() {
159
+ const { rows } = await this._pool.query(
160
+ "SELECT * FROM tool_registry WHERE lifecycle_state = 'promoted' ORDER BY tool_name"
161
+ );
162
+ return rows;
163
+ }
164
+
165
+ // ── Verifier results ──────────────────────────────────────────────────
166
+
167
+ async insertVerifierResult(row) {
168
+ const { rows } = await this._pool.query(
169
+ `INSERT INTO verifier_results
170
+ (session_id, tool_name, verifier_name, outcome, message, tool_call_input, tool_call_output, created_at)
171
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id`,
172
+ [
173
+ row.session_id ?? null, row.tool_name, row.verifier_name, row.outcome,
174
+ row.message ?? null, row.tool_call_input ?? null, row.tool_call_output ?? null,
175
+ new Date().toISOString()
176
+ ]
177
+ );
178
+ return rows[0]?.id ?? null;
179
+ }
180
+ }
181
+
182
+ // ── PostgresPromptStore ────────────────────────────────────────────────────
183
+
184
+ /**
185
+ * Postgres-backed PromptStore — same interface as PromptStore in prompt-store.js.
186
+ * Uses an existing pg.Pool instance (created by buildSidecarContext).
187
+ */
188
+ export class PostgresPromptStore {
189
+ /** @param {import('pg').Pool} pool */
190
+ constructor(pool) {
191
+ this._pool = pool;
192
+ }
193
+
194
+ async getActivePrompt() {
195
+ const { rows } = await this._pool.query(
196
+ 'SELECT * FROM prompt_versions WHERE is_active = 1 LIMIT 1'
197
+ );
198
+ const row = rows[0] ?? null;
199
+ return row ? row.content : '';
200
+ }
201
+
202
+ async getAllVersions() {
203
+ const { rows } = await this._pool.query(
204
+ 'SELECT * FROM prompt_versions ORDER BY id DESC'
205
+ );
206
+ return rows;
207
+ }
208
+
209
+ async createVersion(version, content, notes = null) {
210
+ const { rows } = await this._pool.query(
211
+ `INSERT INTO prompt_versions (version, content, is_active, created_at, notes)
212
+ VALUES ($1, $2, 0, $3, $4) RETURNING id`,
213
+ [version, content, new Date().toISOString(), notes]
214
+ );
215
+ return rows[0]?.id ?? null;
216
+ }
217
+
218
+ async activate(id) {
219
+ const client = await this._pool.connect();
220
+ try {
221
+ await client.query('BEGIN');
222
+ await client.query('UPDATE prompt_versions SET is_active = 0, activated_at = NULL WHERE is_active = 1');
223
+ await client.query('UPDATE prompt_versions SET is_active = 1, activated_at = $1 WHERE id = $2',
224
+ [new Date().toISOString(), id]);
225
+ await client.query('COMMIT');
226
+ } catch (err) {
227
+ await client.query('ROLLBACK');
228
+ throw err;
229
+ } finally {
230
+ client.release();
231
+ }
232
+ }
233
+
234
+ async getVersion(id) {
235
+ const { rows } = await this._pool.query(
236
+ 'SELECT * FROM prompt_versions WHERE id = $1', [id]
237
+ );
238
+ return rows[0] ?? null;
239
+ }
240
+ }
241
+
242
+ // ── PostgresPreferenceStore ────────────────────────────────────────────────
243
+
244
+ const VALID_HITL_LEVELS_PG = ['autonomous', 'cautious', 'standard', 'paranoid'];
245
+
246
+ /**
247
+ * Postgres-backed PreferenceStore — same interface as PreferenceStore in preference-store.js.
248
+ */
249
+ export class PostgresPreferenceStore {
250
+ /**
251
+ * @param {import('pg').Pool} pool
252
+ * @param {object} config — forge config (for detectProvider / resolveApiKey)
253
+ * @param {object} [env] — environment variables
254
+ */
255
+ constructor(pool, config = {}, env = {}) {
256
+ this._pool = pool;
257
+ this._config = config;
258
+ this._env = env;
259
+ }
260
+
261
+ async getUserPreferences(userId) {
262
+ const { rows } = await this._pool.query(
263
+ 'SELECT * FROM user_preferences WHERE user_id = $1', [userId]
264
+ );
265
+ const row = rows[0] ?? null;
266
+ if (!row) return null;
267
+ return { model: row.model, hitlLevel: row.hitl_level };
268
+ }
269
+
270
+ async setUserPreferences(userId, prefs) {
271
+ if (prefs.hitlLevel && !VALID_HITL_LEVELS_PG.includes(prefs.hitlLevel)) {
272
+ throw new Error(`Invalid hitlLevel: ${prefs.hitlLevel}. Must be one of: ${VALID_HITL_LEVELS_PG.join(', ')}`);
273
+ }
274
+ await this._pool.query(
275
+ `INSERT INTO user_preferences (user_id, model, hitl_level, updated_at)
276
+ VALUES ($1, $2, $3, $4)
277
+ ON CONFLICT (user_id) DO UPDATE SET
278
+ model = $2, hitl_level = $3, updated_at = $4`,
279
+ [userId, prefs.model ?? null, prefs.hitlLevel ?? null, new Date().toISOString()]
280
+ );
281
+ }
282
+
283
+ async resolveEffective(userId, config, env = {}) {
284
+ const { detectProvider, resolveApiKey } = await import('./api-client.js');
285
+ const userPrefs = await this.getUserPreferences(userId);
286
+ const model = (config.allowUserModelSelect && userPrefs?.model)
287
+ ? userPrefs.model
288
+ : (config.defaultModel ?? 'claude-sonnet-4-6');
289
+ const hitlLevel = (config.allowUserHitlConfig && userPrefs?.hitlLevel)
290
+ ? userPrefs.hitlLevel
291
+ : (config.defaultHitlLevel ?? 'cautious');
292
+ const provider = detectProvider(model);
293
+ const apiKey = resolveApiKey(provider, env);
294
+ return { model, hitlLevel, provider, apiKey };
295
+ }
296
+ }
297
+
298
+ // ── PostgresAgentRegistry ──────────────────────────────────────────────────
299
+
300
+ /**
301
+ * Postgres-backed AgentRegistry — same interface as AgentRegistry in agent-registry.js.
302
+ */
303
+ export class PostgresAgentRegistry {
304
+ /**
305
+ * @param {object} config — merged forge config
306
+ * @param {import('pg').Pool} pool
307
+ */
308
+ constructor(config, pool) {
309
+ this._config = config;
310
+ this._pool = pool;
311
+ }
312
+
313
+ async resolveAgent(agentId) {
314
+ if (!agentId) {
315
+ const { rows } = await this._pool.query(
316
+ 'SELECT * FROM agent_registry WHERE is_default = 1 AND enabled = 1 LIMIT 1'
317
+ );
318
+ return rows[0] ?? null;
319
+ }
320
+ const { rows } = await this._pool.query(
321
+ 'SELECT * FROM agent_registry WHERE agent_id = $1', [agentId]
322
+ );
323
+ const agent = rows[0] ?? null;
324
+ if (!agent || !agent.enabled) return null;
325
+ return agent;
326
+ }
327
+
328
+ filterTools(loaded, agent) {
329
+ if (!agent) return loaded;
330
+ const allowlist = agent.tool_allowlist;
331
+ if (!allowlist || allowlist === '*') return loaded;
332
+ let allowed;
333
+ try { allowed = JSON.parse(allowlist); } catch { return { toolRows: [], tools: [] }; }
334
+ if (!Array.isArray(allowed)) return { toolRows: [], tools: [] };
335
+ const allowSet = new Set(allowed);
336
+ return {
337
+ toolRows: loaded.toolRows.filter(r => allowSet.has(r.tool_name)),
338
+ tools: loaded.tools.filter(t => allowSet.has(t.name))
339
+ };
340
+ }
341
+
342
+ buildAgentConfig(baseConfig, agent) {
343
+ if (!agent) return baseConfig;
344
+ const scoped = { ...baseConfig };
345
+ if (agent.default_model) scoped.defaultModel = agent.default_model;
346
+ if (agent.default_hitl_level) scoped.defaultHitlLevel = agent.default_hitl_level;
347
+ if (agent.allow_user_model_select) scoped.allowUserModelSelect = true;
348
+ if (agent.allow_user_hitl_config) scoped.allowUserHitlConfig = true;
349
+ if (agent.max_turns != null) scoped.maxTurns = agent.max_turns;
350
+ if (agent.max_tokens != null) scoped.maxTokens = agent.max_tokens;
351
+ return scoped;
352
+ }
353
+
354
+ async resolveSystemPrompt(agent, promptStore, config) {
355
+ if (agent?.system_prompt) return agent.system_prompt;
356
+ const active = typeof promptStore.getActivePrompt === 'function'
357
+ ? await promptStore.getActivePrompt()
358
+ : null;
359
+ if (active) return active;
360
+ return config.systemPrompt || 'You are a helpful assistant.';
361
+ }
362
+
363
+ async getAgent(agentId) {
364
+ const { rows } = await this._pool.query(
365
+ 'SELECT * FROM agent_registry WHERE agent_id = $1', [agentId]
366
+ );
367
+ return rows[0] ?? null;
368
+ }
369
+
370
+ async getAllAgents() {
371
+ const { rows } = await this._pool.query(
372
+ 'SELECT * FROM agent_registry ORDER BY display_name'
373
+ );
374
+ return rows;
375
+ }
376
+
377
+ async upsertAgent(row) {
378
+ const now = new Date().toISOString();
379
+ await this._pool.query(
380
+ `INSERT INTO agent_registry
381
+ (agent_id, display_name, description, system_prompt, default_model,
382
+ default_hitl_level, allow_user_model_select, allow_user_hitl_config,
383
+ tool_allowlist, max_turns, max_tokens, is_default, enabled, seeded_from_config,
384
+ created_at, updated_at)
385
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16)
386
+ ON CONFLICT(agent_id) DO UPDATE SET
387
+ display_name = $2, description = $3, system_prompt = $4, default_model = $5,
388
+ default_hitl_level = $6, allow_user_model_select = $7, allow_user_hitl_config = $8,
389
+ tool_allowlist = $9, max_turns = $10, max_tokens = $11, is_default = $12,
390
+ enabled = $13, seeded_from_config = $14, updated_at = $16`,
391
+ [
392
+ row.agent_id, row.display_name, row.description ?? null, row.system_prompt ?? null,
393
+ row.default_model ?? null, row.default_hitl_level ?? null,
394
+ row.allow_user_model_select ?? 0, row.allow_user_hitl_config ?? 0,
395
+ row.tool_allowlist ?? '*', row.max_turns ?? null, row.max_tokens ?? null,
396
+ row.is_default ?? 0, row.enabled ?? 1, row.seeded_from_config ?? 0, now, now
397
+ ]
398
+ );
399
+ }
400
+
401
+ async setDefault(agentId) {
402
+ const client = await this._pool.connect();
403
+ try {
404
+ await client.query('BEGIN');
405
+ const { rows } = await client.query(
406
+ 'SELECT 1 FROM agent_registry WHERE agent_id = $1 AND enabled = 1', [agentId]
407
+ );
408
+ if (rows.length === 0) { await client.query('ROLLBACK'); return; }
409
+ await client.query('UPDATE agent_registry SET is_default = 0 WHERE is_default = 1');
410
+ await client.query(
411
+ 'UPDATE agent_registry SET is_default = 1, updated_at = $1 WHERE agent_id = $2',
412
+ [new Date().toISOString(), agentId]
413
+ );
414
+ await client.query('COMMIT');
415
+ } catch (err) {
416
+ await client.query('ROLLBACK');
417
+ throw err;
418
+ } finally {
419
+ client.release();
420
+ }
421
+ }
422
+
423
+ async deleteAgent(agentId) {
424
+ await this._pool.query('DELETE FROM agent_registry WHERE agent_id = $1', [agentId]);
425
+ }
426
+
427
+ async seedFromConfig() {
428
+ const agents = this._config.agents;
429
+ if (!Array.isArray(agents) || agents.length === 0) return;
430
+
431
+ let defaultAgentId = null;
432
+ for (const a of agents) {
433
+ if (!a.id || !a.displayName) continue;
434
+ const existing = await this.getAgent(a.id);
435
+ if (existing && !existing.seeded_from_config) continue;
436
+ await this.upsertAgent({
437
+ agent_id: a.id,
438
+ display_name: a.displayName,
439
+ description: a.description ?? null,
440
+ system_prompt: a.systemPrompt ?? null,
441
+ default_model: a.defaultModel ?? null,
442
+ default_hitl_level: a.defaultHitlLevel ?? null,
443
+ allow_user_model_select: a.allowUserModelSelect ? 1 : 0,
444
+ allow_user_hitl_config: a.allowUserHitlConfig ? 1 : 0,
445
+ tool_allowlist: Array.isArray(a.toolAllowlist) ? JSON.stringify(a.toolAllowlist) : '*',
446
+ max_turns: a.maxTurns ?? null,
447
+ max_tokens: a.maxTokens ?? null,
448
+ is_default: 0,
449
+ enabled: 1,
450
+ seeded_from_config: 1
451
+ });
452
+ if (a.isDefault) defaultAgentId = a.id;
453
+ }
454
+
455
+ if (defaultAgentId) {
456
+ await this.setDefault(defaultAgentId);
457
+ } else {
458
+ const defaultAgent = await this.resolveAgent(null);
459
+ if (!defaultAgent) {
460
+ const first = agents.find(a => a.id && a.displayName);
461
+ if (first) await this.setDefault(first.id);
462
+ }
463
+ }
464
+ }
465
+ }
@@ -0,0 +1,47 @@
1
+ export type HitlLevel = 'autonomous' | 'cautious' | 'standard' | 'paranoid';
2
+
3
+ export interface UserPreferences {
4
+ model: string | null;
5
+ hitlLevel: string | null;
6
+ }
7
+
8
+ export interface EffectiveSettings {
9
+ model: string;
10
+ hitlLevel: string;
11
+ provider: string;
12
+ apiKey: string | null;
13
+ }
14
+
15
+ /**
16
+ * Per-user model + HITL preferences with app-owner permission gates.
17
+ *
18
+ * `resolveEffective()` merges user preferences with config gates:
19
+ * - `allowUserModelSelect: false` → user model preference is ignored
20
+ * - `allowUserHitlConfig: false` → user HITL preference is ignored
21
+ */
22
+ export class PreferenceStore {
23
+ constructor(db: object);
24
+
25
+ /**
26
+ * Get stored preferences for a user, or `null` if none exist.
27
+ */
28
+ getUserPreferences(userId: string): UserPreferences | null;
29
+
30
+ /**
31
+ * Upsert user preferences.
32
+ * Throws if `hitlLevel` is not a valid value.
33
+ */
34
+ setUserPreferences(userId: string, prefs: { model?: string; hitlLevel?: string }): void;
35
+
36
+ /**
37
+ * Resolve the effective model, HITL level, provider, and API key for a user.
38
+ * Merges user preferences with app-owner config gates.
39
+ */
40
+ resolveEffective(userId: string, config: object, env?: Record<string, string>): EffectiveSettings;
41
+ }
42
+
43
+ /**
44
+ * Factory — creates a PreferenceStore backed by SQLite.
45
+ * For Postgres, use `buildSidecarContext`, which selects the adapter automatically.
46
+ */
47
+ export function makePreferenceStore(config: object, db: object): PreferenceStore;
@@ -0,0 +1,79 @@
1
+ /**
2
+ * PreferenceStore — per-user model + HITL preferences with permission hierarchy.
3
+ *
4
+ * resolveEffective() merges user preferences with app-owner config gates:
5
+ * - allowUserModelSelect: if false, user model preference is ignored
6
+ * - allowUserHitlConfig: if false, user hitl preference is ignored
7
+ */
8
+
9
+ import { getUserPreferences, upsertUserPreferences } from './db.js';
10
+ import { detectProvider, resolveApiKey } from './api-client.js';
11
+
12
+ const VALID_HITL_LEVELS = ['autonomous', 'cautious', 'standard', 'paranoid'];
13
+
14
+ export class PreferenceStore {
15
+ /** @param {import('better-sqlite3').Database} db */
16
+ constructor(db) {
17
+ this._db = db;
18
+ }
19
+
20
+ /**
21
+ * Get stored preferences for a user.
22
+ * @param {string} userId
23
+ * @returns {{ model: string|null, hitlLevel: string|null } | null}
24
+ */
25
+ getUserPreferences(userId) {
26
+ const row = getUserPreferences(this._db, userId);
27
+ if (!row) return null;
28
+ return { model: row.model, hitlLevel: row.hitl_level };
29
+ }
30
+
31
+ /**
32
+ * Set user preferences (upsert).
33
+ * @param {string} userId
34
+ * @param {{ model?: string, hitlLevel?: string }} prefs
35
+ */
36
+ setUserPreferences(userId, prefs) {
37
+ if (prefs.hitlLevel && !VALID_HITL_LEVELS.includes(prefs.hitlLevel)) {
38
+ throw new Error(`Invalid hitlLevel: ${prefs.hitlLevel}. Must be one of: ${VALID_HITL_LEVELS.join(', ')}`);
39
+ }
40
+ upsertUserPreferences(this._db, userId, prefs);
41
+ }
42
+
43
+ /**
44
+ * Resolve effective settings for a user — merges user prefs with config gates.
45
+ *
46
+ * @param {string} userId
47
+ * @param {object} config — merged forge config (from mergeDefaults)
48
+ * @param {object} env — process.env or equivalent
49
+ * @returns {{ model: string, hitlLevel: string, provider: string, apiKey: string|null }}
50
+ */
51
+ resolveEffective(userId, config, env = {}) {
52
+ const userPrefs = this.getUserPreferences(userId);
53
+
54
+ const model = (config.allowUserModelSelect && userPrefs?.model)
55
+ ? userPrefs.model
56
+ : (config.defaultModel ?? 'claude-sonnet-4-6');
57
+
58
+ const hitlLevel = (config.allowUserHitlConfig && userPrefs?.hitlLevel)
59
+ ? userPrefs.hitlLevel
60
+ : (config.defaultHitlLevel ?? 'cautious');
61
+
62
+ const provider = detectProvider(model);
63
+ const apiKey = resolveApiKey(provider, env);
64
+
65
+ return { model, hitlLevel, provider, apiKey };
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Factory — creates a PreferenceStore backed by SQLite.
71
+ * For Postgres, use buildSidecarContext which selects the adapter automatically.
72
+ * @param {object} config
73
+ * @param {import('better-sqlite3').Database} db
74
+ * @returns {PreferenceStore}
75
+ */
76
+ export function makePreferenceStore(config, db) {
77
+ return new PreferenceStore(db);
78
+ }
79
+
@@ -0,0 +1,42 @@
1
+ export interface PromptVersion {
2
+ id: number;
3
+ version: string;
4
+ content: string;
5
+ is_active: number;
6
+ created_at: string;
7
+ activated_at: string | null;
8
+ notes: string | null;
9
+ }
10
+
11
+ /**
12
+ * Versioned system prompt management.
13
+ * The TUI writes prompt versions; the sidecar reads the active one per request.
14
+ * Hot-swap: activating a new version takes effect on the next chat request.
15
+ */
16
+ export class PromptStore {
17
+ constructor(db: object);
18
+
19
+ /** Get the active prompt content, or `''` if none is active. */
20
+ getActivePrompt(): string;
21
+
22
+ /** Get all versions ordered by most recent first. */
23
+ getAllVersions(): PromptVersion[];
24
+
25
+ /**
26
+ * Create a new prompt version (inactive by default).
27
+ * @returns The new version's id.
28
+ */
29
+ createVersion(version: string, content: string, notes?: string | null): number;
30
+
31
+ /** Activate a prompt version by id (deactivates all others). */
32
+ activate(id: number): void;
33
+
34
+ /** Get a specific version by id, or `null` if not found. */
35
+ getVersion(id: number): PromptVersion | null;
36
+ }
37
+
38
+ /**
39
+ * Factory — creates a PromptStore backed by SQLite.
40
+ * For Postgres, use `buildSidecarContext`, which selects the adapter automatically.
41
+ */
42
+ export function makePromptStore(config: object, db: object): PromptStore;
@@ -0,0 +1,60 @@
1
+ /**
2
+ * PromptStore — versioned system prompt management.
3
+ *
4
+ * TUI writes prompt versions, sidecar reads the active one per request.
5
+ * Hot-swap: activate a new version and the next chat request picks it up.
6
+ */
7
+
8
+ import {
9
+ getActivePrompt,
10
+ insertPromptVersion,
11
+ activatePromptVersion,
12
+ getAllPromptVersions
13
+ } from './db.js';
14
+
15
+ export class PromptStore {
16
+ /** @param {import('better-sqlite3').Database} db */
17
+ constructor(db) {
18
+ this._db = db;
19
+ }
20
+
21
+ /** Get the active prompt content, or '' if none active. */
22
+ getActivePrompt() {
23
+ const row = getActivePrompt(this._db);
24
+ return row ? row.content : '';
25
+ }
26
+
27
+ /** Get all versions ordered by most recent first. */
28
+ getAllVersions() {
29
+ return getAllPromptVersions(this._db);
30
+ }
31
+
32
+ /**
33
+ * Create a new prompt version (inactive by default).
34
+ * @returns {number} id
35
+ */
36
+ createVersion(version, content, notes = null) {
37
+ return insertPromptVersion(this._db, { version, content, notes });
38
+ }
39
+
40
+ /** Activate a prompt version (deactivates all others). */
41
+ activate(id) {
42
+ activatePromptVersion(this._db, id);
43
+ }
44
+
45
+ /** Get a specific version by id, or null. */
46
+ getVersion(id) {
47
+ return this._db.prepare('SELECT * FROM prompt_versions WHERE id = ?').get(id) ?? null;
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Factory — creates a PromptStore backed by SQLite.
53
+ * For Postgres, use buildSidecarContext which selects the adapter automatically.
54
+ * @param {object} config — merged forge config (unused for now, reserved for future adapters)
55
+ * @param {import('better-sqlite3').Database} db
56
+ * @returns {PromptStore}
57
+ */
58
+ export function makePromptStore(config, db) {
59
+ return new PromptStore(db);
60
+ }