golem-kit 0.1.1 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +38 -0
- package/README.md +8 -5
- package/docs/agents.md +64 -0
- package/docs/app-backend.md +261 -0
- package/docs/architecture.md +93 -0
- package/docs/builder.md +15 -0
- package/docs/knowledge.md +35 -0
- package/docs/local-cli.md +22 -12
- package/docs/source-development.md +31 -0
- package/index.html +9 -0
- package/package.json +24 -5
- package/src/backend/accounts.ts +287 -0
- package/src/backend/app.ts +269 -0
- package/src/backend/files.ts +68 -0
- package/src/backend/http.ts +276 -0
- package/src/backend/index.ts +10 -0
- package/src/backend/jobs.ts +302 -0
- package/src/backend/jsonl.ts +87 -0
- package/src/backend/knowledge.ts +264 -0
- package/src/backend/model.ts +129 -0
- package/src/backend/rules.ts +53 -0
- package/src/backend/sqlite.ts +73 -0
- package/src/backend/views.ts +216 -0
- package/src/brain.ts +94 -0
- package/src/browser/adapters.ts +229 -53
- package/src/browser/ansi.ts +104 -0
- package/src/browser/app.d.ts +5 -2
- package/src/browser/app.tsx +167 -39
- package/src/browser/groups.tsx +29 -0
- package/src/browser/main.tsx +1 -0
- package/src/browser/panekeys.ts +34 -0
- package/src/browser/sources.tsx +113 -0
- package/src/browser/styles.css +36 -0
- package/src/browser/terminal.tsx +89 -0
- package/src/browser-build.ts +25 -7
- package/src/chat.ts +74 -0
- package/src/cli.ts +103 -14
- package/src/client.ts +205 -0
- package/src/config.ts +139 -5
- package/src/dev-server.ts +336 -39
- package/src/entry.mjs +23 -0
- package/src/eslint.mjs +55 -0
- package/src/operations.ts +169 -0
- package/src/runtime/assistant.ts +141 -0
- package/src/runtime/discovery.ts +13 -7
- package/src/runtime/harness/agent-status.js +388 -0
- package/src/runtime/harness/claude-tmux.js +573 -0
- package/src/runtime/harness/codex-notify.js +95 -0
- package/src/runtime/harness/codex-tmux.js +292 -0
- package/src/runtime/harness/fake.js +430 -0
- package/src/runtime/harness/package.json +1 -0
- package/src/runtime/harness/port.js +208 -0
- package/src/runtime/harness/tmux-session.js +556 -0
- package/src/runtime/harness/tmux.js +285 -0
- package/src/runtime/harness/turnend-hook.js +105 -0
- package/src/runtime/session.ts +171 -34
- package/src/runtime/tmux.ts +173 -0
- package/src/runtime/tool-names.ts +19 -0
- package/src/source-mode.ts +56 -0
- package/vite.config.ts +2 -4
- package/src/runtime/codex.ts +0 -119
package/src/browser/adapters.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 }>):
|
|
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 === '
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
91
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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('
|
|
159
|
-
const
|
|
160
|
-
|
|
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 === '&' ? '&' : c === '<' ? '<' : '>'))
|
|
101
|
+
out += parts.length ? `<span style="${parts.join(';')}">${text}</span>` : text
|
|
102
|
+
}
|
|
103
|
+
return out
|
|
104
|
+
}
|
package/src/browser/app.d.ts
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
declare module '@golem/app' {
|
|
2
2
|
import type { ComponentType } from 'react'
|
|
3
|
-
|
|
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
|
}
|