golem-kit 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/README.md +11 -6
  3. package/docs/agents.md +64 -0
  4. package/docs/app-backend.md +259 -0
  5. package/docs/architecture.md +93 -0
  6. package/docs/builder.md +15 -0
  7. package/docs/knowledge.md +35 -0
  8. package/docs/local-cli.md +31 -15
  9. package/docs/source-development.md +31 -0
  10. package/index.html +9 -0
  11. package/package.json +24 -5
  12. package/src/backend/accounts.ts +287 -0
  13. package/src/backend/app.ts +269 -0
  14. package/src/backend/files.ts +68 -0
  15. package/src/backend/http.ts +276 -0
  16. package/src/backend/index.ts +10 -0
  17. package/src/backend/jobs.ts +302 -0
  18. package/src/backend/jsonl.ts +87 -0
  19. package/src/backend/knowledge.ts +264 -0
  20. package/src/backend/model.ts +129 -0
  21. package/src/backend/rules.ts +53 -0
  22. package/src/backend/sqlite.ts +73 -0
  23. package/src/backend/views.ts +216 -0
  24. package/src/brain.ts +94 -0
  25. package/src/browser/adapters.ts +229 -53
  26. package/src/browser/ansi.ts +104 -0
  27. package/src/browser/app.d.ts +5 -2
  28. package/src/browser/app.tsx +167 -39
  29. package/src/browser/groups.tsx +29 -0
  30. package/src/browser/main.tsx +1 -0
  31. package/src/browser/panekeys.ts +34 -0
  32. package/src/browser/sources.tsx +113 -0
  33. package/src/browser/styles.css +36 -0
  34. package/src/browser/terminal.tsx +89 -0
  35. package/src/browser-build.ts +20 -7
  36. package/src/chat.ts +74 -0
  37. package/src/cli.ts +91 -17
  38. package/src/client.ts +205 -0
  39. package/src/config.ts +169 -0
  40. package/src/dev-server.ts +339 -38
  41. package/src/entry.mjs +19 -0
  42. package/src/eslint.mjs +55 -0
  43. package/src/operations.ts +169 -0
  44. package/src/runtime/assistant.ts +141 -0
  45. package/src/runtime/discovery.ts +13 -7
  46. package/src/runtime/harness/agent-status.js +388 -0
  47. package/src/runtime/harness/claude-tmux.js +573 -0
  48. package/src/runtime/harness/codex-notify.js +95 -0
  49. package/src/runtime/harness/codex-tmux.js +292 -0
  50. package/src/runtime/harness/fake.js +430 -0
  51. package/src/runtime/harness/package.json +1 -0
  52. package/src/runtime/harness/port.js +208 -0
  53. package/src/runtime/harness/tmux-session.js +556 -0
  54. package/src/runtime/harness/tmux.js +285 -0
  55. package/src/runtime/harness/turnend-hook.js +105 -0
  56. package/src/runtime/session.ts +171 -34
  57. package/src/runtime/tmux.ts +173 -0
  58. package/src/runtime/tool-names.ts +19 -0
  59. package/src/source-mode.ts +56 -0
  60. package/vite.config.ts +2 -4
  61. package/src/runtime/codex.ts +0 -119
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,6 +1,8 @@
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';
5
+ import { loadAppConfig, serverUrl } from './config.ts';
4
6
  import { execFileSync } from 'node:child_process';
5
7
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
6
8
  import { dirname, resolve } from 'node:path';
@@ -12,18 +14,21 @@ Usage: ./golem <command>
12
14
 
13
15
  help Show every command (also the default).
14
16
  init Create a minimal app in the current directory.
15
- dev Serve the browser shell at http://127.0.0.1:3000/.
16
- Stop with Ctrl+C. Uses the local Codex runtime from the browser.
17
+ dev Serve the browser shell using golem.config.ts (127.0.0.1:3000 by default).
18
+ Restart after changing settings. Uses local Claude Code or Codex from the browser.
17
19
  build Build the browser shell into dist/.
20
+ lint Check the app's architecture rules from eslint.config.mjs.
18
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).
19
24
 
20
- Requires Node.js >=22.18.0. Commands accept no additional arguments.
25
+ Requires Node.js >=22.18.0. Only \`say\` takes arguments.
21
26
  Exit codes: 0 success/clean shutdown, 1 unavailable or failed, 2 invalid usage.
22
27
  `;
23
28
 
24
29
  const [command = 'help', ...args] = process.argv.slice(2);
25
30
 
26
- 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)) {
27
32
  console.error('Invalid command or arguments. Run ./golem help.');
28
33
  process.exitCode = 2;
29
34
  } else {
@@ -42,10 +47,22 @@ if (args.length || !['help', 'init', 'dev', 'build', 'doctor'].includes(command)
42
47
  break;
43
48
  case 'doctor':
44
49
  console.log(`Golem shell readiness (Node ${process.version})
45
- Ready: local CLI, HTTP shell, golem-ui browser build and Codex session seam.
46
- Claude integration: not yet connected.
47
- Not implemented: Claude integration.
48
- No network exposure is enabled; the dev server binds to loopback.`);
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.
53
+ The dev server defaults to 127.0.0.1:3000 and uses optional host/port from golem.config.ts.`);
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
+ }
49
66
  break;
50
67
  case 'build':
51
68
  try {
@@ -55,9 +72,25 @@ No network exposure is enabled; the dev server binds to loopback.`);
55
72
  process.exitCode = 1;
56
73
  }
57
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;
58
90
  case 'dev':
59
91
  try {
60
- const server = await startDevServer();
92
+ const config = await loadAppConfig();
93
+ const server = await startDevServer(config.port, undefined, undefined, config.host);
61
94
  const stop = () => {
62
95
  server.close((error) => {
63
96
  process.removeListener('SIGINT', stop);
@@ -73,7 +106,7 @@ No network exposure is enabled; the dev server binds to loopback.`);
73
106
  };
74
107
  process.once('SIGINT', stop);
75
108
  process.once('SIGTERM', stop);
76
- console.log('Golem shell: http://127.0.0.1:3000/ (Ctrl+C to stop)');
109
+ console.log(`Golem shell: ${serverUrl(config.host, config.port)} (Ctrl+C to stop)`);
77
110
  } catch (error) {
78
111
  console.error(`Cannot start Golem dev server: ${error instanceof Error ? error.message : String(error)}`);
79
112
  process.exitCode = 1;
@@ -83,7 +116,7 @@ No network exposure is enabled; the dev server binds to loopback.`);
83
116
 
84
117
  function initProject(): void {
85
118
  const root = resolve(process.cwd());
86
- const files = ['golem.config.ts', 'src/app.tsx', 'docs/domain.md', 'golem'];
119
+ const files = ['golem.config.ts', 'eslint.config.mjs', 'src/app.tsx', 'docs/domain.md', 'AGENTS.md', 'CLAUDE.md', 'golem', 'brain/index.md', 'brain/log.md'];
87
120
  const existing = files.filter((file) => existsSync(resolve(root, file)));
88
121
  if (existing.length) throw new Error(`refusing to overwrite existing files: ${existing.join(', ')}`);
89
122
  const packagePath = resolve(root, 'package.json');
@@ -93,9 +126,10 @@ function initProject(): void {
93
126
  if (!current.dependencies?.['golem-kit']) throw new Error('refusing to overwrite existing package.json');
94
127
  }
95
128
  const frameworkRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
96
- 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> };
97
130
  mkdirSync(resolve(root, 'src'), { recursive: true });
98
131
  mkdirSync(resolve(root, 'docs'), { recursive: true });
132
+ mkdirSync(resolve(root, 'brain'), { recursive: true });
99
133
  if (!existsSync(packagePath)) {
100
134
  writeFileSync(packagePath, JSON.stringify({
101
135
  name: 'golem-app', private: true, type: 'module', packageManager: 'pnpm@10.28.2',
@@ -103,7 +137,15 @@ function initProject(): void {
103
137
  ...(process.env.GOLEM_KIT_TARBALL ? {} : { dependencies: { 'golem-kit': framework.version } }),
104
138
  }, null, 2) + '\n');
105
139
  }
106
- writeFileSync(resolve(root, 'golem.config.ts'), "export default { title: 'Golem' }\n");
140
+ writeFileSync(resolve(root, 'golem.config.ts'), "export default { title: 'Golem', brain: true }\n");
141
+ writeFileSync(resolve(root, 'brain/index.md'), `---
142
+ okf_version: "0.2"
143
+ ---
144
+ # Brain
145
+
146
+ 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.
147
+ `);
148
+ writeFileSync(resolve(root, 'brain/log.md'), '# Log\n');
107
149
  writeFileSync(resolve(root, 'src/app.tsx'), `export default function App() {
108
150
  return (
109
151
  <section className="flex h-full min-h-64 items-center justify-center bg-neutral-50 p-6 text-center">
@@ -115,13 +157,45 @@ function initProject(): void {
115
157
  )
116
158
  }
117
159
  `);
118
- writeFileSync(resolve(root, 'docs/domain.md'), '# Golem app\n\nA minimal editable app entrypoint.\n');
160
+ writeFileSync(resolve(root, 'eslint.config.mjs'), `import golem from 'golem-kit/eslint'
161
+
162
+ // Architecture checks for \`./golem lint\`. To adapt or disable them, see
163
+ // node_modules/golem-kit/docs/architecture.md.
164
+ export default golem()
165
+ `);
166
+ writeFileSync(resolve(root, 'docs/domain.md'), `# App DNA
167
+
168
+ What this app is for and the rules its code must honor. Update it in the same change as the code it describes.
169
+
170
+ ## Purpose
171
+
172
+ Who the app helps and the problem it solves.
173
+
174
+ ## Concepts
175
+
176
+ The words people use for the things this app manages, what each means, and how they relate.
177
+
178
+ ## Operations
179
+
180
+ What people and the system do: each operation's inputs, result, and the rules it enforces.
181
+
182
+ ## Decisions
183
+
184
+ Choices already made and why, so later changes keep them or revisit them on purpose.
185
+ `);
186
+ writeFileSync(resolve(root, 'AGENTS.md'), `# Golem app
187
+
188
+ Before changing this app, read \`docs/domain.md\` (this app's DNA) and the installed framework guide \`node_modules/golem-kit/docs/builder.md\`.
189
+ `);
190
+ writeFileSync(resolve(root, 'CLAUDE.md'), '@AGENTS.md\n');
119
191
  const ignorePath = resolve(root, '.gitignore');
120
192
  const ignore = existsSync(ignorePath) ? readFileSync(ignorePath, 'utf8') : '';
121
- if (!ignore.split(/\r?\n/).includes('.golem/')) writeFileSync(ignorePath, `${ignore}${ignore && !ignore.endsWith('\n') ? '\n' : ''}.golem/\n`);
193
+ const additions = ['.golem/', '.env.local'].filter((entry) => !ignore.split(/\r?\n/).includes(entry));
194
+ if (additions.length) writeFileSync(ignorePath, `${ignore}${ignore && !ignore.endsWith('\n') ? '\n' : ''}${additions.join('\n')}\n`);
122
195
  if (!packageExisted) {
123
196
  const packageSpec = process.env.GOLEM_KIT_TARBALL ?? `golem-kit@${framework.version}`;
124
- execFileSync('pnpm', ['add', '--save-exact', packageSpec], { cwd: root, stdio: 'inherit' });
197
+ // App code imports golem-ui directly, so pin the same version golem-kit builds with.
198
+ execFileSync('pnpm', ['add', '--save-exact', packageSpec, `golem-ui@${framework.dependencies['golem-ui']}`], { cwd: root, stdio: 'inherit' });
125
199
  }
126
- 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 });
200
+ 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 });
127
201
  }
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 ADDED
@@ -0,0 +1,169 @@
1
+ import { resolve } from 'node:path'
2
+ import { pathToFileURL } from 'node:url'
3
+ import { toolNameProblem } from './runtime/tool-names.ts'
4
+
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. */
17
+
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')
37
+ let value: unknown
38
+ try {
39
+ value = (await import(pathToFileURL(path).href)).default
40
+ } catch (error) {
41
+ throw new Error(`Cannot load golem.config.ts: ${message(error)}`)
42
+ }
43
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
44
+ throw new Error('golem.config.ts must default-export an object')
45
+ }
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')
48
+ const host: unknown = configured.host === undefined ? '127.0.0.1' : configured.host
49
+ const port: unknown = configured.port === undefined ? 3000 : configured.port
50
+ if (typeof host !== 'string' || !host.trim()) {
51
+ throw new Error('golem.config.ts host must be a nonempty string')
52
+ }
53
+ if (typeof port !== 'number' || !Number.isInteger(port) || port < 1 || port > 65_535) {
54
+ throw new Error('golem.config.ts port must be an integer from 1 to 65535')
55
+ }
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 }
161
+ }
162
+
163
+ function message(error: unknown): string {
164
+ return error instanceof Error ? error.message : String(error)
165
+ }
166
+
167
+ export function serverUrl(host: string, port: number): string {
168
+ return `http://${host.includes(':') ? `[${host}]` : host}:${port}/`
169
+ }