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.
Files changed (61) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/README.md +8 -5
  3. package/docs/agents.md +64 -0
  4. package/docs/app-backend.md +261 -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 +22 -12
  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 +25 -7
  36. package/src/chat.ts +74 -0
  37. package/src/cli.ts +103 -14
  38. package/src/client.ts +205 -0
  39. package/src/config.ts +139 -5
  40. package/src/dev-server.ts +336 -39
  41. package/src/entry.mjs +23 -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
package/src/chat.ts ADDED
@@ -0,0 +1,74 @@
1
+ import { createHash, randomBytes } from 'node:crypto'
2
+ import type { IncomingMessage } from 'node:http'
3
+ import Anthropic from '@anthropic-ai/sdk'
4
+ import { readCookie, type AppBackend } from './backend/http.ts'
5
+ import { ForbiddenError, type Principal } from './operations.ts'
6
+ import { AssistantBackend, type TurnContext } from './runtime/assistant.ts'
7
+ import type { SessionBackend } from './runtime/session.ts'
8
+
9
+ /**
10
+ * Ordinary-use chat: an API agent limited to the operations golem.config.ts lists, acting as the
11
+ * person chatting. A conversation belongs to an account, or for a signed-out visitor to an opaque
12
+ * browser cookie this server issues; nobody else can read or continue it.
13
+ */
14
+ export function ordinaryChat(backend: AppBackend) {
15
+ const profile = backend.config.agents?.ordinary
16
+ const cookie = `${backend.config.origin?.startsWith('https:') ? '__Host-' : ''}golem-chat-${backend.config.port}`
17
+ const secure = backend.config.origin?.startsWith('https:') ? '; Secure' : ''
18
+ const detail = !profile ? 'Chat is not turned on for this app.'
19
+ : !process.env.ANTHROPIC_API_KEY ? 'Chat needs ANTHROPIC_API_KEY in the server environment.'
20
+ : undefined
21
+
22
+ /** Operations as tools for one turn: only the listed ones, collections checked on parsed input. */
23
+ const tools = (context: TurnContext) => {
24
+ if (!profile) return []
25
+ const { collections, roots } = profile
26
+ return backend.app.agentTools(context.principal, context).filter((tool) => profile.operations.includes(tool.name)).map((tool) => {
27
+ const operation = backend.app.operations.find((one) => one.name === tool.name)
28
+ // The resource a call names, read from its parsed input; undefined when the profile does not scope it.
29
+ const scoped = (input: unknown): { value: unknown; allowed: string[] } | undefined => {
30
+ if (tool.name === 'view.request') {
31
+ const target = (input as { action?: unknown; input?: { root?: unknown } } | null)
32
+ return roots && target?.action === 'source.open' ? { value: target.input?.root, allowed: roots } : undefined
33
+ }
34
+ const parsed = operation?.input.safeParse(input)
35
+ if (!parsed?.success) return undefined
36
+ if (collections && tool.name.startsWith('records.')) return { value: (parsed.data as { collection: string }).collection, allowed: collections }
37
+ if (roots && tool.name.startsWith('knowledge.')) return { value: (parsed.data as { root: string }).root, allowed: roots }
38
+ return undefined
39
+ }
40
+ return {
41
+ ...tool,
42
+ call: async (input: unknown) => {
43
+ const scope = scoped(input)
44
+ if (scope && !scope.allowed.includes(scope.value as string)) throw new ForbiddenError(`Not allowed: ${tool.name}`)
45
+ return tool.call(input)
46
+ },
47
+ }
48
+ })
49
+ }
50
+
51
+ return {
52
+ available: !detail,
53
+ detail,
54
+ /** Whether the assistant may offer to open sources in a chat tab. */
55
+ views: () => Boolean(profile?.operations.includes('view.request') && backend.app.views.actions().length),
56
+ /** The trusted owner key for this request: the account, else this browser's cookie. Never from input. */
57
+ owner(request: IncomingMessage, principal: Principal): string | null {
58
+ if (principal.kind === 'user') return principal.id
59
+ const token = readCookie(request, cookie)
60
+ return token ? `browser:${createHash('sha256').update(token).digest('hex')}` : null
61
+ },
62
+ /** Issues the browser cookie a signed-out visitor's conversations belong to. */
63
+ issue(): { owner: string; header: string } {
64
+ const token = randomBytes(32).toString('base64url')
65
+ return { owner: `browser:${createHash('sha256').update(token).digest('hex')}`, header: `${cookie}=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=31536000${secure}` }
66
+ },
67
+ backend(transcript?: unknown): SessionBackend {
68
+ const client = detail ? undefined : new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY })
69
+ return new AssistantBackend(client ?? detail!, { model: profile?.model ?? '', instructions: profile?.instructions, tools }, Array.isArray(transcript) ? transcript : [])
70
+ },
71
+ }
72
+ }
73
+
74
+ export type OrdinaryChat = ReturnType<typeof ordinaryChat>
package/src/cli.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env tsx
2
2
  import { startDevServer } from './dev-server.ts';
3
+ import { discoverAgents } from './runtime/discovery.ts';
3
4
  import { buildBrowser } from './browser-build.ts';
4
5
  import { loadAppConfig, serverUrl } from './config.ts';
5
6
  import { execFileSync } from 'node:child_process';
@@ -14,17 +15,20 @@ Usage: ./golem <command>
14
15
  help Show every command (also the default).
15
16
  init Create a minimal app in the current directory.
16
17
  dev Serve the browser shell using golem.config.ts (127.0.0.1:3000 by default).
17
- Restart after changing settings. Uses the local Codex runtime from the browser.
18
+ Restart after changing settings. Uses local Claude Code or Codex from the browser.
18
19
  build Build the browser shell into dist/.
20
+ lint Check the app's architecture rules from eslint.config.mjs.
19
21
  doctor Report local shell and backend readiness.
22
+ say <text> | --file <f> Post a reply into the chat session that launched this agent
23
+ (GOLEM_SESSION and GOLEM_API are set in its tmux session).
20
24
 
21
- Requires Node.js >=22.18.0. Commands accept no additional arguments.
25
+ Requires Node.js >=22.18.0. Only \`say\` takes arguments.
22
26
  Exit codes: 0 success/clean shutdown, 1 unavailable or failed, 2 invalid usage.
23
27
  `;
24
28
 
25
29
  const [command = 'help', ...args] = process.argv.slice(2);
26
30
 
27
- if (args.length || !['help', 'init', 'dev', 'build', 'doctor'].includes(command)) {
31
+ if ((args.length && command !== 'say') || !['help', 'init', 'dev', 'build', 'lint', 'doctor', 'say'].includes(command)) {
28
32
  console.error('Invalid command or arguments. Run ./golem help.');
29
33
  process.exitCode = 2;
30
34
  } else {
@@ -43,11 +47,23 @@ if (args.length || !['help', 'init', 'dev', 'build', 'doctor'].includes(command)
43
47
  break;
44
48
  case 'doctor':
45
49
  console.log(`Golem shell readiness (Node ${process.version})
46
- Ready: local CLI, HTTP shell, golem-ui browser build and Codex session seam.
47
- Claude integration: not yet connected.
48
- Not implemented: Claude integration.
50
+ Ready: local CLI, HTTP shell, golem-ui browser build and agent session seam.
51
+ ${(await discoverAgents()).map(({ agent, status, runnable, detail }) => `${agent}: ${runnable ? 'runnable' : status === 'missing' ? 'not installed' : detail ?? status}`).join('\n')}
52
+ Not implemented: domain storage, accounts, permissions.
49
53
  The dev server defaults to 127.0.0.1:3000 and uses optional host/port from golem.config.ts.`);
50
54
  break;
55
+ case 'say':
56
+ try {
57
+ const text = args[0] === '--file' && args[1] ? readFileSync(args[1], 'utf8') : args.join(' ');
58
+ const { GOLEM_SESSION: session, GOLEM_API: api } = process.env;
59
+ if (!text.trim() || !session || !api) throw new Error('usage: golem say <text> | --file <f>, inside a Golem agent session (GOLEM_SESSION, GOLEM_API)');
60
+ const response = await fetch(`${api.replace(/\/$/, '')}/api/sessions/${session}/say`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ text }) });
61
+ if (!response.ok) throw new Error(`${response.status} ${((await response.json().catch(() => ({}))) as { error?: string }).error ?? ''}`.trim());
62
+ } catch (error) {
63
+ console.error(`Cannot say: ${error instanceof Error ? error.message : String(error)}${error instanceof Error && error.cause ? ` (${String(error.cause)})` : ""}`);
64
+ process.exitCode = 1;
65
+ }
66
+ break;
51
67
  case 'build':
52
68
  try {
53
69
  await buildBrowser();
@@ -56,6 +72,21 @@ The dev server defaults to 127.0.0.1:3000 and uses optional host/port from golem
56
72
  process.exitCode = 1;
57
73
  }
58
74
  break;
75
+ case 'lint':
76
+ try {
77
+ const { ESLint } = await import('eslint');
78
+ const eslint = new ESLint();
79
+ const results = await eslint.lintFiles(['.']);
80
+ const report = await (await eslint.loadFormatter('stylish')).format(results);
81
+ if (report) console.log(report);
82
+ if (results.some((result) => result.errorCount)) process.exitCode = 1;
83
+ else console.log('Architecture lint passed.');
84
+ } catch (error) {
85
+ console.error(`Cannot lint Golem app: ${error instanceof Error ? error.message : String(error)}
86
+ See node_modules/golem-kit/docs/architecture.md to add eslint.config.mjs.`);
87
+ process.exitCode = 1;
88
+ }
89
+ break;
59
90
  case 'dev':
60
91
  try {
61
92
  const config = await loadAppConfig();
@@ -85,7 +116,7 @@ The dev server defaults to 127.0.0.1:3000 and uses optional host/port from golem
85
116
 
86
117
  function initProject(): void {
87
118
  const root = resolve(process.cwd());
88
- const files = ['golem.config.ts', 'src/app.tsx', 'docs/domain.md', 'golem'];
119
+ const files = ['golem.config.ts', 'tsconfig.json', 'eslint.config.mjs', 'src/app.tsx', 'src/globals.d.ts', 'docs/domain.md', 'AGENTS.md', 'CLAUDE.md', 'golem', 'brain/index.md', 'brain/log.md'];
89
120
  const existing = files.filter((file) => existsSync(resolve(root, file)));
90
121
  if (existing.length) throw new Error(`refusing to overwrite existing files: ${existing.join(', ')}`);
91
122
  const packagePath = resolve(root, 'package.json');
@@ -95,20 +126,46 @@ function initProject(): void {
95
126
  if (!current.dependencies?.['golem-kit']) throw new Error('refusing to overwrite existing package.json');
96
127
  }
97
128
  const frameworkRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
98
- const framework = JSON.parse(readFileSync(resolve(frameworkRoot, 'package.json'), 'utf8')) as { version: string };
129
+ const framework = JSON.parse(readFileSync(resolve(frameworkRoot, 'package.json'), 'utf8')) as { version: string; dependencies: Record<string, string> };
99
130
  mkdirSync(resolve(root, 'src'), { recursive: true });
100
131
  mkdirSync(resolve(root, 'docs'), { recursive: true });
132
+ mkdirSync(resolve(root, 'brain'), { recursive: true });
101
133
  if (!existsSync(packagePath)) {
102
134
  writeFileSync(packagePath, JSON.stringify({
103
135
  name: 'golem-app', private: true, type: 'module', packageManager: 'pnpm@10.28.2',
104
136
  engines: { node: '>=22.18.0', pnpm: '10.28.2' },
137
+ scripts: { dev: './golem dev', build: './golem build', lint: './golem lint', typecheck: 'tsc --noEmit' },
105
138
  ...(process.env.GOLEM_KIT_TARBALL ? {} : { dependencies: { 'golem-kit': framework.version } }),
139
+ // The app's own tsconfig needs a compiler and the React/Node types in the app's tree;
140
+ // golem-kit has them for its own program, and pnpm does not share them with the app.
141
+ devDependencies: Object.fromEntries(['typescript', '@types/node', '@types/react']
142
+ .map((name) => [name, framework.dependencies[name]])),
106
143
  }, null, 2) + '\n');
107
144
  }
108
- writeFileSync(resolve(root, 'golem.config.ts'), "export default { title: 'Golem' }\n");
145
+ writeFileSync(resolve(root, 'golem.config.ts'), "export default { title: 'Golem', brain: true }\n");
146
+ writeFileSync(resolve(root, 'tsconfig.json'), JSON.stringify({
147
+ compilerOptions: {
148
+ target: 'ES2023', module: 'ESNext', moduleResolution: 'Bundler',
149
+ strict: true, noEmit: true, allowImportingTsExtensions: true,
150
+ jsx: 'react-jsx', skipLibCheck: true, types: ['node'],
151
+ },
152
+ include: ['src/**/*', 'golem.config.ts'],
153
+ }, null, 2) + '\n');
154
+ writeFileSync(resolve(root, 'src/globals.d.ts'), `// golem-ui ships its own compiled stylesheet; any class beyond the ones its components use is
155
+ // CSS this app writes and imports itself.
156
+ declare module '*.css'
157
+ `);
158
+ writeFileSync(resolve(root, 'brain/index.md'), `---
159
+ okf_version: "0.2"
160
+ ---
161
+ # Brain
162
+
163
+ This folder is an Open Knowledge Format bundle: one concept per markdown file with \`type\` front matter, this \`index.md\` listing them one \`* [Title](path) - description\` line each, and \`log.md\` recording changes newest first.
164
+ `);
165
+ writeFileSync(resolve(root, 'brain/log.md'), '# Log\n');
109
166
  writeFileSync(resolve(root, 'src/app.tsx'), `export default function App() {
110
167
  return (
111
- <section className="flex h-full min-h-64 items-center justify-center bg-neutral-50 p-6 text-center">
168
+ <section className="flex h-full items-center justify-center bg-neutral-50 p-6 text-center">
112
169
  <div>
113
170
  <h1 className="text-lg font-semibold">Welcome to Golem</h1>
114
171
  <p className="mt-2 text-sm text-neutral-500">Edit src/app.tsx to build your app.</p>
@@ -117,13 +174,45 @@ function initProject(): void {
117
174
  )
118
175
  }
119
176
  `);
120
- writeFileSync(resolve(root, 'docs/domain.md'), '# Golem app\n\nA minimal editable app entrypoint.\n');
177
+ writeFileSync(resolve(root, 'eslint.config.mjs'), `import golem from 'golem-kit/eslint'
178
+
179
+ // Architecture checks for \`./golem lint\`. To adapt or disable them, see
180
+ // node_modules/golem-kit/docs/architecture.md.
181
+ export default golem()
182
+ `);
183
+ writeFileSync(resolve(root, 'docs/domain.md'), `# App DNA
184
+
185
+ What this app is for and the rules its code must honor. Update it in the same change as the code it describes.
186
+
187
+ ## Purpose
188
+
189
+ Who the app helps and the problem it solves.
190
+
191
+ ## Concepts
192
+
193
+ The words people use for the things this app manages, what each means, and how they relate.
194
+
195
+ ## Operations
196
+
197
+ What people and the system do: each operation's inputs, result, and the rules it enforces.
198
+
199
+ ## Decisions
200
+
201
+ Choices already made and why, so later changes keep them or revisit them on purpose.
202
+ `);
203
+ writeFileSync(resolve(root, 'AGENTS.md'), `# Golem app
204
+
205
+ Before changing this app, read \`docs/domain.md\` (this app's DNA) and the installed framework guide \`node_modules/golem-kit/docs/builder.md\`.
206
+ `);
207
+ writeFileSync(resolve(root, 'CLAUDE.md'), '@AGENTS.md\n');
121
208
  const ignorePath = resolve(root, '.gitignore');
122
209
  const ignore = existsSync(ignorePath) ? readFileSync(ignorePath, 'utf8') : '';
123
- if (!ignore.split(/\r?\n/).includes('.golem/')) writeFileSync(ignorePath, `${ignore}${ignore && !ignore.endsWith('\n') ? '\n' : ''}.golem/\n`);
210
+ const additions = ['.golem/', '.env.local'].filter((entry) => !ignore.split(/\r?\n/).includes(entry));
211
+ if (additions.length) writeFileSync(ignorePath, `${ignore}${ignore && !ignore.endsWith('\n') ? '\n' : ''}${additions.join('\n')}\n`);
124
212
  if (!packageExisted) {
125
213
  const packageSpec = process.env.GOLEM_KIT_TARBALL ?? `golem-kit@${framework.version}`;
126
- execFileSync('pnpm', ['add', '--save-exact', packageSpec], { cwd: root, stdio: 'inherit' });
214
+ // App code imports golem-ui directly, so pin the same version golem-kit builds with.
215
+ execFileSync('pnpm', ['add', '--save-exact', packageSpec, `golem-ui@${framework.dependencies['golem-ui']}`], { cwd: root, stdio: 'inherit' });
127
216
  }
128
- writeFileSync(resolve(root, 'golem'), '#!/bin/sh\nset -eu\ncd -- "$(dirname -- "$0")"\nif [ -n "${GOLEM_SOURCE:-}" ]; then\n exec node "$GOLEM_SOURCE/src/cli.ts" "$@"\nfi\nexec node_modules/.bin/golem-kit "$@"\n', { mode: 0o755 });
217
+ writeFileSync(resolve(root, 'golem'), "#!/bin/sh\nset -eu\ncd -- \"$(dirname -- \"$0\")\"\n# An exported GOLEM_SOURCE launches from the checkout, so source mode does not depend on what\n# node_modules/golem-kit happens to hold. Anything else (including a bad path, and GOLEM_SOURCE\n# set in .env.local, which only node reads) goes through the installed entry and its errors.\nentry=node_modules/golem-kit/src/entry.mjs\n[ -f \"${GOLEM_SOURCE:-}/src/entry.mjs\" ] && entry=\"$GOLEM_SOURCE/src/entry.mjs\" || true\nexec node --env-file-if-exists=.env.local \"$entry\" \"$@\"\n", { mode: 0o755 });
129
218
  }
package/src/client.ts ADDED
@@ -0,0 +1,205 @@
1
+ /**
2
+ * golem-kit/client: the browser binding. golem-ui `Records`, `Files` and `Identity` adapters plus
3
+ * `invoke`, all over the app server's HTTP routes — no storage driver or server module reaches the bundle.
4
+ */
5
+ import { fileId, RecordRefusedError, VersionConflictError, type FileRef, type FilesAdapter, type IdentityAdapter, type RecordsAdapter, type User } from 'golem-ui'
6
+
7
+ /** Calls an app operation by name, as the signed-in principal the server resolves. */
8
+ export function invoke<T = unknown>(operation: string, input: unknown = {}): Promise<T> {
9
+ return call<T>(fetch(`/api/app/operations/${encodeURIComponent(operation)}`, {
10
+ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input),
11
+ }))
12
+ }
13
+
14
+ async function call<T>(request: Promise<Response>): Promise<T> {
15
+ const response = await request
16
+ const body = await response.json().catch(() => ({ error: `${response.status} ${response.statusText}` })) as { result?: T; error?: string; name?: string; current?: Record<string, unknown>; fields?: Array<{ field: string; message: string }> }
17
+ if (response.ok) return body.result as T
18
+ if (body.name === 'VersionConflictError') throw new VersionConflictError(body.error ?? 'Changed since it was read', body.current ?? null)
19
+ if (body.name === 'RecordRefusedError') throw new RecordRefusedError(body.error ?? 'Refused', body.fields ?? [])
20
+ throw Object.assign(new Error(body.error ?? `Request failed (${response.status})`), { name: body.name ?? 'Error', status: response.status })
21
+ }
22
+
23
+ const listeners = new Map<string, Set<() => void>>()
24
+ let changes: EventSource | undefined
25
+
26
+ function connect(): void {
27
+ changes?.close()
28
+ changes = Object.assign(new EventSource('/api/app/changes'), {
29
+ onmessage: (event: MessageEvent) => {
30
+ const data = JSON.parse(event.data) as { collection?: string; identity?: true }
31
+ if (data.identity) void reloadIdentity()
32
+ else listeners.get(data.collection ?? '')?.forEach((one) => one())
33
+ },
34
+ // The server refused or ended the stream for good (e.g. signed out elsewhere): re-read identity
35
+ // once. No reconnect here, so a 401 cannot loop; a new identity reconnects through reloadIdentity.
36
+ onerror: () => {
37
+ if (changes?.readyState === EventSource.CLOSED) void me?.then((known) => { if (known.user) void reloadIdentity(false) }, () => {})
38
+ },
39
+ })
40
+ }
41
+
42
+ function watch(collection: string, listener: () => void): () => void {
43
+ const set = listeners.get(collection) ?? new Set()
44
+ listeners.set(collection, set.add(listener))
45
+ if (!changes) connect()
46
+ return () => {
47
+ set.delete(listener)
48
+ if ([...listeners.values()].every((one) => one.size === 0)) { changes?.close(); changes = undefined }
49
+ }
50
+ }
51
+
52
+ export const records: RecordsAdapter = {
53
+ list: (collection, query) => invoke('records.list', { collection, query }),
54
+ get: (collection, id) => invoke('records.get', { collection, id }),
55
+ create: (collection, data) => invoke('records.create', { collection, data }),
56
+ update: (collection, id, patch, options) => invoke('records.update', { collection, id, patch, ...options }),
57
+ remove: async (collection, id) => { await invoke('records.remove', { collection, id }) },
58
+ subscribe: watch,
59
+ }
60
+
61
+ export const files: FilesAdapter = {
62
+ async upload(file, { folder, onProgress }) {
63
+ const ref = await call<FileRef>(fetch(`/api/app/files?${new URLSearchParams({ folder, name: file.name })}`, {
64
+ method: 'PUT', headers: { 'Content-Type': file.type || 'application/octet-stream' }, body: file,
65
+ }))
66
+ onProgress?.(1)
67
+ return ref
68
+ },
69
+ url: async (ref) => `/api/app/files/${encodeURIComponent(fileId(ref))}`,
70
+ remove: async (ref) => { await invoke('files.remove', { id: fileId(ref) }) },
71
+ list: (folder) => invoke('files.list', { folder }),
72
+ caption: (ref, caption) => invoke('files.caption', { id: fileId(ref), caption }),
73
+ subscribe: (_folder, listener) => watch('_files', listener),
74
+ }
75
+
76
+ /** A signed-in person as the server sees them: golem-ui's `User` plus the groups app rules may check. */
77
+ export type Member = User & { email: string; groups: string[] }
78
+ export type AccountsSettings = { guests: boolean; allowSignUp: boolean; roles: Array<{ id: string; label: string; manages: boolean }> }
79
+ /** `accounts` is null when the app has no accounts; then everyone is anonymous and may build. */
80
+ export type Me = { user: Member | null; canBuild: boolean; accounts: AccountsSettings | null }
81
+
82
+ let me: Promise<Me> | undefined
83
+ const identityListeners = new Set<(user: User | null) => void>()
84
+
85
+ /** Who this browser is signed in as, and what the app's account settings are. Cached until identity changes. */
86
+ export function currentSession(): Promise<Me> {
87
+ me ??= call<Me>(fetch('/api/auth/me')).catch((error) => { me = undefined; throw error })
88
+ return me
89
+ }
90
+
91
+ /** Re-reads the session after sign-in, sign-out or a role change; open lists re-read what they may now see. */
92
+ async function reloadIdentity(reconnect = true): Promise<Me> {
93
+ me = undefined
94
+ const next = await currentSession()
95
+ identityListeners.forEach((listener) => listener(next.user))
96
+ if (changes && reconnect) connect()
97
+ listeners.forEach((set) => set.forEach((listener) => listener()))
98
+ return next
99
+ }
100
+
101
+ const auth = <T = null>(route: string, input: unknown = {}) => call<T>(fetch(`/api/auth/${route}`, {
102
+ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input),
103
+ }))
104
+ const signedIn = async () => {
105
+ const { user } = await reloadIdentity()
106
+ if (!user) throw new Error('Signing in did not stick. Allow cookies for this site and try again.')
107
+ return user
108
+ }
109
+ const passwordOnly = () => Promise.reject(new Error('This app signs in with a password.'))
110
+
111
+ /** golem-ui `IdentityAdapter` over the server's local accounts. Sign-in takes the account's email and password. */
112
+ export const identity: IdentityAdapter = {
113
+ currentUser: async () => (await currentSession()).user,
114
+ subscribe(listener) {
115
+ identityListeners.add(listener)
116
+ // Keeps the change stream open, which is where the server says this account changed.
117
+ const stop = watch('', () => {})
118
+ return () => { identityListeners.delete(listener); stop() }
119
+ },
120
+ signIn: async (email, password) => { await auth('sign-in', { email, password }); return signedIn() },
121
+ signUp: async (input) => { await auth('sign-up', input); return signedIn() },
122
+ requestCode: passwordOnly,
123
+ verifyCode: passwordOnly,
124
+ signOut: async () => { await auth('sign-out'); await reloadIdentity() },
125
+ listMembers: () => call<Member[]>(fetch('/api/auth/members')),
126
+ invite: (role) => auth<string>('invites', { role }),
127
+ removeMember: async (userId) => { await auth(`members/${encodeURIComponent(userId)}/remove`); await reloadIdentity() },
128
+ setRole: async (userId, role) => { await auth(`members/${encodeURIComponent(userId)}/role`, { role }); await reloadIdentity() },
129
+ }
130
+
131
+ /** Re-reads who is signed in and tells identity subscribers; for callers that saw a 401 or 403. */
132
+ export async function refreshIdentity(): Promise<void> { await reloadIdentity() }
133
+
134
+ /** Replaces a member's groups. Accounts that manage members only. */
135
+ export async function setGroups(userId: string, groups: string[]): Promise<void> {
136
+ await auth(`members/${encodeURIComponent(userId)}/groups`, { groups })
137
+ await reloadIdentity()
138
+ }
139
+
140
+ /**
141
+ * golem-ui `RecordsAdapter` over the app's knowledge roots: `collection` is the root name, `id` the
142
+ * file path and `body` its markdown. Hand it to `Editor` (versioned saves, merged conflicts) or `RecordList`.
143
+ */
144
+ export const knowledge: RecordsAdapter = {
145
+ list: (root, query) => invoke('knowledge.list', { root, ...(typeof query?.filter?.folder === 'string' ? { folder: query.filter.folder } : {}) }),
146
+ get: async (root, path) => {
147
+ try { return await invoke('knowledge.read', { root, path }) }
148
+ catch (error) { if ((error as Error).name === 'NotFoundError') return null; throw error }
149
+ },
150
+ create: (root, data) => invoke('knowledge.write', { root, path: data.path ?? data.id, body: data.body ?? '', expectedVersion: 0 }),
151
+ update: (root, path, patch, options) => invoke('knowledge.write', { root, path, body: patch.body, expectedVersion: options?.expectedVersion ?? (patch.version as number) }),
152
+ remove: () => Promise.reject(new Error('Knowledge files are removed in the app folder, not from the browser.')),
153
+ subscribe: (_root, listener) => watch('_knowledge', listener),
154
+ }
155
+
156
+ /** A knowledge passage an agent offered to show; `line` and `endLine` are 1-based and inclusive. */
157
+ export type ViewOffer = { id: string; conversation: string; action: 'source.open'; input: { root: string; path: string; line: number; endLine: number } }
158
+ /**
159
+ * `apply` carries the file `version` its lines were counted in and the `text` of those lines: show them
160
+ * once the editor has that version, and look for `text` instead when the editor shows an unsaved draft.
161
+ */
162
+ export type ViewEvent = { type: 'offer'; offer: ViewOffer } | { type: 'apply'; offer: ViewOffer; version: number; text: string } | { type: 'withdrawn'; id: string }
163
+
164
+ /**
165
+ * Opens this tab's view of one conversation. `id` is the view to send with this tab's chat messages,
166
+ * so the agent's offers come here. An offer arrives as `offer` (or is shown by the chat); `answer` is
167
+ * the person's choice, and only an accepted offer comes back, to this tab alone, as `apply`.
168
+ */
169
+ export function openView(conversation: string, listener: (event: ViewEvent) => void): { id: Promise<string>; answer(offer: string, accept: boolean): Promise<void>; close(): void } {
170
+ let source: EventSource | undefined
171
+ let closed = false
172
+ const id = call<{ id: string }>(fetch('/api/app/views', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ conversation }) })).then((view) => view.id)
173
+ void id.then((view) => {
174
+ if (closed) return
175
+ source = new EventSource(`/api/app/views/${view}`)
176
+ source.onmessage = (event) => listener(JSON.parse(event.data) as ViewEvent)
177
+ }, () => {})
178
+ return {
179
+ id,
180
+ answer: async (offer, accept) => {
181
+ await call(fetch(`/api/app/views/${await id}/answer`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ offer, accept }) }))
182
+ },
183
+ close: () => { closed = true; source?.close() },
184
+ }
185
+ }
186
+
187
+ /** A job run as its owner sees it through `jobs.runs`. */
188
+ export type JobRun = {
189
+ id: string; job: string; status: 'running' | 'succeeded' | 'failed' | 'cancelled' | 'interrupted'
190
+ progress: { done?: number; total?: number; message?: string } | null; result?: unknown; error?: string
191
+ cancelRequested: boolean; resolution?: 'retried' | 'dismissed'; scheduleId: string | null; key: string; startedAt: string; finishedAt?: string
192
+ }
193
+ export type JobSchedule = { id: string; job: string; every?: number; cron?: string; timezone?: string; nextRunAt: string; lastRunAt?: string; lastSkippedAt?: string; error?: string | null }
194
+
195
+ /** The app's server-side jobs. Runs keep going when this page closes; `subscribe` fires whenever one of them changes. */
196
+ export const jobs = {
197
+ list: () => invoke<{ jobs: Array<{ name: string; description: string }>; schedules: JobSchedule[] }>('jobs.list'),
198
+ start: (job: string, input?: unknown) => invoke<JobRun>('jobs.start', { job, input }),
199
+ schedule: (job: string, when: { every: number } | { cron: string; timezone: string }, input?: unknown) => invoke<JobSchedule>('jobs.schedule', { job, input, ...when }),
200
+ unschedule: async (id: string) => { await invoke('jobs.unschedule', { id }) },
201
+ runs: (query: { job?: string; scheduleId?: string; limit?: number } = {}) => invoke<JobRun[]>('jobs.runs', query),
202
+ cancel: (id: string) => invoke<JobRun>('jobs.cancel', { id }),
203
+ resolve: (id: string, action: 'retry' | 'dismiss') => invoke<JobRun>('jobs.resolve', { id, action }),
204
+ subscribe: (listener: () => void) => watch('_jobs', listener),
205
+ }
package/src/config.ts CHANGED
@@ -1,10 +1,39 @@
1
1
  import { resolve } from 'node:path'
2
2
  import { pathToFileURL } from 'node:url'
3
+ import { toolNameProblem } from './runtime/tool-names.ts'
3
4
 
4
- export type AppConfig = { host: string; port: number }
5
+ /** Browser-visible settings: never put secrets in golem.config.ts. */
6
+ export type AppConfig = { host: string; port: number; storage: 'jsonl' | 'sqlite'; origin?: string; accounts?: AccountsConfig; agents?: AgentsConfig; brain?: boolean; chat?: ChatConfig; model?: ModelConfig }
7
+ /** Which local agent CLI answers `context.model`; absent, Claude Code as before. `name` is that runtime's model id. */
8
+ export type ModelConfig = { runtime: 'claude' | 'codex'; name?: string }
9
+ /**
10
+ * Normal-mode chat, the app's rule: `false` (or absent) means no chat column outside builder mode.
11
+ * `anthropic` is the API agent (same shape as `agents.ordinary`, which it fills); `tmux` is a
12
+ * terminal agent in the `chat` window of the app's tmux session, briefed from `docs/chat.md`.
13
+ * `roles` restricts chat to those account roles; absent, anyone signed in may chat.
14
+ */
15
+ export type ChatConfig = ({ provider: 'anthropic' } | { provider: 'tmux'; agent?: 'codex' | 'claude' }) & { roles?: string[] }
16
+ /** `brain: true` serves the app's `brain/` folder read-only and mounts the Brain reader beside the app. */
5
17
 
6
- export async function loadAppConfig(): Promise<AppConfig> {
7
- const path = resolve(process.cwd(), 'golem.config.ts')
18
+ /**
19
+ * `builder` is the agent build mode starts with. `ordinary` turns on everyday chat: an API agent
20
+ * whose only tools are the listed app operations, run as the person chatting.
21
+ */
22
+ export type AgentsConfig = { builder?: 'codex' | 'claude'; ordinary?: OrdinaryAgentConfig }
23
+ export type OrdinaryAgentConfig = { backend: 'anthropic'; model: string; operations: string[]; collections?: string[]; roots?: string[]; instructions?: string }
24
+
25
+ /** golem-ui's Auth role shape: `manages` roles run accounts and may build; `builder` may build. */
26
+ export type AccountRole = { id: string; label: string; manages: boolean }
27
+ export type AccountsConfig = { guests: boolean; allowSignUp: boolean; roles: AccountRole[] }
28
+
29
+ const defaultRoles: AccountRole[] = [
30
+ { id: 'member', label: 'Member', manages: false },
31
+ { id: 'builder', label: 'Builder', manages: false },
32
+ { id: 'admin', label: 'Admin', manages: true },
33
+ ]
34
+
35
+ export async function loadAppConfig(root = process.cwd()): Promise<AppConfig> {
36
+ const path = resolve(root, 'golem.config.ts')
8
37
  let value: unknown
9
38
  try {
10
39
  value = (await import(pathToFileURL(path).href)).default
@@ -14,7 +43,8 @@ export async function loadAppConfig(): Promise<AppConfig> {
14
43
  if (!value || typeof value !== 'object' || Array.isArray(value)) {
15
44
  throw new Error('golem.config.ts must default-export an object')
16
45
  }
17
- const configured = value as { host?: unknown; port?: unknown }
46
+ const configured = value as { host?: unknown; port?: unknown; storage?: unknown; origin?: unknown; accounts?: unknown; agents?: unknown; brain?: unknown; chat?: unknown; model?: unknown }
47
+ if (configured.brain !== undefined && typeof configured.brain !== 'boolean') throw new Error('golem.config.ts brain must be a boolean')
18
48
  const host: unknown = configured.host === undefined ? '127.0.0.1' : configured.host
19
49
  const port: unknown = configured.port === undefined ? 3000 : configured.port
20
50
  if (typeof host !== 'string' || !host.trim()) {
@@ -23,7 +53,111 @@ export async function loadAppConfig(): Promise<AppConfig> {
23
53
  if (typeof port !== 'number' || !Number.isInteger(port) || port < 1 || port > 65_535) {
24
54
  throw new Error('golem.config.ts port must be an integer from 1 to 65535')
25
55
  }
26
- return { host, port }
56
+ const storage = configured.storage ?? 'jsonl'
57
+ if (storage !== 'jsonl' && storage !== 'sqlite') {
58
+ throw new Error("golem.config.ts storage must be 'jsonl' or 'sqlite'")
59
+ }
60
+ const origin = configured.origin
61
+ if (origin !== undefined && (typeof origin !== 'string' || !/^https?:$/.test(safeUrl(origin)?.protocol ?? '') || safeUrl(origin)?.origin !== origin)) {
62
+ throw new Error("golem.config.ts origin must be an exact origin like 'https://notes.example.com'")
63
+ }
64
+ const config: AppConfig = { host, port, storage, ...(origin === undefined ? {} : { origin }), ...(configured.accounts === undefined ? {} : { accounts: accounts(configured.accounts) }), ...(configured.agents === undefined ? {} : { agents: agents(configured.agents) }), ...(configured.brain ? { brain: true } : {}), ...(configured.model === undefined ? {} : { model: modelConfig(configured.model) }) }
65
+ if (configured.chat !== undefined && configured.chat !== false) {
66
+ if (!configured.chat || typeof configured.chat !== 'object' || Array.isArray(configured.chat)) throw new Error('golem.config.ts chat must be false or { provider, ... }')
67
+ const { provider, roles, ...rest } = configured.chat as Record<string, unknown>
68
+ const chatRoles = roles === undefined ? {} : { roles: chatRolesOf(roles, config.accounts) }
69
+ if (provider === 'anthropic') {
70
+ config.agents = { ...config.agents, ordinary: ordinaryAgent({ backend: 'anthropic', ...rest }) }
71
+ config.chat = { provider, ...chatRoles }
72
+ } else if (provider === 'tmux') {
73
+ const { agent, ...unknown } = rest
74
+ if (Object.keys(unknown).length) throw new Error(`golem.config.ts chat has unknown fields: ${Object.keys(unknown).join(', ')}`)
75
+ if (agent !== undefined && agent !== 'codex' && agent !== 'claude') throw new Error("golem.config.ts chat.agent must be 'codex' or 'claude'")
76
+ config.chat = { provider, ...(agent ? { agent } : {}), ...chatRoles }
77
+ } else throw new Error("golem.config.ts chat.provider must be 'anthropic' or 'tmux'")
78
+ } else if (config.agents?.ordinary) config.chat = { provider: 'anthropic' }
79
+ return config
80
+ }
81
+
82
+ function modelConfig(value: unknown): ModelConfig {
83
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('golem.config.ts model must be { runtime, name? }')
84
+ const { runtime, name, ...unknown } = value as Record<string, unknown>
85
+ if (Object.keys(unknown).length) throw new Error(`golem.config.ts model has unknown fields: ${Object.keys(unknown).join(', ')}`)
86
+ if (runtime !== 'claude' && runtime !== 'codex') throw new Error("golem.config.ts model.runtime must be 'claude' or 'codex'")
87
+ if (name !== undefined && (typeof name !== 'string' || !name)) throw new Error('golem.config.ts model.name must be a nonempty string')
88
+ return { runtime, ...(name ? { name } : {}) }
89
+ }
90
+
91
+ function accounts(value: unknown): AccountsConfig {
92
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('golem.config.ts accounts must be an object')
93
+ const { guests = false, allowSignUp = false, roles = defaultRoles, ...unknown } = value as Record<string, unknown>
94
+ if (Object.keys(unknown).length) throw new Error(`golem.config.ts accounts has unknown fields: ${Object.keys(unknown).join(', ')}`)
95
+ if (typeof guests !== 'boolean' || typeof allowSignUp !== 'boolean') throw new Error('golem.config.ts accounts guests and allowSignUp must be booleans')
96
+ if (!Array.isArray(roles) || !roles.length) throw new Error('golem.config.ts accounts roles must be a nonempty list')
97
+ const parsed = roles.map((role: { id?: unknown; label?: unknown; manages?: unknown }) => {
98
+ if (typeof role?.id !== 'string' || !/^[A-Za-z0-9_-]{1,64}$/.test(role.id) || typeof role.label !== 'string' || (role.manages !== undefined && typeof role.manages !== 'boolean')) {
99
+ throw new Error('golem.config.ts accounts roles need { id, label, manages? }')
100
+ }
101
+ return { id: role.id, label: role.label, manages: role.manages === true }
102
+ })
103
+ if (new Set(parsed.map((role) => role.id)).size !== parsed.length) throw new Error('golem.config.ts accounts role ids must be unique')
104
+ if (!parsed.some((role) => role.manages)) throw new Error('golem.config.ts accounts roles need one role with manages: true')
105
+ if (allowSignUp && !parsed.some(isPlain)) throw new Error("golem.config.ts accounts allowSignUp needs a role that neither manages nor is 'builder'")
106
+ return { guests, allowSignUp, roles: parsed }
107
+ }
108
+
109
+ /** A typo here would lock everyone out of chat, so every named role must be one the app declares. */
110
+ function chatRolesOf(value: unknown, accounts: AccountsConfig | undefined): string[] {
111
+ if (!Array.isArray(value) || !value.length || !value.every((role) => typeof role === 'string' && role)) throw new Error('golem.config.ts chat.roles must be a nonempty list of role ids')
112
+ if (!accounts) throw new Error('golem.config.ts chat.roles needs accounts: without them nobody has a role')
113
+ const unknown = (value as string[]).filter((role) => !accounts.roles.some((one) => one.id === role))
114
+ if (unknown.length) throw new Error(`golem.config.ts chat.roles names roles the app does not declare: ${unknown.join(', ')}`)
115
+ return value as string[]
116
+ }
117
+
118
+ function agents(value: unknown): AgentsConfig {
119
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('golem.config.ts agents must be an object')
120
+ const { builder, ordinary, ...unknown } = value as Record<string, unknown>
121
+ if (Object.keys(unknown).length) throw new Error(`golem.config.ts agents has unknown fields: ${Object.keys(unknown).join(', ')}`)
122
+ if (builder !== undefined && builder !== 'codex' && builder !== 'claude') throw new Error("golem.config.ts agents.builder must be 'codex' or 'claude'")
123
+ return { ...(builder ? { builder } : {}), ...(ordinary === undefined ? {} : { ordinary: ordinaryAgent(ordinary) }) }
124
+ }
125
+
126
+ // Operations whose input or output is raw bytes, which a chat tool cannot carry.
127
+ const byteOperations = ['files.upload', 'files.read']
128
+
129
+ function ordinaryAgent(value: unknown): OrdinaryAgentConfig {
130
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('golem.config.ts agents.ordinary must be an object')
131
+ const { backend, model = 'claude-opus-5', operations, collections, roots, instructions, ...unknown } = value as Record<string, unknown>
132
+ if (Object.keys(unknown).length) throw new Error(`golem.config.ts agents.ordinary has unknown fields: ${Object.keys(unknown).join(', ')}`)
133
+ if (backend === 'codex' || backend === 'claude') {
134
+ throw new Error(`golem.config.ts agents.ordinary.backend '${backend}' is not supported: a terminal agent runs with this computer account's file access, which Golem cannot limit to the listed operations. Use 'anthropic'.`)
135
+ }
136
+ if (backend !== 'anthropic') throw new Error("golem.config.ts agents.ordinary.backend must be 'anthropic'")
137
+ if (typeof model !== 'string' || !model) throw new Error('golem.config.ts agents.ordinary.model must be a nonempty string')
138
+ const names = (list: unknown, field: string) => {
139
+ if (!Array.isArray(list) || !list.every((item) => typeof item === 'string' && item)) throw new Error(`golem.config.ts agents.ordinary.${field} must be a list of names`)
140
+ return list as string[]
141
+ }
142
+ const allowed = names(operations, 'operations')
143
+ const refused = allowed.filter((name) => byteOperations.includes(name))
144
+ if (refused.length) throw new Error(`golem.config.ts agents.ordinary.operations cannot include ${refused.join(', ')}: chat tools carry no file bytes`)
145
+ const problem = toolNameProblem(allowed)
146
+ if (problem) throw new Error(`golem.config.ts agents.ordinary.operations: ${problem}`)
147
+ if (instructions !== undefined && typeof instructions !== 'string') throw new Error('golem.config.ts agents.ordinary.instructions must be a string')
148
+ return {
149
+ backend, model, operations: allowed,
150
+ ...(collections === undefined ? {} : { collections: names(collections, 'collections') }),
151
+ ...(roots === undefined ? {} : { roots: names(roots, 'roots') }),
152
+ ...(instructions === undefined ? {} : { instructions }),
153
+ }
154
+ }
155
+
156
+ /** Neither manages accounts nor builds: what an open sign-up may receive. */
157
+ export const isPlain = (role: AccountRole) => !role.manages && role.id !== 'builder'
158
+
159
+ function safeUrl(value: string): URL | undefined {
160
+ try { return new URL(value) } catch { return undefined }
27
161
  }
28
162
 
29
163
  function message(error: unknown): string {