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.
- package/CHANGELOG.md +31 -0
- package/README.md +11 -6
- package/docs/agents.md +64 -0
- package/docs/app-backend.md +259 -0
- package/docs/architecture.md +93 -0
- package/docs/builder.md +15 -0
- package/docs/knowledge.md +35 -0
- package/docs/local-cli.md +31 -15
- package/docs/source-development.md +31 -0
- package/index.html +9 -0
- package/package.json +24 -5
- package/src/backend/accounts.ts +287 -0
- package/src/backend/app.ts +269 -0
- package/src/backend/files.ts +68 -0
- package/src/backend/http.ts +276 -0
- package/src/backend/index.ts +10 -0
- package/src/backend/jobs.ts +302 -0
- package/src/backend/jsonl.ts +87 -0
- package/src/backend/knowledge.ts +264 -0
- package/src/backend/model.ts +129 -0
- package/src/backend/rules.ts +53 -0
- package/src/backend/sqlite.ts +73 -0
- package/src/backend/views.ts +216 -0
- package/src/brain.ts +94 -0
- package/src/browser/adapters.ts +229 -53
- package/src/browser/ansi.ts +104 -0
- package/src/browser/app.d.ts +5 -2
- package/src/browser/app.tsx +167 -39
- package/src/browser/groups.tsx +29 -0
- package/src/browser/main.tsx +1 -0
- package/src/browser/panekeys.ts +34 -0
- package/src/browser/sources.tsx +113 -0
- package/src/browser/styles.css +36 -0
- package/src/browser/terminal.tsx +89 -0
- package/src/browser-build.ts +20 -7
- package/src/chat.ts +74 -0
- package/src/cli.ts +91 -17
- package/src/client.ts +205 -0
- package/src/config.ts +169 -0
- package/src/dev-server.ts +339 -38
- package/src/entry.mjs +19 -0
- package/src/eslint.mjs +55 -0
- package/src/operations.ts +169 -0
- package/src/runtime/assistant.ts +141 -0
- package/src/runtime/discovery.ts +13 -7
- package/src/runtime/harness/agent-status.js +388 -0
- package/src/runtime/harness/claude-tmux.js +573 -0
- package/src/runtime/harness/codex-notify.js +95 -0
- package/src/runtime/harness/codex-tmux.js +292 -0
- package/src/runtime/harness/fake.js +430 -0
- package/src/runtime/harness/package.json +1 -0
- package/src/runtime/harness/port.js +208 -0
- package/src/runtime/harness/tmux-session.js +556 -0
- package/src/runtime/harness/tmux.js +285 -0
- package/src/runtime/harness/turnend-hook.js +105 -0
- package/src/runtime/session.ts +171 -34
- package/src/runtime/tmux.ts +173 -0
- package/src/runtime/tool-names.ts +19 -0
- package/src/source-mode.ts +56 -0
- package/vite.config.ts +2 -4
- package/src/runtime/codex.ts +0 -119
|
@@ -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
|
+
}
|