golem-kit 0.1.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.
@@ -0,0 +1,326 @@
1
+ import { randomUUID } from 'node:crypto'
2
+ import type { AgentName } from './discovery.ts'
3
+
4
+ export type BackendEvent =
5
+ | { type: 'message'; text: string }
6
+ | { type: 'interrupted'; reason?: string }
7
+ | { type: 'error'; message: string }
8
+
9
+ /** The runtime contract only; Claude/Codex process bridging is intentionally not implemented yet. */
10
+ export type SessionBackend = {
11
+ start(emit: (event: BackendEvent) => void): Promise<void>
12
+ send(text: string): Promise<void>
13
+ shutdown(): Promise<void>
14
+ interrupt?(): Promise<void>
15
+ threadId?(): string | undefined
16
+ }
17
+
18
+ export type SessionStatus = 'starting' | 'ready' | 'interrupted' | 'stopped' | 'failed'
19
+
20
+ export type SessionEvent = {
21
+ sequence: number
22
+ sessionId: string
23
+ type: 'status' | 'user' | 'message' | 'interrupted' | 'error' | 'rebuilt'
24
+ status?: SessionStatus
25
+ text?: string
26
+ reason?: string
27
+ }
28
+
29
+ export class Session {
30
+ readonly history: SessionEvent[] = []
31
+ status: SessionStatus = 'starting'
32
+ private readonly listeners = new Set<(event: SessionEvent) => void>()
33
+ private sequence = 0
34
+ private readonly pending: Array<{ text: string; resolve: () => void; reject: (error: Error) => void }> = []
35
+ private active = false
36
+ private dispatchScheduled = false
37
+ private closed = false
38
+ private shutdownPromise: Promise<void> | undefined
39
+ private workerShutdownPromise: Promise<void> | undefined
40
+ private activeReject: ((error: Error) => void) | undefined
41
+ private workerStarted = false
42
+ private persistence = Promise.resolve()
43
+ private persistenceError: Error | undefined
44
+ readonly id: string
45
+ readonly backend: AgentName
46
+ /** Server-owned: set once at creation from the request's explicit build intent, never inferred. */
47
+ readonly buildMode: boolean
48
+ private readonly worker: SessionBackend
49
+ private readonly save?: (snapshot: SessionSnapshot) => Promise<void>
50
+
51
+ constructor(
52
+ backend: AgentName,
53
+ worker: SessionBackend,
54
+ id: string,
55
+ buildMode = false,
56
+ save?: (snapshot: SessionSnapshot) => Promise<void>,
57
+ ) {
58
+ this.id = id
59
+ this.backend = backend
60
+ this.worker = worker
61
+ this.buildMode = buildMode
62
+ this.save = save
63
+ }
64
+
65
+ async start(): Promise<void> {
66
+ try {
67
+ await this.startWorker()
68
+ if (this.status !== 'starting') {
69
+ await this.closeWorker()
70
+ throw new Error(`Session ${this.status} during startup`)
71
+ }
72
+ this.setStatus('ready')
73
+ } catch (error) {
74
+ if (this.status === 'starting') {
75
+ this.setStatus('failed')
76
+ this.record({ type: 'error', text: error instanceof Error ? error.message : String(error) })
77
+ }
78
+ await this.closeWorker()
79
+ throw error
80
+ }
81
+ }
82
+
83
+ static restore(snapshot: SessionSnapshot, worker: SessionBackend, save?: (snapshot: SessionSnapshot) => Promise<void>): Session {
84
+ const session = new Session(snapshot.backend, worker, snapshot.id, snapshot.buildMode, save)
85
+ session.history.push(...snapshot.history)
86
+ session.status = snapshot.status
87
+ session.sequence = Math.max(-1, ...snapshot.history.map((event) => event.sequence)) + 1
88
+ session.closed = snapshot.status === 'stopped'
89
+ if (snapshot.active) session.recoverInterrupted()
90
+ return session
91
+ }
92
+
93
+ snapshot(): SessionSnapshot {
94
+ return { id: this.id, backend: this.backend, buildMode: this.buildMode, status: this.status, active: this.active, history: this.history, threadId: this.worker.threadId?.() }
95
+ }
96
+
97
+ async flush(): Promise<void> {
98
+ await this.persistence
99
+ if (this.persistenceError) throw this.persistenceError
100
+ }
101
+
102
+ send(text: string): Promise<void> {
103
+ if (this.closed || (this.status !== 'ready' && this.status !== 'interrupted' && this.status !== 'failed')) {
104
+ return Promise.reject(new Error(`Session ${this.status}`))
105
+ }
106
+ if (this.status === 'interrupted' || this.status === 'failed') this.setStatus('ready')
107
+ return new Promise((resolve, reject) => {
108
+ this.pending.push({ text, resolve, reject })
109
+ this.pump()
110
+ })
111
+ }
112
+
113
+ subscribe(listener: (event: SessionEvent) => void): () => void {
114
+ this.listeners.add(listener)
115
+ return () => this.listeners.delete(listener)
116
+ }
117
+
118
+ subscribeFrom(sequence: number, listener: (event: SessionEvent) => void): () => void {
119
+ for (const event of this.history) if (event.sequence > sequence) listener(event)
120
+ return this.subscribe(listener)
121
+ }
122
+
123
+ async shutdown(): Promise<void> {
124
+ if (this.shutdownPromise) return this.shutdownPromise
125
+ this.closed = true
126
+ this.setStatus('stopped')
127
+ this.rejectPending(new Error('Session stopped'))
128
+ this.shutdownPromise = this.closeWorker()
129
+ return this.shutdownPromise
130
+ }
131
+
132
+ async dispose(): Promise<void> { await this.closeWorker() }
133
+
134
+ abandon(): void {
135
+ if (!this.active || this.closed) return
136
+ this.setStatus('interrupted')
137
+ this.record({ type: 'interrupted', reason: 'server restarted during this turn' })
138
+ this.activeReject?.(new Error('Session interrupted'))
139
+ }
140
+
141
+ async interrupt(): Promise<void> {
142
+ if (this.closed || this.status === 'stopped') return
143
+ if (this.status !== 'ready') return
144
+ this.setStatus('interrupted')
145
+ this.record({ type: 'interrupted', reason: 'interrupted by user' })
146
+ this.activeReject?.(new Error('Session interrupted'))
147
+ this.rejectPending(new Error('Session interrupted'))
148
+ await this.worker.interrupt?.()
149
+ }
150
+
151
+ /** Server-owned rebuild-then-refresh signal; fired once after a successful build-mode turn. */
152
+ notifyRebuilt(): void {
153
+ if (this.closed) return
154
+ this.record({ type: 'rebuilt' })
155
+ }
156
+
157
+ /** Routed through the existing error surface: visible in chat, session stays recoverable. */
158
+ notifyBuildFailed(message: string): void {
159
+ if (this.closed) return
160
+ this.setStatus('failed')
161
+ this.record({ type: 'error', text: message })
162
+ }
163
+
164
+ private receive(event: BackendEvent): void {
165
+ if (this.closed) return
166
+ if (event.type === 'message') this.record({ type: 'message', text: event.text })
167
+ if (event.type === 'interrupted') {
168
+ const alreadyInterrupted = this.status === 'interrupted'
169
+ if (!alreadyInterrupted) this.setStatus('interrupted')
170
+ this.rejectPending(new Error('Session interrupted'))
171
+ if (!alreadyInterrupted) this.record({ type: 'interrupted', reason: event.reason })
172
+ }
173
+ if (event.type === 'error') {
174
+ this.setStatus('failed')
175
+ this.rejectPending(new Error('Session failed'))
176
+ this.record({ type: 'error', text: event.message })
177
+ }
178
+ }
179
+
180
+ private pump(): void {
181
+ if (this.active || this.dispatchScheduled || this.closed || this.status !== 'ready') return
182
+ const next = this.pending[0]
183
+ if (!next) return
184
+ this.dispatchScheduled = true
185
+ Promise.resolve()
186
+ .then(async () => {
187
+ this.dispatchScheduled = false
188
+ if (this.closed || this.status !== 'ready' || this.pending[0] !== next) {
189
+ this.pump()
190
+ return
191
+ }
192
+ this.pending.shift()
193
+ this.active = true
194
+ this.record({ type: 'user', text: next.text })
195
+ if (this.closed || this.status !== 'ready') {
196
+ this.active = false
197
+ next.reject(new Error(`Session ${this.status}`))
198
+ this.pump()
199
+ return
200
+ }
201
+ try {
202
+ await this.startWorker()
203
+ if (this.closed || this.status !== 'ready') {
204
+ this.active = false
205
+ next.reject(new Error(`Session ${this.status}`))
206
+ this.pump()
207
+ return
208
+ }
209
+ this.activeReject = next.reject
210
+ Promise.resolve(this.worker.send(next.text)).then(next.resolve, next.reject).finally(() => {
211
+ this.activeReject = undefined
212
+ this.active = false
213
+ this.persist()
214
+ this.pump()
215
+ })
216
+ } catch (error) {
217
+ this.active = false
218
+ next.reject(error instanceof Error ? error : new Error(String(error)))
219
+ this.pump()
220
+ }
221
+ })
222
+ }
223
+
224
+ private rejectPending(error: Error): void {
225
+ while (this.pending.length) this.pending.shift()?.reject(error)
226
+ }
227
+
228
+ private closeWorker(): Promise<void> {
229
+ if (!this.workerShutdownPromise) this.workerShutdownPromise = Promise.resolve().then(() => this.worker.shutdown())
230
+ return this.workerShutdownPromise
231
+ }
232
+
233
+ private async startWorker(): Promise<boolean> {
234
+ if (this.workerStarted) return false
235
+ await this.worker.start((event) => this.receive(event))
236
+ this.workerStarted = true
237
+ return true
238
+ }
239
+
240
+ private recoverInterrupted(): void {
241
+ this.active = false
242
+ this.status = 'interrupted'
243
+ this.record({ type: 'status', status: 'interrupted' })
244
+ this.record({ type: 'interrupted', reason: 'server restarted during this turn' })
245
+ }
246
+
247
+ private setStatus(status: SessionStatus): void {
248
+ this.status = status
249
+ this.record({ type: 'status', status })
250
+ }
251
+
252
+ private record(event: Omit<SessionEvent, 'sequence' | 'sessionId'>): void {
253
+ const complete = { ...event, sequence: this.sequence++, sessionId: this.id }
254
+ this.history.push(complete)
255
+ this.listeners.forEach((listener) => listener(complete))
256
+ this.persist()
257
+ }
258
+
259
+ private persist(): void {
260
+ if (!this.save || this.persistenceError) return
261
+ const snapshot = this.snapshot()
262
+ this.persistence = this.persistence.catch(() => {}).then(() => this.save!(snapshot))
263
+ void this.persistence.catch((error) => {
264
+ if (this.persistenceError) return
265
+ this.persistenceError = error instanceof Error ? error : new Error(String(error))
266
+ const complete = { type: 'error' as const, text: `Unable to save conversation: ${this.persistenceError.message}`, sequence: this.sequence++, sessionId: this.id }
267
+ this.history.push(complete)
268
+ this.listeners.forEach((listener) => listener(complete))
269
+ })
270
+ }
271
+ }
272
+
273
+ export type SessionSnapshot = {
274
+ id: string
275
+ backend: AgentName
276
+ buildMode: boolean
277
+ status: SessionStatus
278
+ active: boolean
279
+ history: SessionEvent[]
280
+ threadId?: string
281
+ }
282
+
283
+ export class SessionManager {
284
+ private readonly sessions = new Map<string, Session>()
285
+ private readonly save: ((snapshots: SessionSnapshot[]) => Promise<void>) | undefined
286
+
287
+ constructor(save?: (snapshots: SessionSnapshot[]) => Promise<void>) { this.save = save }
288
+
289
+ private persist = async (): Promise<void> => this.save?.([...this.sessions.values()].map((session) => session.snapshot()))
290
+
291
+ async start(backend: AgentName, worker: SessionBackend, buildMode = false): Promise<Session> {
292
+ const session = new Session(backend, worker, randomUUID(), buildMode, this.persist)
293
+ this.sessions.set(session.id, session)
294
+ try {
295
+ await session.start()
296
+ await session.flush()
297
+ return session
298
+ } catch (error) {
299
+ this.sessions.delete(session.id)
300
+ throw error
301
+ }
302
+ }
303
+
304
+ get(id: string): Session | undefined { return this.sessions.get(id) }
305
+
306
+ restore(snapshots: SessionSnapshot[], createWorker: (snapshot: SessionSnapshot) => SessionBackend): void {
307
+ for (const snapshot of snapshots) this.sessions.set(snapshot.id, Session.restore(snapshot, createWorker(snapshot), this.persist))
308
+ }
309
+
310
+ async shutdownAll(): Promise<void> {
311
+ await Promise.all([...this.sessions.values()].map((session) => session.shutdown()))
312
+ }
313
+
314
+ async disposeAll(): Promise<void> {
315
+ for (const session of this.sessions.values()) session.abandon()
316
+ await Promise.all([...this.sessions.values()].map((session) => session.dispose()))
317
+ }
318
+
319
+ async flushAll(): Promise<void> { await Promise.all([...this.sessions.values()].map((session) => session.flush())) }
320
+
321
+ async shutdown(id: string): Promise<void> {
322
+ const session = this.sessions.get(id)
323
+ if (!session) throw new Error(`Unknown session ${id}`)
324
+ await session.shutdown()
325
+ }
326
+ }
@@ -0,0 +1,42 @@
1
+ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
2
+ import { dirname, join } from 'node:path'
3
+ import { randomUUID } from 'node:crypto'
4
+ import type { SessionSnapshot } from './session.ts'
5
+
6
+ type State = { version: 1; sessions: SessionSnapshot[] }
7
+
8
+ export class ConversationState {
9
+ private readonly file: string
10
+ private writes = Promise.resolve()
11
+
12
+ constructor(directory: string) { this.file = join(directory, 'conversations.json') }
13
+
14
+ async load(): Promise<SessionSnapshot[]> {
15
+ try {
16
+ const state = JSON.parse(await readFile(this.file, 'utf8')) as State
17
+ if (state.version !== 1 || !Array.isArray(state.sessions)) throw new Error('unsupported conversation state')
18
+ return state.sessions
19
+ } catch (error) {
20
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []
21
+ throw new Error(`Cannot load saved conversations: ${error instanceof Error ? error.message : String(error)}`)
22
+ }
23
+ }
24
+
25
+ async save(sessions: SessionSnapshot[]): Promise<void> {
26
+ const contents = JSON.stringify({ version: 1, sessions }) + '\n'
27
+ this.writes = this.writes.catch(() => {}).then(() => this.replace(contents))
28
+ return this.writes
29
+ }
30
+
31
+ private async replace(contents: string): Promise<void> {
32
+ const temporary = `${this.file}.${randomUUID()}.tmp`
33
+ try {
34
+ await mkdir(dirname(this.file), { recursive: true })
35
+ await writeFile(temporary, contents, 'utf8')
36
+ await rename(temporary, this.file)
37
+ } catch (error) {
38
+ try { await import('node:fs/promises').then(({ unlink }) => unlink(temporary)) } catch {}
39
+ throw new Error(`Cannot save conversations: ${error instanceof Error ? error.message : String(error)}`)
40
+ }
41
+ }
42
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,16 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2023",
4
+ "module": "ESNext",
5
+ "moduleResolution": "Bundler",
6
+ "strict": true,
7
+ "noEmit": true,
8
+ "allowImportingTsExtensions": true,
9
+ "jsx": "react-jsx",
10
+ "erasableSyntaxOnly": true,
11
+ "skipLibCheck": true,
12
+ "types": ["node"],
13
+ "paths": { "@golem/app": ["./src/app.tsx"], "@golem/config": ["./golem.config.ts"] }
14
+ },
15
+ "include": ["src/**/*.ts", "src/**/*.tsx", "vite.config.ts"]
16
+ }
package/vite.config.ts ADDED
@@ -0,0 +1,76 @@
1
+ import { execFileSync } from 'node:child_process'
2
+ import { existsSync } from 'node:fs'
3
+ import { resolve } from 'node:path'
4
+ import { pathToFileURL } from 'node:url'
5
+ import { defineConfig, type UserConfig } from 'vite'
6
+ import react from '@vitejs/plugin-react'
7
+
8
+ const frameworkRoot = import.meta.dirname
9
+
10
+ export type UiSource = {
11
+ root: string
12
+ revision: string
13
+ }
14
+
15
+ function gitRevision(root: string): string {
16
+ try {
17
+ return execFileSync('git', ['-C', root, 'rev-parse', '--short', 'HEAD'], { encoding: 'utf8' }).trim()
18
+ } catch {
19
+ return 'unavailable'
20
+ }
21
+ }
22
+
23
+ export function resolveUiSource(): UiSource | undefined {
24
+ const configured = process.env.GOLEM_UI_SOURCE
25
+ if (!configured) return undefined
26
+
27
+ const root = resolve(configured)
28
+ if (!existsSync(resolve(root, 'package.json'))) {
29
+ throw new Error(`GOLEM_UI_SOURCE must point to a golem-ui checkout containing package.json: ${root}`)
30
+ }
31
+ if (!existsSync(resolve(root, 'src/index.ts')) || !existsSync(resolve(root, 'src/styles.css'))) {
32
+ throw new Error(`GOLEM_UI_SOURCE is missing golem-ui src/index.ts or src/styles.css: ${root}`)
33
+ }
34
+ return { root, revision: gitRevision(root) }
35
+ }
36
+
37
+ export default defineConfig(async (): Promise<UserConfig> => {
38
+ const ui = resolveUiSource()
39
+ const appRoot = resolve(process.cwd())
40
+ const plugins: NonNullable<UserConfig['plugins']> = [react()]
41
+ if (ui) {
42
+ const styles = resolve(ui.root, 'src/styles.css')
43
+ plugins.push({
44
+ name: 'golem-ui-source-path',
45
+ enforce: 'pre',
46
+ transform(code: string, id: string) {
47
+ const sourcePath = JSON.stringify(ui.root.replaceAll('\\', '/'))
48
+ return id.split('?')[0] === styles
49
+ ? { code: `@source ${sourcePath};\n${code}`, map: null }
50
+ : undefined
51
+ },
52
+ })
53
+ const tailwindPlugin = resolve(ui.root, 'node_modules/@tailwindcss/vite/dist/index.mjs')
54
+ if (!existsSync(tailwindPlugin)) {
55
+ throw new Error(`GOLEM_UI_SOURCE needs @tailwindcss/vite installed; run pnpm install in ${ui.root}`)
56
+ }
57
+ const { default: tailwindcss } = await import(pathToFileURL(tailwindPlugin).href)
58
+ plugins.push(tailwindcss())
59
+ }
60
+ return {
61
+ plugins,
62
+ resolve: {
63
+ alias: [
64
+ ...(ui ? [
65
+ { find: /^golem-ui$/, replacement: resolve(ui.root, 'src/index.ts') },
66
+ { find: /^golem-ui\/styles\.css$/, replacement: resolve(ui.root, 'src/styles.css') },
67
+ ] : []),
68
+ { find: /^@golem\/app$/, replacement: resolve(appRoot, 'src/app.tsx') },
69
+ { find: /^@golem\/config$/, replacement: resolve(appRoot, 'golem.config.ts') },
70
+ ],
71
+ dedupe: ['react', 'react-dom'],
72
+ },
73
+ root: frameworkRoot,
74
+ build: { outDir: resolve(appRoot, 'dist'), emptyOutDir: true },
75
+ }
76
+ })