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
@@ -0,0 +1,129 @@
1
+ import { spawn } from 'node:child_process'
2
+ import { access, constants } from 'node:fs/promises'
3
+ import { tmpdir } from 'node:os'
4
+ import { basename, isAbsolute } from 'node:path'
5
+ import type { ModelConfig } from '../config.ts'
6
+ import { InvalidError, ModelUnavailableError, z, type Model } from '../operations.ts'
7
+
8
+ /**
9
+ * `context.model`, over a local agent CLI: Claude Code (the default, haiku) or Codex, whichever
10
+ * `golem.config.ts` names in `model`. An app never learns which one answered — the runtime is the
11
+ * app owner's choice, not the operation's.
12
+ *
13
+ * The prompt goes in on stdin, never in argv where `ps` would show it, and the call runs in a
14
+ * temporary directory so nothing in the app's folder becomes part of it.
15
+ */
16
+ const timeoutMs = 180_000
17
+
18
+ const maxImages = 10
19
+
20
+ type Runtime = {
21
+ executable: string
22
+ args(images: string[]): string[]
23
+ /** Whether the CLI can be handed image files at all. */
24
+ images: 'flag' | 'none'
25
+ /** The answer text out of the CLI's own stdout format, or a reason there is none. */
26
+ answer(out: string): { text: string } | { error: string }
27
+ }
28
+
29
+ const runtimes: Record<ModelConfig['runtime'], (name?: string) => Runtime> = {
30
+ claude: (name = 'haiku') => ({
31
+ executable: 'claude',
32
+ args: () => ['-p', '--output-format', 'json', '--model', name, '--allowed-tools', '', '--strict-mcp-config'],
33
+ images: 'none',
34
+ answer(out) {
35
+ const { is_error: failed, result } = JSON.parse(out) as { is_error?: boolean; result?: unknown }
36
+ if (failed || typeof result !== 'string') return { error: typeof result === 'string' ? result : 'The model runtime reported an error.' }
37
+ return { text: result }
38
+ },
39
+ }),
40
+ // `--ephemeral` keeps the call out of ~/.codex/sessions; `-` reads the prompt from stdin.
41
+ codex: (name) => ({
42
+ executable: 'codex',
43
+ // `-i <file>` attaches an image to the initial prompt; the CLI reads it, nothing is copied.
44
+ args: (images) => ['exec', ...(name ? ['-m', name] : []), ...images.flatMap((path) => ['-i', path]), '--skip-git-repo-check', '--ephemeral', '-s', 'read-only', '--json', '-'],
45
+ images: 'flag',
46
+ answer(out) {
47
+ // JSONL events; the answer is the last agent message, a failed turn carries its reason.
48
+ let text: string | undefined
49
+ let error: string | undefined
50
+ for (const line of out.split('\n')) {
51
+ if (!line.trim()) continue
52
+ const event = JSON.parse(line) as { type: string; item?: { type: string; text?: string }; error?: { message?: string } }
53
+ if (event.type === 'item.completed' && event.item?.type === 'agent_message' && typeof event.item.text === 'string') text = event.item.text
54
+ if (event.type === 'turn.failed') error = event.error?.message ?? 'The model runtime reported an error.'
55
+ }
56
+ return error ? { error } : text === undefined ? { error: 'The model gave no answer.' } : { text }
57
+ },
58
+ }),
59
+ }
60
+
61
+ export function openModel(config: ModelConfig = { runtime: 'claude' }): Model {
62
+ const runtime = runtimes[config.runtime](config.name)
63
+ return {
64
+ async extract({ schema, text, instructions, images = [] }) {
65
+ // Input problems first, runtime limits second, spawn last.
66
+ if (images.length > maxImages) throw new InvalidError(`At most ${maxImages} images per call.`)
67
+ for (const path of images) {
68
+ if (!isAbsolute(path)) throw new InvalidError(`Image not readable: ${basename(path)}`)
69
+ try {
70
+ await access(path, constants.R_OK)
71
+ } catch {
72
+ throw new InvalidError(`Image not readable: ${basename(path)}`)
73
+ }
74
+ }
75
+ if (images.length && runtime.images === 'none') throw new ModelUnavailableError('This model runtime reads no images.')
76
+ const prompt = [
77
+ instructions ?? 'Fill the schema from what the text actually says. Leave a field empty rather than guessing.',
78
+ 'Answer with one JSON value this JSON Schema accepts, and nothing else:',
79
+ JSON.stringify(z.toJSONSchema(schema, { unrepresentable: 'any' })),
80
+ `Text:\n${text}`,
81
+ ...(images.length ? [`Images: ${images.length} attached, in the order given.`] : []),
82
+ ].join('\n\n')
83
+ const answer = await ask(runtime, prompt, images)
84
+ let value: unknown
85
+ try {
86
+ value = JSON.parse(unfence(answer))
87
+ } catch {
88
+ throw new ModelUnavailableError('The model did not answer with JSON.')
89
+ }
90
+ const parsed = schema.safeParse(value)
91
+ if (!parsed.success) throw new ModelUnavailableError(`The model's answer does not fit the schema: ${z.prettifyError(parsed.error)}`)
92
+ return parsed.data
93
+ },
94
+ }
95
+ }
96
+
97
+ /** The answer text, or `ModelUnavailableError` for every way the runtime can fail to give one. */
98
+ function ask({ executable, args, answer }: Runtime, prompt: string, images: string[]): Promise<string> {
99
+ return new Promise((resolve, reject) => {
100
+ const child = spawn(executable, args(images), { cwd: tmpdir(), stdio: ['pipe', 'pipe', 'pipe'] })
101
+ let out = ''
102
+ let error = ''
103
+ const timer = setTimeout(() => { timedOut = true; child.kill('SIGKILL') }, timeoutMs)
104
+ let timedOut = false
105
+ child.stdout.on('data', (chunk) => { out += chunk })
106
+ child.stderr.on('data', (chunk) => { error += chunk })
107
+ child.once('error', (cause: NodeJS.ErrnoException) => {
108
+ clearTimeout(timer)
109
+ reject(new ModelUnavailableError(cause.code === 'ENOENT' ? `No model runtime: ${executable} is not installed.` : `The model runtime failed to start: ${cause.message}`))
110
+ })
111
+ child.once('close', (code) => {
112
+ clearTimeout(timer)
113
+ if (timedOut) return reject(new ModelUnavailableError('The model did not answer in time.'))
114
+ if (code !== 0) return reject(new ModelUnavailableError(`The model runtime exited with code ${code}${error.trim() ? `: ${error.trim().slice(0, 200)}` : ''}`))
115
+ let result: ReturnType<Runtime['answer']>
116
+ try {
117
+ result = answer(out)
118
+ } catch {
119
+ return reject(new ModelUnavailableError('The model runtime did not answer in its own format.'))
120
+ }
121
+ if ('error' in result) return reject(new ModelUnavailableError(result.error.slice(0, 200)))
122
+ resolve(result.text)
123
+ })
124
+ child.stdin.end(prompt)
125
+ })
126
+ }
127
+
128
+ /** Models like to wrap JSON in a ``` fence whatever the instruction says. */
129
+ const unfence = (text: string) => text.trim().replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/, '')
@@ -0,0 +1,53 @@
1
+ import { InvalidError, NotFoundError, VersionConflictError, validField, type RecordPage, type RecordQuery, type Row } from '../operations.ts'
2
+
3
+ /** Shared update rule: the version check and the store-owned fields. */
4
+ export function nextVersion(collection: string, id: string, current: Row | undefined, patch: Record<string, unknown>, options?: { expectedVersion: number; versionField?: string }): Row {
5
+ if (!current) throw new NotFoundError(`No ${collection} record ${id}`)
6
+ if (options && current[options.versionField ?? 'version'] !== options.expectedVersion) {
7
+ throw new VersionConflictError(`This ${collection} record changed since it was read`, current)
8
+ }
9
+ const { id: _id, version: _version, createdAt: _createdAt, updatedAt: _updatedAt, ...changes } = patch
10
+ return { ...current, ...json(changes), id: current.id, version: current.version + 1, createdAt: current.createdAt, updatedAt: new Date().toISOString() }
11
+ }
12
+
13
+ /** The query semantics both stores share: equality filters, case-insensitive search, stable sort, offset cursor. */
14
+ export function page(rows: Row[], query: RecordQuery = {}): RecordPage<Row> {
15
+ const { offset, limit } = bounds(query)
16
+ let matched = rows
17
+ for (const [key, value] of Object.entries(query.filter ?? {})) {
18
+ validField(key)
19
+ const allowed = Array.isArray(value) ? value : [value]
20
+ matched = matched.filter((row) => allowed.some((one) => one === null ? row[key] == null : row[key] === one))
21
+ }
22
+ if (query.search?.text) {
23
+ const text = query.search.text.toLowerCase()
24
+ const fields = query.search.fields.map(validField)
25
+ matched = matched.filter((row) => fields.some((key) => row[key] != null && String(row[key]).toLowerCase().includes(text)))
26
+ }
27
+ if (query.sort) {
28
+ const { field, direction } = query.sort
29
+ validField(field)
30
+ const sign = direction === 'desc' ? -1 : 1
31
+ matched = matched.toSorted((left, right) => sign * compare(left[field], right[field]))
32
+ }
33
+ const slice = matched.slice(offset, offset + limit)
34
+ return { rows: slice, nextCursor: offset + limit < matched.length ? String(offset + limit) : null }
35
+ }
36
+
37
+ export function bounds(query: RecordQuery = {}): { offset: number; limit: number } {
38
+ const offset = query.cursor ? Number(query.cursor) : 0
39
+ const limit = query.limit ?? 50
40
+ if (!Number.isInteger(offset) || offset < 0) throw new InvalidError('Invalid cursor')
41
+ if (!Number.isInteger(limit) || limit < 1 || limit > 500) throw new InvalidError('limit must be an integer from 1 to 500')
42
+ return { offset, limit }
43
+ }
44
+
45
+ function compare(left: unknown, right: unknown): number {
46
+ if (left == null || right == null) return left == null ? (right == null ? 0 : -1) : 1
47
+ return left < right ? -1 : left > right ? 1 : 0
48
+ }
49
+
50
+ /** Stored values are JSON: what is kept in memory must equal what a restart reads back. */
51
+ export function json<T>(value: T): T {
52
+ return JSON.parse(JSON.stringify(value)) as T
53
+ }
@@ -0,0 +1,73 @@
1
+ import { randomUUID } from 'node:crypto'
2
+ import { mkdirSync } from 'node:fs'
3
+ import { dirname } from 'node:path'
4
+ import { DatabaseSync, type SQLInputValue } from 'node:sqlite'
5
+ import { NotFoundError, RecordRefusedError, validCollection, validField, validId, type RecordStore, type Row } from '../operations.ts'
6
+ import { bounds, json, nextVersion } from './rules.ts'
7
+
8
+ /**
9
+ * Records as JSON documents in one SQLite table, queried with json_extract.
10
+ * `native` is the open `DatabaseSync` for app code in src/server/persistence that needs real SQL.
11
+ */
12
+ export function sqliteStore(file: string): RecordStore & { native: DatabaseSync } {
13
+ mkdirSync(dirname(file), { recursive: true })
14
+ const db = new DatabaseSync(file)
15
+ db.exec(`PRAGMA journal_mode = WAL;
16
+ PRAGMA synchronous = FULL;
17
+ CREATE TABLE IF NOT EXISTS records (collection TEXT NOT NULL, id TEXT NOT NULL, data TEXT NOT NULL, PRIMARY KEY (collection, id));`)
18
+ const read = db.prepare('SELECT data FROM records WHERE collection = ? AND id = ?')
19
+ const insert = db.prepare('INSERT INTO records (collection, id, data) VALUES (?, ?, ?)')
20
+ const write = db.prepare('UPDATE records SET data = ? WHERE collection = ? AND id = ?')
21
+ const erase = db.prepare('DELETE FROM records WHERE collection = ? AND id = ?')
22
+ const get = (collection: string, id: string): Row | null => {
23
+ const found = read.get(validCollection(collection), validId(id)) as { data: string } | undefined
24
+ return found ? JSON.parse(found.data) as Row : null
25
+ }
26
+ // node:sqlite is synchronous, so each method below runs to completion without interleaving.
27
+ return {
28
+ native: db,
29
+ async list(collection, query = {}) {
30
+ const { offset, limit } = bounds(query)
31
+ const where = ['collection = ?']
32
+ const params: SQLInputValue[] = [validCollection(collection)]
33
+ const path = (field: string) => `'$.${validField(field)}'`
34
+ for (const [key, value] of Object.entries(query.filter ?? {})) {
35
+ const allowed = Array.isArray(value) ? value : [value]
36
+ const clauses = allowed.map((one) => {
37
+ if (one === null) return `json_extract(data, ${path(key)}) IS NULL`
38
+ params.push(typeof one === 'boolean' ? JSON.stringify(one) : one)
39
+ return typeof one === 'boolean'
40
+ ? `json_type(data, ${path(key)}) = ?`
41
+ : `(json_extract(data, ${path(key)}) = ? AND json_type(data, ${path(key)}) ${typeof one === 'string' ? "= 'text'" : "IN ('integer', 'real')"})`
42
+ })
43
+ where.push(clauses.length ? `(${clauses.join(' OR ')})` : '0')
44
+ }
45
+ if (query.search?.text) {
46
+ const text = query.search.text.toLowerCase()
47
+ where.push(`(${query.search.fields.map((field) => { params.push(text); return `instr(lower(CAST(json_extract(data, ${path(field)}) AS TEXT)), ?) > 0` }).join(' OR ') || '0'})`)
48
+ }
49
+ const order = query.sort ? `json_extract(data, ${path(query.sort.field)}) ${query.sort.direction === 'desc' ? 'DESC' : 'ASC'}, rowid` : 'rowid'
50
+ const rows = db.prepare(`SELECT data FROM records WHERE ${where.join(' AND ')} ORDER BY ${order} LIMIT ? OFFSET ?`)
51
+ .all(...params, limit + 1, offset) as Array<{ data: string }>
52
+ return { rows: rows.slice(0, limit).map((row) => JSON.parse(row.data) as Row), nextCursor: rows.length > limit ? String(offset + limit) : null }
53
+ },
54
+ async get(collection, id) { return get(collection, id) },
55
+ async create(collection, data) {
56
+ const id = data.id === undefined ? randomUUID() : validId(String(data.id))
57
+ if (get(collection, id)) throw new RecordRefusedError(`A record with id ${id} already exists`, [{ field: 'id', message: 'This id is already taken.' }])
58
+ const now = new Date().toISOString()
59
+ const row: Row = { ...json(data), id, version: 1, createdAt: now, updatedAt: now }
60
+ insert.run(collection, id, JSON.stringify(row))
61
+ return row
62
+ },
63
+ async update(collection, id, patch, options) {
64
+ const row = nextVersion(collection, id, get(collection, id) ?? undefined, patch, options)
65
+ write.run(JSON.stringify(row), collection, id)
66
+ return row
67
+ },
68
+ async remove(collection, id) {
69
+ if (!erase.run(validCollection(collection), validId(id)).changes) throw new NotFoundError(`No ${collection} record ${id}`)
70
+ },
71
+ async close() { if (db.isOpen) db.close() },
72
+ }
73
+ }
@@ -0,0 +1,216 @@
1
+ import { randomBytes, randomUUID } from 'node:crypto'
2
+ import type { IncomingMessage } from 'node:http'
3
+ import { ForbiddenError, InvalidError, NotFoundError, UnauthorizedError, VersionConflictError, z, type Principal, type Via } from '../operations.ts'
4
+
5
+ /** Something an agent offered to show; the person accepts it in one browser view, where alone it is applied. */
6
+ export type ViewOffer = { id: string; conversation: string; action: 'source.open'; input: { root: string; path: string; line: number; endLine: number } }
7
+ /**
8
+ * `apply` carries the file `version` its lines were counted in and the `text` of those lines: show them
9
+ * once the editor has that version, and look for `text` instead when the editor shows an unsaved draft.
10
+ */
11
+ export type ViewEvent = { type: 'offer'; offer: ViewOffer } | { type: 'apply'; offer: ViewOffer; version: number; text: string } | { type: 'withdrawn'; id: string }
12
+
13
+ /** What an agent can discover: names, when to use them and their input, never data. */
14
+ export type ViewActionDoc = { name: string; description: string; inputSchema: unknown }
15
+
16
+ /**
17
+ * The agent runtime owns conversations and the browser identity of anonymous visitors, so it supplies
18
+ * both checks. `owner` derives a trusted key from the actual request and its server-resolved principal
19
+ * (an account id, or a key for the runtime's browser cookie), never from request input; `null` refuses.
20
+ * `owns` says whether that key owns the conversation.
21
+ */
22
+ export type Conversations = {
23
+ owner(request: IncomingMessage, principal: Principal): Promise<string | null> | string | null
24
+ owns(conversation: string, owner: string): Promise<boolean> | boolean
25
+ }
26
+
27
+ /** Trusted context the runtime holds for one message: who, which conversation, and the view it came from. */
28
+ export type ViewBinding = { principal: Principal; owner: string; conversation: string }
29
+
30
+ export type Views = {
31
+ /** The actions an agent may request in this app. Empty until the app configures knowledge roots. */
32
+ actions(): ViewActionDoc[]
33
+ /** Installs the runtime's checks. Until then no view opens and no offer is made. */
34
+ useConversations(conversations: Conversations): void
35
+ /** A browser tab opens one view of a conversation it owns; the returned id is that tab's capability. */
36
+ open(request: IncomingMessage, principal: Principal, conversation: string): Promise<{ id: string }>
37
+ /** Whether `view` is a live view of this binding's conversation, held by the same person, session and owner. The runtime checks a message's view with it before accepting the message. */
38
+ bound(view: string, binding: ViewBinding): Promise<boolean>
39
+ /** Delivers this view's events until the returned function is called. */
40
+ connect(request: IncomingMessage, principal: Principal, id: string, send: (event: ViewEvent) => void): Promise<() => void>
41
+ /**
42
+ * An agent acting in `binding` offers an action. The source is authorized as the principal first.
43
+ * With `view` (the tab the message came from) the offer is sent to that view; without one it is only
44
+ * returned, for the chat to show. Either way it is applied only after the person accepts it.
45
+ */
46
+ request(binding: ViewBinding & { view?: string }, action: string, input: unknown): Promise<{ offer: ViewOffer; delivered: boolean }>
47
+ /** The person's answer from one of their views. Accepting re-authorizes and applies to that view only. */
48
+ answer(request: IncomingMessage, principal: Principal, id: string, offer: string, accept: boolean): Promise<void>
49
+ }
50
+
51
+ type Channel = { holder: Principal; owner: string; conversation: string; send?: (event: ViewEvent) => void }
52
+ /** `sha256` and `passage` are what the offer pointed at, so acceptance can find the same text in a changed file. */
53
+ type Pending = { offer: ViewOffer; holder: Principal; owner: string; view?: string; sha256: string; passage: string }
54
+
55
+ const offerLifetime = 10 * 60_000
56
+ const connectWithin = 60_000
57
+
58
+ const sourceOpen = z.object({
59
+ root: z.string().min(1),
60
+ path: z.string().min(1),
61
+ quote: z.string().min(1).max(2000).optional().describe('Text copied from the source; the lines containing it are highlighted.'),
62
+ line: z.number().int().min(1).optional().describe('1-based first line to highlight, when there is no quote.'),
63
+ endLine: z.number().int().min(1).optional(),
64
+ }).strict()
65
+
66
+ const catalog: ViewActionDoc[] = [{
67
+ name: 'source.open',
68
+ description: 'Offer to open a knowledge file beside the conversation with a passage highlighted and the rest of the file around it. The person sees the offer and chooses whether to open it.',
69
+ inputSchema: z.toJSONSchema(sourceOpen),
70
+ }]
71
+
72
+ /** Same person in the same signed-in session, or both anonymous (then the owner key tells visitors apart). */
73
+ const same = (a: Principal, b: Principal) => a.kind === b.kind && (a.kind === 'anonymous' || (b.kind === 'user' && a.id === b.id && a.session === b.session))
74
+ const refused = () => new ForbiddenError('Cannot open that source')
75
+ const noView = () => new NotFoundError('No such view')
76
+
77
+ export function createViews(app: {
78
+ invoke(name: string, input: unknown, principal: Principal, via: Via): Promise<unknown>
79
+ refresh(principal: Principal): Promise<Principal>
80
+ has(operation: string): boolean
81
+ }): Views {
82
+ const channels = new Map<string, Channel>()
83
+ const offers = new Map<string, Pending>()
84
+ let conversations: Conversations | undefined
85
+
86
+ /** The binding as it stands now: a live principal whose owner key still owns the conversation. */
87
+ async function check({ principal, owner, conversation }: ViewBinding): Promise<Principal> {
88
+ const now = await app.refresh(principal)
89
+ if (!conversations || typeof owner !== 'string' || !owner || typeof conversation !== 'string' || !conversation || !(await conversations.owns(conversation, owner))) {
90
+ throw new NotFoundError('No such conversation')
91
+ }
92
+ return now
93
+ }
94
+
95
+ async function ownerOf(request: IncomingMessage, principal: Principal): Promise<string> {
96
+ const owner = conversations ? await conversations.owner(request, principal) : null
97
+ if (!owner) throw new NotFoundError('No such conversation')
98
+ return owner
99
+ }
100
+
101
+ /** A browser call on a view: the capability, the principal and session it was opened with, and the same owner. */
102
+ async function owned(request: IncomingMessage, principal: Principal, id: string): Promise<Channel> {
103
+ const channel = channels.get(id)
104
+ if (!channel || !same(channel.holder, principal) || (await ownerOf(request, principal)) !== channel.owner) throw noView()
105
+ await check({ principal, owner: channel.owner, conversation: channel.conversation })
106
+ return channel
107
+ }
108
+
109
+ function withdraw(id: string) {
110
+ const pending = offers.get(id)
111
+ offers.delete(id)
112
+ for (const channel of channels.values()) {
113
+ if (pending && channel.conversation === pending.offer.conversation && channel.owner === pending.owner) channel.send?.({ type: 'withdrawn', id })
114
+ }
115
+ }
116
+
117
+ /** Reads the file as `principal`; any failure is the same refusal, so a view never tells what exists. */
118
+ async function readable(principal: Principal, root: string, path: string): Promise<{ body: string; sha256: string; version: number }> {
119
+ try {
120
+ return (await app.invoke('knowledge.read', { root, path }, principal, 'agent')) as { body: string; sha256: string; version: number }
121
+ } catch (error) {
122
+ if ((error as Error).name === 'UnauthorizedError') throw error
123
+ throw refused()
124
+ }
125
+ }
126
+
127
+ return {
128
+ actions: () => app.has('knowledge.read') ? catalog : [],
129
+ useConversations(next) { conversations = next },
130
+ async open(request, principal, conversation) {
131
+ const owner = await ownerOf(request, principal)
132
+ const holder = await check({ principal, owner, conversation })
133
+ const id = randomBytes(24).toString('base64url')
134
+ channels.set(id, { holder, owner, conversation })
135
+ setTimeout(() => { if (!channels.get(id)?.send) channels.delete(id) }, connectWithin).unref()
136
+ return { id }
137
+ },
138
+ async bound(view, binding) {
139
+ const channel = channels.get(view)
140
+ if (!channel?.send || channel.conversation !== binding.conversation || channel.owner !== binding.owner) return false
141
+ const now = await check(binding).catch(() => null)
142
+ return Boolean(now && same(channel.holder, now))
143
+ },
144
+ async connect(request, principal, id, send) {
145
+ const channel = await owned(request, principal, id)
146
+ channel.send = send
147
+ return () => { channels.delete(id) }
148
+ },
149
+ async request(binding, action, raw) {
150
+ if (action !== 'source.open' || !app.has('knowledge.read')) throw new NotFoundError(`Unknown view action: ${action}`)
151
+ const parsed = sourceOpen.safeParse(raw)
152
+ if (!parsed.success) throw new InvalidError(`${action}: ${z.prettifyError(parsed.error)}`)
153
+ const { root, path, quote, line, endLine } = parsed.data
154
+ const { owner, conversation, view } = binding
155
+ const holder = await check(binding)
156
+ const source = await readable(holder, root, path)
157
+ const lines = source.body.split('\n')
158
+ let first = line ?? 1
159
+ let last = endLine ?? first
160
+ if (quote) {
161
+ const at = locate(lines, quote)
162
+ if (!at) throw new InvalidError('That passage is not in the source')
163
+ ;[first, last] = at
164
+ }
165
+ first = Math.min(first, lines.length)
166
+ last = Math.min(Math.max(last, first), lines.length)
167
+ const offer: ViewOffer = { id: randomUUID(), conversation, action, input: { root, path, line: first, endLine: last } }
168
+ const channel = view === undefined ? undefined : channels.get(view)
169
+ const target = channel?.send && channel.conversation === conversation && channel.owner === owner && same(channel.holder, holder) ? view : undefined
170
+ offers.set(offer.id, { offer, holder, owner, view: target, sha256: source.sha256, passage: lines.slice(first - 1, last).join('\n') })
171
+ setTimeout(() => { if (offers.has(offer.id)) withdraw(offer.id) }, offerLifetime).unref()
172
+ if (target) channel!.send!({ type: 'offer', offer })
173
+ return { offer, delivered: Boolean(target) }
174
+ },
175
+ async answer(request, principal, id, offerId, accept) {
176
+ const channel = await owned(request, principal, id)
177
+ const pending = offers.get(offerId)
178
+ // An offer sent to one view is answered there; one shown only in the chat may be answered from any view of it.
179
+ if (!pending || pending.offer.conversation !== channel.conversation || pending.owner !== channel.owner || !same(pending.holder, channel.holder) || (pending.view && pending.view !== id)) {
180
+ throw new NotFoundError('That offer is no longer open')
181
+ }
182
+ withdraw(offerId)
183
+ if (!accept) return
184
+ // Access may have changed since the offer: check again as the person now is.
185
+ const now = await app.refresh(principal).catch(() => { throw new UnauthorizedError('Sign in again to open this source.') })
186
+ const source = await readable(now, pending.offer.input.root, pending.offer.input.path)
187
+ const lines = source.body.split('\n')
188
+ let offer = pending.offer
189
+ if (source.sha256 !== pending.sha256) {
190
+ // The file changed since the offer: highlight the same passage where it now is, or say it is gone.
191
+ const at = pending.passage.trim() ? locate(lines, pending.passage) : null
192
+ if (!at) throw new VersionConflictError('That passage changed since it was offered; ask for it again', null)
193
+ offer = { ...offer, input: { ...offer.input, line: at[0], endLine: at[1] } }
194
+ }
195
+ const { line, endLine } = offer.input
196
+ channel.send?.({ type: 'apply', offer, version: source.version, text: lines.slice(line - 1, endLine).join('\n') })
197
+ },
198
+ }
199
+ }
200
+
201
+ /** First and last 1-based line of the first place `quote` appears, ignoring case and runs of whitespace. */
202
+ function locate(lines: string[], quote: string): [number, number] | null {
203
+ let flat = ''
204
+ const lineOf: number[] = []
205
+ lines.forEach((text, index) => {
206
+ for (const char of `${text}\n`) {
207
+ const space = /\s/.test(char)
208
+ if (space && flat.endsWith(' ')) continue
209
+ flat += space ? ' ' : char.toLowerCase()
210
+ lineOf.push(index + 1)
211
+ }
212
+ })
213
+ const needle = quote.replace(/\s+/g, ' ').trim().toLowerCase()
214
+ const at = needle ? flat.indexOf(needle) : -1
215
+ return at < 0 ? null : [lineOf[at], lineOf[at + needle.length - 1]]
216
+ }
package/src/brain.ts ADDED
@@ -0,0 +1,94 @@
1
+ import { watch } from 'node:fs'
2
+ import { readdir, readFile } from 'node:fs/promises'
3
+ import { join } from 'node:path'
4
+
5
+ /**
6
+ * A Golem app's brain: the `brain/` folder at the app root, an OKF bundle (root `index.md` with
7
+ * `okf_version`, concepts with `type` front matter, optional `log.md`), served read-only to the
8
+ * golem-ui Brain adapter. Paths are `/`-separated and relative to `brain/`.
9
+ */
10
+ export type BrainEntry = { path: string; kind: 'file' | 'dir' }
11
+ export type BrainHit = { path: string; line: number; excerpt: string }
12
+
13
+ const segment = /^[^/\\\0.][^/\\\0]{0,127}$/
14
+ /** A relative path inside the bundle: no `..`, no hidden segments, no backslashes. Empty is the root. */
15
+ function clean(value: string): string {
16
+ const path = value.replace(/\/$/, '')
17
+ if (path && !path.split('/').every((part) => segment.test(part))) throw new Error(`Invalid brain path: ${JSON.stringify(value)}`)
18
+ return path
19
+ }
20
+
21
+ export function openBrain(dir: string) {
22
+ const list = async (dir_ = ''): Promise<BrainEntry[]> => {
23
+ const folder = clean(dir_)
24
+ const entries = await readdir(join(dir, folder), { withFileTypes: true }).catch(() => [])
25
+ return entries
26
+ .filter((entry) => !entry.name.startsWith('.') && (entry.isDirectory() || entry.name.endsWith('.md')))
27
+ .map((entry): BrainEntry => ({ path: folder ? `${folder}/${entry.name}` : entry.name, kind: entry.isDirectory() ? 'dir' : 'file' }))
28
+ .sort((a, b) => (a.kind === b.kind ? a.path.localeCompare(b.path) : a.kind === 'dir' ? -1 : 1))
29
+ }
30
+ const read = async (path: string): Promise<string> => {
31
+ const file = clean(path)
32
+ if (!file.endsWith('.md')) throw new Error(`Not a markdown file: ${path}`)
33
+ return readFile(join(dir, file), 'utf8')
34
+ }
35
+ async function* walk(folder = ''): AsyncGenerator<string> {
36
+ for (const entry of await list(folder)) {
37
+ if (entry.kind === 'dir') yield* walk(entry.path)
38
+ else yield entry.path
39
+ }
40
+ }
41
+ return {
42
+ list,
43
+ read,
44
+ /** That folder's `index.md`, or the OKF fallback: one bullet per entry, its `description` from front matter. */
45
+ async index(dir_ = ''): Promise<string> {
46
+ const folder = clean(dir_)
47
+ const own = await read(folder ? `${folder}/index.md` : 'index.md').catch(() => undefined)
48
+ if (own !== undefined) return own
49
+ const lines = []
50
+ for (const entry of await list(folder)) {
51
+ const name = entry.path.slice(folder ? folder.length + 1 : 0)
52
+ if (entry.kind === 'file' && (name === 'index.md' || name === 'log.md')) continue
53
+ const description = entry.kind === 'file' ? /^description:\s*(.*)$/m.exec((await read(entry.path)).split(/^---$/m)[1] ?? '')?.[1]?.replace(/^["']|["']$/g, '') : ''
54
+ lines.push(`* [${name}](${name}${entry.kind === 'dir' ? '/' : ''})${description ? ` - ${description}` : ''}`)
55
+ }
56
+ return `# ${folder || 'Index'}\n\n${lines.join('\n')}\n`
57
+ },
58
+ // ponytail: substring scan of every file per query; an index if a brain outgrows a few MB.
59
+ async search(query: string, limit = 50): Promise<BrainHit[]> {
60
+ const needle = query.trim().toLowerCase()
61
+ const hits: BrainHit[] = []
62
+ if (!needle) return hits
63
+ for await (const path of walk()) {
64
+ const lines = (await read(path)).split('\n')
65
+ for (let i = 0; i < lines.length && hits.length < limit; i++) {
66
+ if (lines[i]!.toLowerCase().includes(needle)) hits.push({ path, line: i + 1, excerpt: lines[i]!.trim().slice(0, 200) })
67
+ }
68
+ if (hits.length >= limit) break
69
+ }
70
+ return hits
71
+ },
72
+ /** Fires on any change under the folder; the browser re-reads what it shows. */
73
+ watch(listener: () => void): () => void {
74
+ let watcher: ReturnType<typeof watch> | undefined
75
+ try { watcher = watch(dir, { recursive: true }, () => listener()) } catch { /* No folder yet: nothing to watch. */ }
76
+ watcher?.on('error', () => {})
77
+ return () => watcher?.close()
78
+ },
79
+ }
80
+ }
81
+
82
+ export type Brain = ReturnType<typeof openBrain>
83
+
84
+ /**
85
+ * Source locations cited in an agent's reply, `path#L<start>-L<end>` relative to `brain/` (a leading
86
+ * `brain/` is dropped), in order of first mention. What becomes `sources` on the chat event.
87
+ */
88
+ export function citations(text: string): string[] {
89
+ const found = new Set<string>()
90
+ for (const match of text.matchAll(/(?<![\w/.-])(?:brain\/)?([\w][\w./-]*\.md)#L(\d+)(?:-L?(\d+))?/g)) {
91
+ found.add(`${match[1]}#L${match[2]}-L${match[3] ?? match[2]}`)
92
+ }
93
+ return [...found]
94
+ }