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,789 @@
1
+ /**
2
+ * <forge-chat> — Reference chat widget (Web Component, vanilla JS, zero deps).
3
+ *
4
+ * Usage:
5
+ * <script src="/widget/forge-chat.js"></script>
6
+ * <forge-chat endpoint="https://myapp.com/agent-api" theme="light"></forge-chat>
7
+ *
8
+ * Attributes:
9
+ * endpoint — Base URL for the agent API (required)
10
+ * theme — "light" or "dark" (default: "light")
11
+ * token — JWT token for auth (optional, can also be set via setToken())
12
+ * agent — Agent ID to route chat requests to a specific agent (optional)
13
+ *
14
+ * Custom events:
15
+ * forge:message — { detail: { role, content } }
16
+ * forge:tool-call — { detail: { tool, args } }
17
+ * forge:hitl — { detail: { tool, message, resumeToken } }
18
+ * forge:error — { detail: { message } }
19
+ */
20
+
21
+ class ForgeChat extends HTMLElement {
22
+ constructor() {
23
+ super();
24
+ this._sessionId = null;
25
+ this._token = null;
26
+ this._messages = [];
27
+ this._prefsOpen = false;
28
+ this._abortCtrl = null;
29
+ this._streaming = false;
30
+ this._pendingTheme = null;
31
+ this.attachShadow({ mode: 'open' });
32
+ }
33
+
34
+ static get observedAttributes() {
35
+ return ['endpoint', 'theme', 'token', 'agent'];
36
+ }
37
+
38
+ connectedCallback() {
39
+ // Security: prefer setting this._token programmatically rather than via attribute
40
+ // to avoid token exposure in DOM/DevTools. Strip attribute after reading.
41
+ const tokenAttr = this.getAttribute('token');
42
+ if (tokenAttr) {
43
+ this._token = tokenAttr;
44
+ this.removeAttribute('token'); // strip from DOM to reduce exposure
45
+ }
46
+ this._render();
47
+ }
48
+
49
+ disconnectedCallback() {
50
+ this._abortCtrl?.abort();
51
+ }
52
+
53
+ attributeChangedCallback(name, oldVal, newVal) {
54
+ if (name === 'token') {
55
+ if (newVal !== null) { // Only update on set, not on remove
56
+ this._token = newVal;
57
+ }
58
+ return;
59
+ }
60
+ if (name === 'theme') this._applyTheme(newVal);
61
+ }
62
+
63
+ setToken(token) {
64
+ this._token = token;
65
+ }
66
+
67
+ _render() {
68
+ const theme = this.getAttribute('theme') || 'light';
69
+ const isDark = theme === 'dark';
70
+
71
+ this.shadowRoot.innerHTML = `
72
+ <style>
73
+ :host { display: block; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; }
74
+ *, *::before, *::after { box-sizing: border-box; }
75
+ .forge-chat {
76
+ border: 1px solid ${isDark ? '#444' : '#ddd'};
77
+ border-radius: 8px;
78
+ display: flex;
79
+ flex-direction: column;
80
+ height: 400px;
81
+ background: ${isDark ? '#1a1a2e' : '#fff'};
82
+ color: ${isDark ? '#e0e0e0' : '#333'};
83
+ position: relative;
84
+ }
85
+ .forge-header {
86
+ display: flex;
87
+ align-items: center;
88
+ justify-content: space-between;
89
+ padding: 6px 12px;
90
+ border-bottom: 1px solid ${isDark ? '#333' : '#eee'};
91
+ font-size: 0.85em;
92
+ font-weight: 600;
93
+ color: ${isDark ? '#aaa' : '#666'};
94
+ }
95
+ .forge-header button {
96
+ background: none;
97
+ border: none;
98
+ cursor: pointer;
99
+ font-size: 1.1em;
100
+ color: ${isDark ? '#aaa' : '#666'};
101
+ padding: 2px 6px;
102
+ border-radius: 4px;
103
+ }
104
+ .forge-header button:hover { background: ${isDark ? '#333' : '#eee'}; }
105
+ .forge-header button:focus-visible { outline: 2px solid #1976d2; outline-offset: 1px; }
106
+ .forge-messages {
107
+ flex: 1;
108
+ overflow-y: auto;
109
+ padding: 12px;
110
+ }
111
+ .forge-msg {
112
+ margin-bottom: 8px;
113
+ padding: 8px 12px;
114
+ border-radius: 6px;
115
+ max-width: 80%;
116
+ word-wrap: break-word;
117
+ line-height: 1.5;
118
+ }
119
+ .forge-msg.user {
120
+ background: ${isDark ? '#0d47a1' : '#e3f2fd'};
121
+ margin-left: auto;
122
+ text-align: right;
123
+ }
124
+ .forge-msg.assistant {
125
+ background: ${isDark ? '#2d2d44' : '#f5f5f5'};
126
+ }
127
+ /* Markdown styles inside assistant messages */
128
+ .forge-msg.assistant p { margin: 4px 0; }
129
+ .forge-msg.assistant code {
130
+ background: ${isDark ? '#1a1a2e' : '#e8e8e8'};
131
+ padding: 1px 4px;
132
+ border-radius: 3px;
133
+ font-size: 0.9em;
134
+ }
135
+ .forge-msg.assistant pre {
136
+ background: ${isDark ? '#111' : '#e8e8e8'};
137
+ padding: 8px;
138
+ border-radius: 4px;
139
+ overflow-x: auto;
140
+ font-size: 0.85em;
141
+ }
142
+ .forge-msg.assistant pre code { background: none; padding: 0; }
143
+ .forge-msg.assistant ul, .forge-msg.assistant ol { padding-left: 20px; margin: 4px 0; }
144
+ .forge-msg.assistant strong { font-weight: 600; }
145
+ .forge-msg.assistant em { font-style: italic; }
146
+ .forge-msg.assistant a { color: #1976d2; text-decoration: underline; }
147
+ .forge-msg.tool-warning {
148
+ background: ${isDark ? '#4a3800' : '#fff3e0'};
149
+ border-left: 3px solid #ff9800;
150
+ font-size: 0.9em;
151
+ }
152
+ .forge-msg.hitl {
153
+ background: ${isDark ? '#4a0000' : '#fce4ec'};
154
+ border-left: 3px solid #f44336;
155
+ text-align: center;
156
+ }
157
+ .forge-msg.hitl button {
158
+ margin: 4px;
159
+ padding: 6px 16px;
160
+ border-radius: 4px;
161
+ cursor: pointer;
162
+ border: none;
163
+ font-size: 0.9em;
164
+ }
165
+ .forge-msg.hitl button:focus-visible { outline: 2px solid #1976d2; outline-offset: 1px; }
166
+ .forge-msg.hitl button.confirm { background: #4caf50; color: #fff; }
167
+ .forge-msg.hitl button.cancel { background: #f44336; color: #fff; }
168
+ .forge-typing {
169
+ font-style: italic;
170
+ color: ${isDark ? '#888' : '#999'};
171
+ padding: 4px 12px;
172
+ font-size: 0.9em;
173
+ }
174
+ .forge-typing .dots::after {
175
+ content: '';
176
+ animation: forge-dots 1.5s steps(4, end) infinite;
177
+ }
178
+ @keyframes forge-dots {
179
+ 0% { content: ''; }
180
+ 25% { content: '.'; }
181
+ 50% { content: '..'; }
182
+ 75% { content: '...'; }
183
+ }
184
+ .forge-input-row {
185
+ display: flex;
186
+ border-top: 1px solid ${isDark ? '#444' : '#ddd'};
187
+ padding: 8px;
188
+ }
189
+ .forge-input-row input {
190
+ flex: 1;
191
+ padding: 8px;
192
+ border: 1px solid ${isDark ? '#555' : '#ccc'};
193
+ border-radius: 4px;
194
+ background: ${isDark ? '#2d2d44' : '#fff'};
195
+ color: ${isDark ? '#e0e0e0' : '#333'};
196
+ outline: none;
197
+ font-size: 0.95em;
198
+ }
199
+ .forge-input-row input:focus-visible { border-color: #1976d2; box-shadow: 0 0 0 1px #1976d2; }
200
+ .forge-input-row button {
201
+ margin-left: 8px;
202
+ padding: 8px 16px;
203
+ border: none;
204
+ border-radius: 4px;
205
+ background: #1976d2;
206
+ color: #fff;
207
+ cursor: pointer;
208
+ font-size: 0.95em;
209
+ }
210
+ .forge-input-row button:focus-visible { outline: 2px solid #1976d2; outline-offset: 2px; }
211
+ .forge-input-row button:disabled { opacity: 0.5; cursor: not-allowed; }
212
+
213
+ /* Preference panel */
214
+ .forge-prefs-overlay {
215
+ position: absolute;
216
+ inset: 0;
217
+ background: ${isDark ? 'rgba(0,0,0,0.7)' : 'rgba(0,0,0,0.3)'};
218
+ display: flex;
219
+ align-items: center;
220
+ justify-content: center;
221
+ z-index: 10;
222
+ }
223
+ .forge-prefs-panel {
224
+ background: ${isDark ? '#2d2d44' : '#fff'};
225
+ border-radius: 8px;
226
+ padding: 16px;
227
+ width: 280px;
228
+ box-shadow: 0 4px 12px rgba(0,0,0,0.2);
229
+ }
230
+ .forge-prefs-panel h3 { margin: 0 0 12px; font-size: 1em; }
231
+ .forge-prefs-panel label { display: block; margin-bottom: 8px; font-size: 0.9em; }
232
+ .forge-prefs-panel select, .forge-prefs-panel input[type="text"] {
233
+ width: 100%;
234
+ padding: 6px;
235
+ border: 1px solid ${isDark ? '#555' : '#ccc'};
236
+ border-radius: 4px;
237
+ background: ${isDark ? '#1a1a2e' : '#fff'};
238
+ color: ${isDark ? '#e0e0e0' : '#333'};
239
+ font-size: 0.9em;
240
+ margin-top: 2px;
241
+ }
242
+ .forge-prefs-panel select:focus-visible, .forge-prefs-panel input:focus-visible {
243
+ outline: 2px solid #1976d2;
244
+ }
245
+ .forge-prefs-panel .prefs-actions {
246
+ display: flex;
247
+ gap: 8px;
248
+ margin-top: 12px;
249
+ justify-content: flex-end;
250
+ }
251
+ .forge-prefs-panel .prefs-actions button {
252
+ padding: 6px 14px;
253
+ border: none;
254
+ border-radius: 4px;
255
+ cursor: pointer;
256
+ font-size: 0.9em;
257
+ }
258
+ .forge-prefs-panel .prefs-actions button:focus-visible { outline: 2px solid #1976d2; outline-offset: 1px; }
259
+ .forge-prefs-panel .prefs-actions .save-btn { background: #1976d2; color: #fff; }
260
+ .forge-prefs-panel .prefs-actions .cancel-btn {
261
+ background: ${isDark ? '#444' : '#e0e0e0'};
262
+ color: ${isDark ? '#ccc' : '#333'};
263
+ }
264
+ .forge-prefs-panel .perm-note { font-size: 0.8em; color: ${isDark ? '#888' : '#999'}; margin-top: 2px; }
265
+
266
+ /* Screen reader only */
267
+ .sr-only {
268
+ position: absolute;
269
+ width: 1px; height: 1px;
270
+ padding: 0; margin: -1px;
271
+ overflow: hidden;
272
+ clip: rect(0,0,0,0);
273
+ border: 0;
274
+ }
275
+ </style>
276
+ <div class="forge-chat" role="region" aria-label="Chat">
277
+ <div class="forge-header">
278
+ <span>${this.getAttribute('agent') ? `Forge Chat — ${this._escapeHtml(this.getAttribute('agent'))}` : 'Forge Chat'}</span>
279
+ <button id="prefs-btn" aria-label="Open preferences" title="Preferences">&#9881;</button>
280
+ </div>
281
+ <div class="forge-messages" id="messages" role="log" aria-live="polite" aria-label="Chat messages"></div>
282
+ <div id="typing" class="forge-typing" style="display:none" aria-live="polite">
283
+ <span>Assistant is thinking</span><span class="dots"></span>
284
+ </div>
285
+ <div class="forge-input-row">
286
+ <label for="forge-input" class="sr-only">Message</label>
287
+ <input type="text" id="forge-input" placeholder="Type a message..." autocomplete="off" aria-label="Type a message" />
288
+ <button id="send" aria-label="Send message">Send</button>
289
+ </div>
290
+ </div>
291
+ `;
292
+
293
+ const input = this.shadowRoot.getElementById('forge-input');
294
+ const sendBtn = this.shadowRoot.getElementById('send');
295
+ const prefsBtn = this.shadowRoot.getElementById('prefs-btn');
296
+
297
+ sendBtn.addEventListener('click', () => this._sendMessage());
298
+ input.addEventListener('keydown', (e) => {
299
+ if (e.key === 'Enter' && !e.shiftKey) {
300
+ e.preventDefault();
301
+ this._sendMessage();
302
+ }
303
+ });
304
+ prefsBtn.addEventListener('click', () => this._togglePrefs());
305
+ }
306
+
307
+ _addMessage(role, content, extraClass = '') {
308
+ const msgDiv = document.createElement('div');
309
+ msgDiv.className = `forge-msg ${role} ${extraClass}`.trim();
310
+ msgDiv.setAttribute('role', role === 'user' ? 'status' : 'article');
311
+
312
+ if (role === 'assistant' && !extraClass) {
313
+ msgDiv.innerHTML = this._renderMarkdown(content);
314
+ } else {
315
+ msgDiv.textContent = content;
316
+ }
317
+
318
+ const container = this.shadowRoot.getElementById('messages');
319
+ container.appendChild(msgDiv);
320
+ container.scrollTop = container.scrollHeight;
321
+ return msgDiv;
322
+ }
323
+
324
+ /**
325
+ * Basic markdown renderer — handles the most common patterns:
326
+ * code blocks, inline code, bold, italic, links, lists, paragraphs.
327
+ * No external dependencies.
328
+ */
329
+ _renderMarkdown(text) {
330
+ if (!text) return '';
331
+
332
+ // Escape HTML
333
+ let html = text
334
+ .replace(/&/g, '&amp;')
335
+ .replace(/</g, '&lt;')
336
+ .replace(/>/g, '&gt;');
337
+
338
+ // Code blocks: ```lang\n...\n```
339
+ html = html.replace(/```(\w*)\n([\s\S]*?)```/g, (_, lang, code) => {
340
+ return `<pre><code>${code.trim()}</code></pre>`;
341
+ });
342
+
343
+ // Inline code: `code`
344
+ html = html.replace(/`([^`]+)`/g, '<code>$1</code>');
345
+
346
+ // Bold: **text** or __text__
347
+ html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
348
+ html = html.replace(/__(.+?)__/g, '<strong>$1</strong>');
349
+
350
+ // Italic: *text* or _text_ (but not inside words)
351
+ html = html.replace(/(?<!\w)\*([^*]+?)\*(?!\w)/g, '<em>$1</em>');
352
+ html = html.replace(/(?<!\w)_([^_]+?)_(?!\w)/g, '<em>$1</em>');
353
+
354
+ // Links: [text](url) — reject javascript: and data: protocols
355
+ html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, text, href) => {
356
+ const trimmed = href.trim().toLowerCase();
357
+ if (trimmed.startsWith('javascript:') || trimmed.startsWith('data:') || trimmed.startsWith('vbscript:')) {
358
+ return text; // strip the link, keep text
359
+ }
360
+ const safeHref = href.replace(/"/g, '&quot;');
361
+ return `<a href="${safeHref}" target="_blank" rel="noopener noreferrer">${text}</a>`;
362
+ });
363
+
364
+ // Unordered lists: lines starting with - or *
365
+ html = html.replace(/^([ \t]*[-*] .+(?:\n|$))+/gm, (match) => {
366
+ const items = match.trim().split('\n').map(line => {
367
+ return `<li>${line.replace(/^[ \t]*[-*] /, '')}</li>`;
368
+ }).join('');
369
+ return `<ul>${items}</ul>`;
370
+ });
371
+
372
+ // Ordered lists: lines starting with 1. 2. etc
373
+ html = html.replace(/^([ \t]*\d+\. .+(?:\n|$))+/gm, (match) => {
374
+ const items = match.trim().split('\n').map(line => {
375
+ return `<li>${line.replace(/^[ \t]*\d+\. /, '')}</li>`;
376
+ }).join('');
377
+ return `<ol>${items}</ol>`;
378
+ });
379
+
380
+ // Paragraphs: double newlines
381
+ html = html.replace(/\n\n+/g, '</p><p>');
382
+ if (!html.startsWith('<')) html = `<p>${html}`;
383
+ if (!html.endsWith('>')) html = `${html}</p>`;
384
+
385
+ // Single newlines → <br> (but not inside pre/code blocks)
386
+ html = html.replace(/(?<!<\/li>|<\/ul>|<\/ol>|<\/pre>|<\/code>)\n(?!<)/g, '<br>');
387
+
388
+ return html;
389
+ }
390
+
391
+ _addStreamingMessage() {
392
+ const msgDiv = document.createElement('div');
393
+ msgDiv.className = 'forge-msg assistant';
394
+ msgDiv.setAttribute('role', 'article');
395
+ const container = this.shadowRoot.getElementById('messages');
396
+ container.appendChild(msgDiv);
397
+ container.scrollTop = container.scrollHeight;
398
+ return msgDiv;
399
+ }
400
+
401
+ _updateStreamingMessage(msgDiv, text) {
402
+ msgDiv.textContent = text;
403
+ const container = this.shadowRoot.getElementById('messages');
404
+ container.scrollTop = container.scrollHeight;
405
+ }
406
+
407
+ _finalizeStreamingMessage(msgDiv, text) {
408
+ msgDiv.innerHTML = this._renderMarkdown(text);
409
+ const container = this.shadowRoot.getElementById('messages');
410
+ container.scrollTop = container.scrollHeight;
411
+ }
412
+
413
+ _showTyping() {
414
+ const el = this.shadowRoot.getElementById('typing');
415
+ if (el) el.style.display = 'block';
416
+ }
417
+
418
+ _hideTyping() {
419
+ const el = this.shadowRoot.getElementById('typing');
420
+ if (el) el.style.display = 'none';
421
+ }
422
+
423
+ async _readSseStream(reader) {
424
+ const decoder = new TextDecoder();
425
+ let buffer = '';
426
+ let assistantText = '';
427
+ let assistantMsgDiv = null;
428
+
429
+ while (true) {
430
+ const { done, value } = await reader.read();
431
+ if (done) break;
432
+
433
+ buffer += decoder.decode(value, { stream: true });
434
+ const lines = buffer.split('\n');
435
+ buffer = lines.pop(); // keep incomplete line
436
+
437
+ let eventType = null;
438
+ for (const line of lines) {
439
+ if (line.startsWith('event: ')) {
440
+ eventType = line.slice(7).trim();
441
+ } else if (line.startsWith('data: ') && eventType) {
442
+ try {
443
+ const data = JSON.parse(line.slice(6));
444
+
445
+ if (eventType === 'text_delta') {
446
+ assistantText += data.content || '';
447
+ if (!assistantMsgDiv) {
448
+ this._hideTyping();
449
+ assistantMsgDiv = this._addStreamingMessage();
450
+ }
451
+ this._updateStreamingMessage(assistantMsgDiv, assistantText);
452
+ } else if (eventType === 'text') {
453
+ assistantText = data.content || '';
454
+ if (assistantMsgDiv) {
455
+ this._finalizeStreamingMessage(assistantMsgDiv, assistantText);
456
+ }
457
+ } else if (eventType === 'session') {
458
+ this._sessionId = data.sessionId;
459
+ } else {
460
+ this._handleSSEEvent(eventType, data);
461
+ }
462
+ } catch { /* skip malformed */ }
463
+ eventType = null; // reset after each complete event
464
+ } else if (line === '') {
465
+ // blank line = SSE message boundary; reset eventType for next message
466
+ eventType = null;
467
+ }
468
+ }
469
+ }
470
+
471
+ return { assistantText, assistantMsgDiv };
472
+ }
473
+
474
+ async _sendMessage() {
475
+ const input = this.shadowRoot.getElementById('forge-input');
476
+ const message = input.value.trim();
477
+ if (!message) return;
478
+
479
+ input.value = '';
480
+ this._addMessage('user', message);
481
+ this.dispatchEvent(new CustomEvent('forge:message', { detail: { role: 'user', content: message } }));
482
+
483
+ const endpoint = this.getAttribute('endpoint');
484
+ if (!endpoint) {
485
+ this._addMessage('assistant', 'Error: no endpoint configured');
486
+ return;
487
+ }
488
+
489
+ const sendBtn = this.shadowRoot.getElementById('send');
490
+ sendBtn.disabled = true;
491
+ this._showTyping();
492
+ this._streaming = true;
493
+
494
+ try {
495
+ const headers = { 'Content-Type': 'application/json' };
496
+ if (this._token) headers['Authorization'] = `Bearer ${this._token}`;
497
+
498
+ const chatBody = { message, sessionId: this._sessionId };
499
+ const agentAttr = this.getAttribute('agent');
500
+ if (agentAttr) chatBody.agentId = agentAttr;
501
+
502
+ this._abortCtrl = new AbortController();
503
+ const res = await fetch(`${endpoint}/chat`, {
504
+ method: 'POST',
505
+ headers,
506
+ body: JSON.stringify(chatBody),
507
+ signal: this._abortCtrl.signal
508
+ });
509
+
510
+ if (!res.ok) {
511
+ const err = await res.text();
512
+ this._hideTyping();
513
+ this._addMessage('assistant', `Error: ${res.status} — ${err}`);
514
+ return;
515
+ }
516
+
517
+ // Parse SSE stream via shared helper
518
+ const reader = res.body.getReader();
519
+ const { assistantText, assistantMsgDiv } = await this._readSseStream(reader);
520
+
521
+ this._hideTyping();
522
+
523
+ // Non-streaming fallback: if no streaming div was created, render traditionally
524
+ if (assistantText && !assistantMsgDiv) {
525
+ this._addMessage('assistant', assistantText);
526
+ }
527
+ if (assistantText) {
528
+ this.dispatchEvent(new CustomEvent('forge:message', { detail: { role: 'assistant', content: assistantText } }));
529
+ }
530
+ } catch (err) {
531
+ this._hideTyping();
532
+ this._addMessage('assistant', `Connection error: ${err.message}`);
533
+ this.dispatchEvent(new CustomEvent('forge:error', { detail: { message: err.message } }));
534
+ } finally {
535
+ this._streaming = false;
536
+ if (this._pendingTheme !== null) {
537
+ this._applyTheme(this._pendingTheme);
538
+ this._pendingTheme = null;
539
+ }
540
+ sendBtn.disabled = false;
541
+ input.focus();
542
+ }
543
+ }
544
+
545
+ _handleSSEEvent(type, data) {
546
+ switch (type) {
547
+ case 'tool_call':
548
+ this.dispatchEvent(new CustomEvent('forge:tool-call', { detail: data }));
549
+ break;
550
+ case 'tool_warning':
551
+ this._addMessage('assistant', `Warning: ${data.message}`, 'tool-warning');
552
+ break;
553
+ case 'hitl':
554
+ this._showHitlDialog(data);
555
+ this.dispatchEvent(new CustomEvent('forge:hitl', { detail: data }));
556
+ break;
557
+ case 'error':
558
+ this._hideTyping();
559
+ this._addMessage('assistant', `Error: ${data.message}`);
560
+ this.dispatchEvent(new CustomEvent('forge:error', { detail: data }));
561
+ break;
562
+ }
563
+ }
564
+
565
+ _showHitlDialog(data) {
566
+ this._hideTyping();
567
+ const msgDiv = document.createElement('div');
568
+ msgDiv.className = 'forge-msg hitl';
569
+ msgDiv.setAttribute('role', 'alertdialog');
570
+ msgDiv.setAttribute('aria-label', 'Tool confirmation required');
571
+ msgDiv.innerHTML = `
572
+ <p>${this._escapeHtml(data.message || 'Tool call requires confirmation')}</p>
573
+ <p><strong>${this._escapeHtml(data.tool || 'Unknown tool')}</strong></p>
574
+ <button class="confirm" aria-label="Confirm tool call">Confirm</button>
575
+ <button class="cancel" aria-label="Cancel tool call">Cancel</button>
576
+ `;
577
+
578
+ const container = this.shadowRoot.getElementById('messages');
579
+ container.appendChild(msgDiv);
580
+ container.scrollTop = container.scrollHeight;
581
+
582
+ // Focus the confirm button for keyboard users
583
+ const confirmBtn = msgDiv.querySelector('.confirm');
584
+ confirmBtn.focus();
585
+
586
+ confirmBtn.addEventListener('click', () => {
587
+ this._resumeHitl(data.resumeToken, true);
588
+ msgDiv.remove();
589
+ });
590
+ msgDiv.querySelector('.cancel').addEventListener('click', () => {
591
+ this._resumeHitl(data.resumeToken, false);
592
+ msgDiv.remove();
593
+ this._addMessage('assistant', 'Action cancelled.');
594
+ });
595
+ }
596
+
597
+ async _resumeHitl(resumeToken, confirmed) {
598
+ const endpoint = this.getAttribute('endpoint');
599
+ if (!endpoint || !resumeToken) return;
600
+
601
+ this._streaming = true;
602
+ this._abortCtrl = new AbortController();
603
+
604
+ const headers = { 'Content-Type': 'application/json' };
605
+ if (this._token) headers['Authorization'] = `Bearer ${this._token}`;
606
+
607
+ if (confirmed) this._showTyping();
608
+
609
+ try {
610
+ const res = await fetch(`${endpoint}/chat/resume`, {
611
+ method: 'POST',
612
+ headers,
613
+ body: JSON.stringify({ resumeToken, confirmed }),
614
+ signal: this._abortCtrl.signal
615
+ });
616
+
617
+ // Read the SSE response — resumed agent continues streaming
618
+ if (res.ok && res.body) {
619
+ const reader = res.body.getReader();
620
+ const { assistantText } = await this._readSseStream(reader);
621
+ this._hideTyping();
622
+ if (assistantText) {
623
+ this.dispatchEvent(new CustomEvent('forge:message', { detail: { role: 'assistant', content: assistantText } }));
624
+ }
625
+ } else {
626
+ this._hideTyping();
627
+ }
628
+ } catch (err) {
629
+ this._hideTyping();
630
+ this.dispatchEvent(new CustomEvent('forge:error', { detail: { message: err.message } }));
631
+ } finally {
632
+ this._streaming = false;
633
+ if (this._pendingTheme !== null) {
634
+ this._applyTheme(this._pendingTheme);
635
+ this._pendingTheme = null;
636
+ }
637
+ }
638
+ }
639
+
640
+ // ── Preference panel ───────────────────────────────────────────────────
641
+
642
+ async _togglePrefs() {
643
+ if (this._prefsOpen) {
644
+ this._closePrefs();
645
+ return;
646
+ }
647
+
648
+ const endpoint = this.getAttribute('endpoint');
649
+ if (!endpoint) return;
650
+
651
+ // Fetch current preferences
652
+ const headers = {};
653
+ if (this._token) headers['Authorization'] = `Bearer ${this._token}`;
654
+
655
+ let prefsData;
656
+ try {
657
+ const res = await fetch(`${endpoint}/user/preferences`, { headers });
658
+ if (!res.ok) {
659
+ if (res.status === 401) return; // Not authenticated
660
+ return;
661
+ }
662
+ prefsData = await res.json();
663
+ } catch {
664
+ return;
665
+ }
666
+
667
+ this._prefsOpen = true;
668
+ const chat = this.shadowRoot.querySelector('.forge-chat');
669
+
670
+ const overlay = document.createElement('div');
671
+ overlay.className = 'forge-prefs-overlay';
672
+ overlay.id = 'prefs-overlay';
673
+ overlay.setAttribute('role', 'dialog');
674
+ overlay.setAttribute('aria-label', 'User preferences');
675
+
676
+ const canModel = prefsData.permissions?.canChangeModel;
677
+ const canHitl = prefsData.permissions?.canChangeHitl;
678
+ const hitlLevels = prefsData.options?.hitlLevels || ['autonomous', 'cautious', 'standard', 'paranoid'];
679
+ const currentModel = prefsData.preferences?.model || '';
680
+ const currentHitl = prefsData.preferences?.hitlLevel || prefsData.effective?.hitlLevel || 'cautious';
681
+
682
+ overlay.innerHTML = `
683
+ <div class="forge-prefs-panel">
684
+ <h3>Preferences</h3>
685
+ ${canModel ? `
686
+ <label>
687
+ Model
688
+ <input type="text" id="pref-model" value="${this._escapeHtml(currentModel)}" placeholder="${this._escapeHtml(prefsData.effective?.model || 'default')}" />
689
+ </label>
690
+ ` : `<p class="perm-note">Model selection disabled by admin</p>`}
691
+ ${canHitl ? `
692
+ <label>
693
+ Confirmation level
694
+ <select id="pref-hitl">
695
+ ${hitlLevels.map(l => {
696
+ const safe = this._escapeHtml(l);
697
+ return `<option value="${safe}" ${l === currentHitl ? 'selected' : ''}>${safe}</option>`;
698
+ }).join('')}
699
+ </select>
700
+ </label>
701
+ ` : `<p class="perm-note">HITL level disabled by admin</p>`}
702
+ <p class="perm-note">Effective: ${this._escapeHtml(prefsData.effective?.model || 'default')} / ${this._escapeHtml(prefsData.effective?.hitlLevel || 'default')}</p>
703
+ <div class="prefs-actions">
704
+ <button class="cancel-btn" id="prefs-cancel">Cancel</button>
705
+ ${canModel || canHitl ? '<button class="save-btn" id="prefs-save">Save</button>' : ''}
706
+ </div>
707
+ </div>
708
+ `;
709
+
710
+ chat.appendChild(overlay);
711
+
712
+ // Focus first interactive element
713
+ const firstInput = overlay.querySelector('input, select, button');
714
+ if (firstInput) firstInput.focus();
715
+
716
+ overlay.querySelector('#prefs-cancel').addEventListener('click', () => this._closePrefs());
717
+
718
+ const saveBtn = overlay.querySelector('#prefs-save');
719
+ if (saveBtn) {
720
+ saveBtn.addEventListener('click', () => this._savePrefs(prefsData.permissions));
721
+ }
722
+
723
+ // Close on Escape
724
+ overlay.addEventListener('keydown', (e) => {
725
+ if (e.key === 'Escape') this._closePrefs();
726
+ });
727
+ }
728
+
729
+ async _savePrefs(permissions) {
730
+ const endpoint = this.getAttribute('endpoint');
731
+ if (!endpoint) return;
732
+
733
+ const body = {};
734
+ if (permissions.canChangeModel) {
735
+ const modelInput = this.shadowRoot.getElementById('pref-model');
736
+ if (modelInput?.value) body.model = modelInput.value;
737
+ }
738
+ if (permissions.canChangeHitl) {
739
+ const hitlSelect = this.shadowRoot.getElementById('pref-hitl');
740
+ if (hitlSelect?.value) body.hitl_level = hitlSelect.value;
741
+ }
742
+
743
+ if (Object.keys(body).length === 0) {
744
+ this._closePrefs();
745
+ return;
746
+ }
747
+
748
+ const headers = { 'Content-Type': 'application/json' };
749
+ if (this._token) headers['Authorization'] = `Bearer ${this._token}`;
750
+
751
+ try {
752
+ await fetch(`${endpoint}/user/preferences`, {
753
+ method: 'PUT',
754
+ headers,
755
+ body: JSON.stringify(body)
756
+ });
757
+ } catch { /* ignore save error */ }
758
+
759
+ this._closePrefs();
760
+ }
761
+
762
+ _closePrefs() {
763
+ this._prefsOpen = false;
764
+ const overlay = this.shadowRoot.getElementById('prefs-overlay');
765
+ if (overlay) overlay.remove();
766
+ // Return focus to prefs button
767
+ const prefsBtn = this.shadowRoot.getElementById('prefs-btn');
768
+ if (prefsBtn) prefsBtn.focus();
769
+ }
770
+
771
+ _escapeHtml(str) {
772
+ return String(str)
773
+ .replace(/&/g, '&amp;')
774
+ .replace(/</g, '&lt;')
775
+ .replace(/>/g, '&gt;')
776
+ .replace(/"/g, '&quot;');
777
+ }
778
+
779
+ _applyTheme(theme) {
780
+ if (!this.isConnected) return;
781
+ if (this._streaming) {
782
+ this._pendingTheme = theme; // queue it — apply after stream completes
783
+ return;
784
+ }
785
+ this._render();
786
+ }
787
+ }
788
+
789
+ customElements.define('forge-chat', ForgeChat);