golem-kit 0.1.0 → 0.2.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 (61) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/README.md +11 -6
  3. package/docs/agents.md +64 -0
  4. package/docs/app-backend.md +259 -0
  5. package/docs/architecture.md +93 -0
  6. package/docs/builder.md +15 -0
  7. package/docs/knowledge.md +35 -0
  8. package/docs/local-cli.md +31 -15
  9. package/docs/source-development.md +31 -0
  10. package/index.html +9 -0
  11. package/package.json +24 -5
  12. package/src/backend/accounts.ts +287 -0
  13. package/src/backend/app.ts +269 -0
  14. package/src/backend/files.ts +68 -0
  15. package/src/backend/http.ts +276 -0
  16. package/src/backend/index.ts +10 -0
  17. package/src/backend/jobs.ts +302 -0
  18. package/src/backend/jsonl.ts +87 -0
  19. package/src/backend/knowledge.ts +264 -0
  20. package/src/backend/model.ts +129 -0
  21. package/src/backend/rules.ts +53 -0
  22. package/src/backend/sqlite.ts +73 -0
  23. package/src/backend/views.ts +216 -0
  24. package/src/brain.ts +94 -0
  25. package/src/browser/adapters.ts +229 -53
  26. package/src/browser/ansi.ts +104 -0
  27. package/src/browser/app.d.ts +5 -2
  28. package/src/browser/app.tsx +167 -39
  29. package/src/browser/groups.tsx +29 -0
  30. package/src/browser/main.tsx +1 -0
  31. package/src/browser/panekeys.ts +34 -0
  32. package/src/browser/sources.tsx +113 -0
  33. package/src/browser/styles.css +36 -0
  34. package/src/browser/terminal.tsx +89 -0
  35. package/src/browser-build.ts +20 -7
  36. package/src/chat.ts +74 -0
  37. package/src/cli.ts +91 -17
  38. package/src/client.ts +205 -0
  39. package/src/config.ts +169 -0
  40. package/src/dev-server.ts +339 -38
  41. package/src/entry.mjs +19 -0
  42. package/src/eslint.mjs +55 -0
  43. package/src/operations.ts +169 -0
  44. package/src/runtime/assistant.ts +141 -0
  45. package/src/runtime/discovery.ts +13 -7
  46. package/src/runtime/harness/agent-status.js +388 -0
  47. package/src/runtime/harness/claude-tmux.js +573 -0
  48. package/src/runtime/harness/codex-notify.js +95 -0
  49. package/src/runtime/harness/codex-tmux.js +292 -0
  50. package/src/runtime/harness/fake.js +430 -0
  51. package/src/runtime/harness/package.json +1 -0
  52. package/src/runtime/harness/port.js +208 -0
  53. package/src/runtime/harness/tmux-session.js +556 -0
  54. package/src/runtime/harness/tmux.js +285 -0
  55. package/src/runtime/harness/turnend-hook.js +105 -0
  56. package/src/runtime/session.ts +171 -34
  57. package/src/runtime/tmux.ts +173 -0
  58. package/src/runtime/tool-names.ts +19 -0
  59. package/src/source-mode.ts +56 -0
  60. package/vite.config.ts +2 -4
  61. package/src/runtime/codex.ts +0 -119
@@ -1,3 +1,4 @@
1
+ import { refreshIdentity, type ViewEvent } from '../client.ts'
1
2
  import type { ChatAdapter, ChatAttachment, ChatMessage, IdentityAdapter, NavigationAdapter, Route, User } from 'golem-ui'
2
3
 
3
4
  const unavailable = () => Promise.reject(new Error('No agent or identity service is connected.'))
@@ -31,13 +32,31 @@ export const navigation: NavigationAdapter = {
31
32
  },
32
33
  }
33
34
 
34
- const storageKey = 'golem.browser.session'
35
+ /** Which conversation the chat column shows: the builder agent, or the app's normal-mode chat. */
36
+ export type SessionKind = 'builder' | 'chat' | 'anthropic'
37
+ // This tab remembers one conversation per kind, so the Builder switch comes back to the same one.
38
+ const storageKey = (kind: SessionKind) => `golem.browser.session.${kind}`
39
+ const outboxKey = 'golem.browser.outbox'
40
+ const kinds = ['builder', 'chat', 'anthropic'] as const
41
+ // Until the shell decides the mode, the tab's last conversation of any kind is the shown one.
35
42
  let sessionId: string | undefined = (() => {
36
- try { return window.sessionStorage.getItem(storageKey) ?? undefined } catch { return undefined }
43
+ try { return kinds.map((kind) => window.sessionStorage.getItem(storageKey(kind))).find(Boolean) ?? undefined } catch { return undefined }
37
44
  })()
38
- let messages: ChatMessage[] = []
45
+ const sessionListeners = new Set<(id: string | undefined) => void>()
46
+ /** Fires when the shown conversation changes underneath an open Chat, e.g. after `/reset`. */
47
+ export function subscribeBrowserSession(listener: (id: string | undefined) => void): () => void {
48
+ sessionListeners.add(listener)
49
+ return () => sessionListeners.delete(listener)
50
+ }
51
+ type BrowserMessage = ChatMessage & { delivery?: 'pending' | 'failed'; sources?: string[] }
52
+ type OutboxMessage = { id: string; sessionId: string; text: string; attachments?: ChatAttachment[]; delivery?: 'pending' | 'failed'; at: string }
53
+ let outbox: OutboxMessage[] = (() => {
54
+ try { return (JSON.parse(window.localStorage.getItem(outboxKey) ?? '[]') as OutboxMessage[]).map((message) => ({ ...message, delivery: message.delivery === 'pending' ? 'failed' : message.delivery })) } catch { return [] }
55
+ })()
56
+ let messages: BrowserMessage[] = []
57
+ let sessionBackend: string | undefined
39
58
  let cursor = -1
40
- let eventLog = new Map<number, { sequence: number; type: string; text?: string; status?: string; reason?: string }>()
59
+ let eventLog = new Map<number, { sequence: number; type: string; text?: string; status?: string; reason?: string; clientMessageId?: string; attachments?: ChatAttachment[]; sources?: string[] }>()
41
60
  let status = 'starting'
42
61
  let source: EventSource | undefined
43
62
  const listeners = new Set<(messages: ChatMessage[]) => void>()
@@ -45,55 +64,181 @@ const statusListeners = new Set<(status: string) => void>()
45
64
  const emit = () => listeners.forEach((listener) => listener([...messages]))
46
65
  const setStatus = (next: string) => { status = next; statusListeners.forEach((listener) => listener(status)) }
47
66
 
48
- function mergeEvents(events: Array<{ sequence: number; type: string; text?: string; status?: string; reason?: string }>): string | undefined {
67
+ function saveOutbox(confirmed = new Set<string>()): void {
68
+ try {
69
+ const stored = JSON.parse(window.localStorage.getItem(outboxKey) ?? '[]') as OutboxMessage[]
70
+ const merged = new Map(stored.filter((message) => !confirmed.has(message.id)).map((message) => [message.id, message]))
71
+ for (const message of outbox) if (!confirmed.has(message.id)) merged.set(message.id, message)
72
+ outbox = [...merged.values()]
73
+ window.localStorage.setItem(outboxKey, JSON.stringify(outbox))
74
+ } catch { /* Delivery still works for this page. */ }
75
+ }
76
+
77
+ function mergeEvents(events: Array<{ sequence: number; type: string; text?: string; status?: string; reason?: string; clientMessageId?: string; attachments?: ChatAttachment[]; sources?: string[] }>): string | undefined {
49
78
  for (const event of events) if (!eventLog.has(event.sequence)) eventLog.set(event.sequence, event)
50
79
  const ordered = [...eventLog.values()].sort((left, right) => left.sequence - right.sequence)
51
80
  cursor = Math.max(cursor, ...ordered.map((event) => event.sequence))
52
- messages = fromEvents(ordered)
81
+ const confirmed = fromEvents(ordered)
82
+ const ids = new Set(confirmed.map((message) => message.id))
83
+ outbox = outbox.filter((message) => message.sessionId !== sessionId || !ids.has(message.id))
84
+ saveOutbox(ids)
85
+ messages = [...confirmed, ...outbox.filter((message) => message.sessionId === sessionId).map((message) => ({ ...message, role: 'user' as const }))]
53
86
  return ordered.findLast((event) => event.type === 'status')?.status
54
87
  }
55
88
 
56
- function fromEvents(events: Array<{ type: string; sequence: number; text?: string; status?: string; reason?: string }>): ChatMessage[] {
89
+ function fromEvents(events: Array<{ type: string; sequence: number; text?: string; ok?: boolean; status?: string; reason?: string; clientMessageId?: string; attachments?: ChatAttachment[]; sources?: string[] }>): BrowserMessage[] {
57
90
  return events.flatMap((event) => {
58
91
  if ((event.type === 'user' || event.type === 'message') && event.text) {
59
- return [{ id: `${event.sequence}`, role: event.type === 'user' ? 'user' : 'agent', text: event.text, at: new Date().toISOString() }]
92
+ return [{ id: event.type === 'user' && event.clientMessageId ? event.clientMessageId : `${event.sequence}`, role: event.type === 'user' ? 'user' : 'agent', text: event.text, attachments: event.attachments, ...(event.sources ? { sources: event.sources } : {}), at: new Date().toISOString() }]
60
93
  }
61
- if (event.type === 'error') return [{ id: `${event.sequence}`, role: 'agent', text: `Error: ${event.text ?? 'Agent failed.'}`, at: new Date().toISOString() }]
62
- if (event.type === 'interrupted') return [{ id: `${event.sequence}`, role: 'agent', text: `Interrupted${event.reason ? `: ${event.reason}` : '.'}`, at: new Date().toISOString() }]
94
+ if (event.type === 'error') return [{ id: `${event.sequence}`, role: 'agent', text: `Error: ${event.text ?? 'Agent failed.'}`, attachments: undefined, at: new Date().toISOString() }]
95
+ if (event.type === 'tool' && event.ok === false) return [{ id: `${event.sequence}`, role: 'agent', text: `An action failed: ${event.text ?? 'unknown error'}`, attachments: undefined, at: new Date().toISOString() }]
96
+ if (event.type === 'interrupted') return [{ id: `${event.sequence}`, role: 'agent', text: `Interrupted${event.reason ? `: ${event.reason}` : '.'}`, attachments: undefined, at: new Date().toISOString() }]
63
97
  return []
64
98
  })
65
99
  }
66
100
 
67
- /** The only session-starting call in the UI — declares build intent explicitly; the server decides permission from it. */
68
- export async function startBrowserSession(): Promise<{ id: string; backend: string }> {
69
- const response = await fetch('/api/sessions', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ backend: 'codex', intent: 'build' }) })
101
+ function refresh(): void { mergeEvents([]); emit() }
102
+
103
+ type SlashCommand = { name: string; description: string; args?: Array<{ value: string; description: string }> }
104
+
105
+ /** (Re)opens the shown conversation's event stream; the previous stream, if any, is closed. */
106
+ function connect(): void {
107
+ source?.close()
108
+ const subscribedSession = sessionId!
109
+ const nextSource = new EventSource(`/api/sessions/${subscribedSession}/events?after=${cursor}${sessionBackend === 'anthropic' ? '&view=1' : ''}`)
110
+ source = nextSource
111
+ nextSource.onmessage = (event) => applyEvent(JSON.parse(event.data))
112
+ if (sessionBackend === 'anthropic') nextSource.addEventListener('view', (event) => {
113
+ const data = JSON.parse((event as MessageEvent).data) as ViewStreamEvent
114
+ if (data.type === 'view') chatView = data.id
115
+ viewListeners.forEach((listener) => listener(data))
116
+ })
117
+ nextSource.onopen = () => {
118
+ void fetch(`/api/sessions/${subscribedSession}/history`).then(async (response) => {
119
+ if (!response.ok || source !== nextSource || sessionId !== subscribedSession) return
120
+ const result = await response.json() as { events: Array<{ sequence: number; type: string; text?: string; reason?: string; clientMessageId?: string; attachments?: ChatAttachment[]; sources?: string[] }>; status: string }
121
+ if (source !== nextSource || sessionId !== subscribedSession) return
122
+ setStatus(mergeEvents(result.events) ?? result.status)
123
+ emit()
124
+ }).catch(() => {})
125
+ }
126
+ nextSource.onerror = () => {
127
+ if (source !== nextSource || nextSource.readyState !== EventSource.CLOSED || sessionId !== subscribedSession) return
128
+ // Reconnect only while this conversation is still ours to read; a 401/403 means access
129
+ // changed, so let identity decide instead of retrying into the same refusal.
130
+ void fetch(`/api/sessions/${subscribedSession}/history`).then((response) => {
131
+ if (source !== nextSource || sessionId !== subscribedSession) return
132
+ if (response.status === 401 || response.status === 403) void refreshIdentity()
133
+ else connect()
134
+ }, () => { if (source === nextSource) connect() })
135
+ }
136
+ }
137
+
138
+ // This tab's view of the open chat, from its event stream: sent with each message so the
139
+ // assistant's offers come here, and used to answer them.
140
+ let chatView: string | undefined
141
+ type ViewStreamEvent = ViewEvent | { type: 'view'; id: string }
142
+ const viewListeners = new Set<(event: ViewStreamEvent) => void>()
143
+ export function subscribeChatView(listener: (event: ViewStreamEvent) => void): () => void {
144
+ viewListeners.add(listener)
145
+ return () => viewListeners.delete(listener)
146
+ }
147
+ export async function answerOffer(offer: string, accept: boolean): Promise<void> {
148
+ if (!chatView) throw new Error('This tab is not connected to the chat.')
149
+ const response = await fetch(`/api/app/views/${chatView}/answer`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ offer, accept }) })
150
+ if (!response.ok) throw new Error((await response.json()).error ?? 'Unable to answer the offer')
151
+ }
152
+ function messageId(): string { return globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random()}` }
153
+ function findOutbox(id: string): OutboxMessage | undefined { return outbox.find((message) => message.id === id && message.sessionId === sessionId) }
154
+
155
+ async function deliver(message: OutboxMessage): Promise<void> {
156
+ try {
157
+ const response = await fetch(`/api/sessions/${message.sessionId}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text: message.text, attachments: message.attachments, clientMessageId: message.id, ...(chatView ? { view: chatView } : {}) }) })
158
+ if (!response.ok) throw new Error((await response.json()).error ?? 'Message was not accepted')
159
+ message.delivery = undefined
160
+ saveOutbox()
161
+ refresh()
162
+ } catch (error) {
163
+ message.delivery = 'failed'
164
+ saveOutbox()
165
+ refresh()
166
+ throw error
167
+ }
168
+ }
169
+
170
+ /**
171
+ * Starts a conversation of `kind`: the builder agent (the only build-starting call in the UI, declaring
172
+ * build intent explicitly; the server decides permission), a terminal chat agent, or the API assistant.
173
+ */
174
+ export async function startBrowserSession(backend = 'codex', kind: SessionKind = 'builder'): Promise<{ id: string; backend: string }> {
175
+ return kind === 'anthropic' ? begin(kind, '/api/chat', {}, 'anthropic') : begin(kind, '/api/sessions', { backend, intent: kind === 'builder' ? 'build' : 'chat' }, backend)
176
+ }
177
+
178
+ async function begin(kind: SessionKind, url: string, body: object, backend: string): Promise<{ id: string; backend: string }> {
179
+ const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
70
180
  const result = await response.json() as { id?: string; backend?: string; error?: string }
71
181
  if (!response.ok || !result.id) throw new Error(result.error ?? 'Unable to start session')
72
- source?.close()
73
- source = undefined
74
- sessionId = result.id
75
- window.sessionStorage.setItem(storageKey, sessionId)
182
+ show(kind, result.id, result.backend ?? backend)
183
+ return { id: result.id, backend: sessionBackend! }
184
+ }
185
+
186
+ /** Makes `id` the shown conversation: remembered for its kind, stream reconnected if a Chat is listening. */
187
+ function show(kind: SessionKind, id: string, backend: string): void {
188
+ sessionKind = kind
189
+ sessionId = id
190
+ sessionBackend = backend
191
+ try { window.sessionStorage.setItem(storageKey(kind), id) } catch {}
76
192
  cursor = -1
77
193
  eventLog = new Map()
78
194
  setStatus('ready')
79
195
  messages = []
80
196
  emit()
81
- return { id: result.id, backend: result.backend ?? 'codex' }
197
+ if (listeners.size) connect()
198
+ sessionListeners.forEach((listener) => listener(id))
82
199
  }
83
200
 
201
+ /** Closes the shown conversation's stream without forgetting it; the chat column is going away. */
202
+ export function leaveBrowserSession(): void {
203
+ source?.close()
204
+ source = undefined
205
+ sessionId = undefined
206
+ sessionKind = undefined
207
+ sessionBackend = undefined
208
+ messages = []
209
+ emit()
210
+ }
211
+ let sessionKind: SessionKind | undefined
212
+
84
213
  export function currentBrowserSession(): string | undefined { return sessionId }
214
+ export function currentBrowserBackend(): string | undefined { return sessionBackend }
85
215
 
86
- export async function restoreBrowserSession(): Promise<boolean> {
87
- if (!sessionId) return false
88
- const response = await fetch(`/api/sessions/${sessionId}/history`)
216
+ /** Drops this tab's remembered conversations, e.g. when a different person signs in. */
217
+ export function forgetBrowserSession(): void {
218
+ sessionId = undefined
219
+ for (const kind of kinds) try { window.sessionStorage.removeItem(storageKey(kind)) } catch {}
220
+ }
221
+
222
+ /** Reopens this tab's conversation of `kind`, else the latest one of that kind on the server. */
223
+ export async function restoreBrowserSession(kind: SessionKind = 'builder'): Promise<boolean> {
224
+ let id: string | undefined
225
+ try { id = window.sessionStorage.getItem(storageKey(kind)) ?? undefined } catch { /* Discovery below. */ }
226
+ if (!id) {
227
+ const discovered = await fetch(kind === 'anthropic' ? '/api/chat' : `/api/sessions/latest${kind === 'chat' ? '?chat=1' : ''}`)
228
+ if (discovered.status === 404) return false
229
+ if (!discovered.ok) throw new Error((await discovered.json()).error ?? 'Unable to discover a saved session')
230
+ const found = await discovered.json() as { id?: string; latest?: { id: string } }
231
+ id = kind === 'anthropic' ? found.latest?.id : found.id
232
+ if (!id) return false
233
+ }
234
+ const response = await fetch(`/api/sessions/${id}/history`)
89
235
  if (response.status === 404) {
90
- sessionId = undefined
91
- window.sessionStorage.removeItem(storageKey)
92
- return false
236
+ try { window.sessionStorage.removeItem(storageKey(kind)) } catch {}
237
+ return restoreBrowserSession(kind)
93
238
  }
94
239
  if (!response.ok) throw new Error((await response.json()).error ?? 'Unable to restore session')
95
- const result = await response.json() as { events: Array<{ sequence: number; type: string; text?: string; reason?: string }>; status: string }
96
- eventLog = new Map()
240
+ const result = await response.json() as { events: Array<{ sequence: number; type: string; text?: string; reason?: string; clientMessageId?: string; attachments?: ChatAttachment[]; sources?: string[] }>; status: string; backend?: string }
241
+ show(kind, id, result.backend ?? (kind === 'anthropic' ? 'anthropic' : 'codex'))
97
242
  setStatus(mergeEvents(result.events) ?? result.status)
98
243
  emit()
99
244
  return true
@@ -111,10 +256,10 @@ export async function interruptBrowserSession(): Promise<void> {
111
256
  if (!response.ok) throw new Error((await response.json()).error ?? 'Unable to interrupt session')
112
257
  }
113
258
 
114
- function applyEvent(event: { sessionId?: string; sequence: number; type: string; text?: string; status?: string; reason?: string }): void {
259
+ function applyEvent(event: { sessionId?: string; sequence: number; type: string; text?: string; status?: string; reason?: string; clientMessageId?: string; attachments?: ChatAttachment[]; sources?: string[] }): void {
115
260
  if (event.sessionId && event.sessionId !== sessionId) return
116
- if (event.sequence <= cursor) return
117
- cursor = event.sequence
261
+ if (eventLog.has(event.sequence)) return
262
+ cursor = Math.max(cursor, event.sequence)
118
263
  if (event.status) setStatus(event.status)
119
264
  // Live-only: a replayed 'rebuilt' from history/reload restoration must never re-trigger this.
120
265
  if (event.type === 'rebuilt') { window.location.reload(); return }
@@ -122,43 +267,74 @@ function applyEvent(event: { sessionId?: string; sequence: number; type: string;
122
267
  emit()
123
268
  }
124
269
 
125
- export const chat: ChatAdapter = {
270
+ // interrupt, commands and runCommand are declared here too so the object still typechecks against a golem-ui whose ChatAdapter predates them (0.1.1).
271
+ export const chat: ChatAdapter & { retry(messageId: string): Promise<void>; interrupt(): Promise<void>; openSource(location: string): void; commands(): Promise<SlashCommand[]>; runCommand(line: string): Promise<string> } = {
272
+ interrupt: interruptBrowserSession,
273
+ // A source chip: the Brain panel opens on the cited lines, and on a phone the canvas tab comes forward.
274
+ openSource: (location) => navigation.go(`${window.location.pathname}?brain=${encodeURIComponent(location)}`),
126
275
  history: async () => {
127
276
  if (!sessionId) return []
128
277
  const response = await fetch(`/api/sessions/${sessionId}/history`)
129
278
  if (!response.ok) throw new Error((await response.json()).error ?? 'Unable to load session history')
130
- const result = await response.json() as { events: Array<{ sequence: number; type: string; text?: string; reason?: string }>; status: string }
279
+ const result = await response.json() as { events: Array<{ sequence: number; type: string; text?: string; reason?: string; clientMessageId?: string; attachments?: ChatAttachment[]; sources?: string[] }>; status: string }
131
280
  setStatus(mergeEvents(result.events) ?? result.status)
132
281
  emit()
133
282
  return [...messages]
134
283
  },
135
284
  subscribe(listener) {
136
285
  listeners.add(listener)
137
- if (sessionId) {
138
- source?.close()
139
- const subscribedSession = sessionId
140
- let subscribedSource: EventSource
141
- const connect = () => {
142
- const nextSource = new EventSource(`/api/sessions/${subscribedSession}/events?after=${cursor}`)
143
- subscribedSource = nextSource
144
- source = nextSource
145
- nextSource.onmessage = (event) => applyEvent(JSON.parse(event.data))
146
- nextSource.onerror = () => {
147
- if (source === nextSource && nextSource.readyState === EventSource.CLOSED && sessionId === subscribedSession) {
148
- connect()
149
- }
150
- }
151
- }
152
- connect()
153
- return () => { subscribedSource.close(); if (source === subscribedSource) source = undefined; listeners.delete(listener) }
154
- }
155
- return () => listeners.delete(listener)
286
+ if (sessionId && !source) connect()
287
+ return () => { listeners.delete(listener); if (!listeners.size) { source?.close(); source = undefined } }
288
+ },
289
+ // Slash commands: `/reset` and the harness's own, from the server; a reply naming a new session switches to it.
290
+ commands: async () => {
291
+ if (!sessionId) return []
292
+ const response = await fetch(`/api/sessions/${sessionId}/commands`)
293
+ if (!response.ok) throw new Error((await response.json()).error ?? 'Unable to list commands')
294
+ return (await response.json() as { commands: SlashCommand[] }).commands
295
+ },
296
+ runCommand: async (line: string) => {
297
+ if (!sessionId || !sessionKind) throw new Error('Start a conversation before running a command')
298
+ const response = await fetch(`/api/sessions/${sessionId}/command`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ line }) })
299
+ const result = await response.json() as { text?: string; session?: string; error?: string }
300
+ if (!response.ok) throw new Error(result.error ?? 'Command failed')
301
+ if (result.session) show(sessionKind, result.session, sessionBackend ?? 'codex')
302
+ return result.text ?? ''
156
303
  },
157
304
  async send(text, attachments) {
158
- if (!sessionId) throw new Error('Enter build mode before sending a message')
159
- const response = await fetch(`/api/sessions/${sessionId}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text, attachments }) })
160
- if (!response.ok) throw new Error((await response.json()).error ?? 'Agent request failed')
305
+ if (!sessionId) throw new Error('Start a conversation before sending a message')
306
+ const message: OutboxMessage = { id: messageId(), sessionId, text, attachments, delivery: 'pending', at: new Date().toISOString() }
307
+ outbox.push(message)
308
+ saveOutbox()
309
+ refresh()
310
+ await deliver(message)
311
+ },
312
+ retry: async (id: string) => {
313
+ const message = findOutbox(id)
314
+ if (!message) return
315
+ message.delivery = 'pending'
316
+ saveOutbox()
317
+ refresh()
318
+ await deliver(message)
161
319
  },
162
320
  }
163
321
 
164
322
  export type { ChatMessage, User }
323
+
324
+ /** The app's `brain/` folder over the dev server's read-only routes; golem-ui's BrainAdapter shape. */
325
+ const brainGet = async <T,>(route: string, params: Record<string, string>): Promise<T> => {
326
+ const response = await fetch(`/api/brain/${route}?${new URLSearchParams(params)}`)
327
+ if (!response.ok) throw new Error((await response.json().catch(() => ({}))).error ?? `Cannot read the brain (${response.status})`)
328
+ return response.json() as Promise<T>
329
+ }
330
+ export const brain = {
331
+ index: (dir = '') => brainGet<{ text: string }>('index', { dir }).then((r) => r.text),
332
+ list: (dir = '') => brainGet<{ entries: Array<{ path: string; kind: 'file' | 'dir' }> }>('list', { dir }).then((r) => r.entries),
333
+ read: (path: string) => brainGet<{ text: string }>('read', { path }).then((r) => r.text),
334
+ search: (query: string) => brainGet<{ hits: Array<{ path: string; line: number; excerpt: string }> }>('search', { q: query }).then((r) => r.hits),
335
+ subscribe(listener: () => void) {
336
+ const events = new EventSource('/api/brain/events')
337
+ events.onmessage = () => listener()
338
+ return () => events.close()
339
+ },
340
+ }
@@ -0,0 +1,104 @@
1
+ // Tiny zero-dep ANSI SGR → HTML renderer for pane frames, ported from Bridge Commander's ui/js/ansi.js.
2
+ // Handles reset, bold/dim, 16-color, 256-color and truecolor fg/bg; every other escape (cursor moves,
3
+ // OSC titles) is stripped. Text is HTML-escaped before any markup is added.
4
+ const BASE16 = [
5
+ '#3b4453', '#cd3131', '#0dbc79', '#e5e510', '#2472c8', '#bc3fc0', '#11a8cd', '#e5e5e5',
6
+ '#666666', '#f14c4c', '#23d18b', '#f5f543', '#3b8eea', '#d670d6', '#29b8db', '#ffffff',
7
+ ]
8
+ const CUBE = [0, 95, 135, 175, 215, 255]
9
+ const hex2 = (n: number) => n.toString(16).padStart(2, '0')
10
+ const rgb = (r: number, g: number, b: number) => `#${hex2(r)}${hex2(g)}${hex2(b)}`
11
+
12
+ function color256(n: number): string | null {
13
+ if (!Number.isInteger(n) || n < 0 || n > 255) return null
14
+ if (n < 16) return BASE16[n]
15
+ if (n < 232) { const v = n - 16; return rgb(CUBE[Math.floor(v / 36)], CUBE[Math.floor(v / 6) % 6], CUBE[v % 6]) }
16
+ const g = 8 + (n - 232) * 10
17
+ return rgb(g, g, g)
18
+ }
19
+
20
+ type Style = { bold: boolean; dim: boolean; fg: string | null; bg: string | null }
21
+ export type Segment = Style & { text: string }
22
+
23
+ export function ansiToSegments(str: string): Segment[] {
24
+ const st: Style = { bold: false, dim: false, fg: null, bg: null }
25
+ const out: Segment[] = []
26
+ let buf = ''
27
+ let bufSt: Style = { ...st }
28
+ const same = (a: Style, b: Style) => a.fg === b.fg && a.bg === b.bg && a.bold === b.bold && a.dim === b.dim
29
+ const flush = () => { if (buf) { out.push({ text: buf, ...bufSt }); buf = '' } }
30
+ const applySgr = (raw: string) => {
31
+ const parts = (raw === '' ? '0' : raw).split(';')
32
+ for (let p = 0; p < parts.length; p++) {
33
+ const item = parts[p]
34
+ const code = parseInt(item.split(':')[0], 10)
35
+ if (Number.isNaN(code) || code === 0) { st.bold = false; st.dim = false; st.fg = null; st.bg = null }
36
+ else if (code === 1) st.bold = true
37
+ else if (code === 2) st.dim = true
38
+ else if (code === 22) { st.bold = false; st.dim = false }
39
+ else if (code >= 30 && code <= 37) st.fg = BASE16[code - 30]
40
+ else if (code >= 90 && code <= 97) st.fg = BASE16[code - 90 + 8]
41
+ else if (code === 39) st.fg = null
42
+ else if (code >= 40 && code <= 47) st.bg = BASE16[code - 40]
43
+ else if (code >= 100 && code <= 107) st.bg = BASE16[code - 100 + 8]
44
+ else if (code === 49) st.bg = null
45
+ else if (code === 38 || code === 48) {
46
+ let mode: string | undefined
47
+ let args: string[]
48
+ if (item.includes(':')) { const sub = item.split(':'); mode = sub[1]; args = sub.length >= 6 ? sub.slice(3) : sub.slice(2) }
49
+ else {
50
+ mode = parts[p + 1]
51
+ if (mode === '5') { args = [parts[p + 2]]; p += 2 }
52
+ else if (mode === '2') { args = parts.slice(p + 2, p + 5); p += 4 }
53
+ else { args = []; p += 1 }
54
+ }
55
+ let col: string | null = null
56
+ if (mode === '5') col = color256(parseInt(args[0], 10))
57
+ else if (mode === '2') {
58
+ const [r, g, b] = args.map((v) => parseInt(v, 10))
59
+ if ([r, g, b].every((v) => Number.isInteger(v) && v >= 0 && v <= 255)) col = rgb(r, g, b)
60
+ }
61
+ if (code === 38) st.fg = col; else st.bg = col
62
+ }
63
+ }
64
+ }
65
+ for (let i = 0; i < str.length; i++) {
66
+ const c = str[i]
67
+ if (c === '\x1b') {
68
+ const n = str[i + 1]
69
+ if (n === '[') {
70
+ let j = i + 2
71
+ while (j < str.length && !/[@-~]/.test(str[j])) j++
72
+ if (j < str.length && str[j] === 'm') applySgr(str.slice(i + 2, j))
73
+ i = j < str.length ? j : str.length
74
+ continue
75
+ }
76
+ if (n === ']') {
77
+ let j = i + 2
78
+ while (j < str.length && str[j] !== '\x07' && !(str[j] === '\x1b' && str[j + 1] === '\\')) j++
79
+ i = str[j] === '\x1b' ? j + 1 : j
80
+ continue
81
+ }
82
+ i++
83
+ continue
84
+ }
85
+ if (!same(st, bufSt)) { flush(); bufSt = { ...st } }
86
+ buf += c
87
+ }
88
+ flush()
89
+ return out
90
+ }
91
+
92
+ export function ansiToHtml(str: string): string {
93
+ let out = ''
94
+ for (const s of ansiToSegments(str)) {
95
+ const parts: string[] = []
96
+ if (s.fg) parts.push(`color:${s.fg}`)
97
+ if (s.bg) parts.push(`background:${s.bg}`)
98
+ if (s.bold) parts.push('font-weight:700')
99
+ if (s.dim) parts.push('opacity:.55')
100
+ const text = s.text.replace(/[&<>]/g, (c) => (c === '&' ? '&amp;' : c === '<' ? '&lt;' : '&gt;'))
101
+ out += parts.length ? `<span style="${parts.join(';')}">${text}</span>` : text
102
+ }
103
+ return out
104
+ }
@@ -1,9 +1,12 @@
1
1
  declare module '@golem/app' {
2
2
  import type { ComponentType } from 'react'
3
- const App: ComponentType
3
+ /** `screen` is the id of the chosen `screens` item, undefined for the app's first screen. */
4
+ const App: ComponentType<{ screen?: string }>
4
5
  export default App
6
+ /** Optional: one bottom-menu item per screen the app has beyond its first. */
7
+ export const screens: { id: string; label: string; icon?: string }[] | undefined
5
8
  }
6
9
  declare module '@golem/config' {
7
- const config: { title: string }
10
+ const config: { title: string; brain?: boolean }
8
11
  export default config
9
12
  }