golem-kit 0.1.1 → 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 +8 -5
  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 +19 -12
  9. package/docs/source-development.md +31 -0
  10. package/index.html +9 -0
  11. package/package.json +24 -5
  12. package/src/backend/accounts.ts +287 -0
  13. package/src/backend/app.ts +269 -0
  14. package/src/backend/files.ts +68 -0
  15. package/src/backend/http.ts +276 -0
  16. package/src/backend/index.ts +10 -0
  17. package/src/backend/jobs.ts +302 -0
  18. package/src/backend/jsonl.ts +87 -0
  19. package/src/backend/knowledge.ts +264 -0
  20. package/src/backend/model.ts +129 -0
  21. package/src/backend/rules.ts +53 -0
  22. package/src/backend/sqlite.ts +73 -0
  23. package/src/backend/views.ts +216 -0
  24. package/src/brain.ts +94 -0
  25. package/src/browser/adapters.ts +229 -53
  26. package/src/browser/ansi.ts +104 -0
  27. package/src/browser/app.d.ts +5 -2
  28. package/src/browser/app.tsx +167 -39
  29. package/src/browser/groups.tsx +29 -0
  30. package/src/browser/main.tsx +1 -0
  31. package/src/browser/panekeys.ts +34 -0
  32. package/src/browser/sources.tsx +113 -0
  33. package/src/browser/styles.css +36 -0
  34. package/src/browser/terminal.tsx +89 -0
  35. package/src/browser-build.ts +20 -7
  36. package/src/chat.ts +74 -0
  37. package/src/cli.ts +85 -13
  38. package/src/client.ts +205 -0
  39. package/src/config.ts +139 -5
  40. package/src/dev-server.ts +336 -39
  41. package/src/entry.mjs +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,302 @@
1
+ import { Cron } from 'croner'
2
+ import {
3
+ anonymous, defineOperation, ForbiddenError, InvalidError, NotFoundError, RecordRefusedError, z,
4
+ type JobContext, type Operation, type Principal, type RecordStore, type Row,
5
+ } from '../operations.ts'
6
+
7
+ /** Reserved collections: the records operations refuse `_` names, so run state never leaks through them. */
8
+ export const SCHEDULES = '_job_schedules'
9
+ export const RUNS = '_job_runs'
10
+ // Finished runs are kept; there is no retention limit yet.
11
+ /** The only name job state puts on the change stream; readers re-list through `jobs.runs`. */
12
+ export const JOBS_CHANGE = '_jobs'
13
+
14
+ /** An app-owned job: which registered operation it runs. Callers pick the job and its input, never the operation or who it acts for. */
15
+ export type JobDefinition = {
16
+ name: string
17
+ description: string
18
+ /** A registered operation; each run goes through `invoke` and `authorize` like any other call. */
19
+ operation: string
20
+ /** Signed-out guests may start and schedule it; its runs act as `anonymous`. Apps without accounts are always anonymous. */
21
+ anonymous?: boolean
22
+ /** A scheduled time that passed while the server was down: `skip` (default) waits for the next one, `once` runs one catch-up at start. */
23
+ missed?: 'skip' | 'once'
24
+ }
25
+
26
+ type Deps = {
27
+ store: RecordStore
28
+ definitions(): JobDefinition[]
29
+ hasAccounts: boolean
30
+ resolveAccount(id: string): Promise<Principal>
31
+ invoke(name: string, input: unknown, principal: Principal, job: JobContext): Promise<unknown>
32
+ emit(): void
33
+ }
34
+
35
+ const maxDelay = 2 ** 31 - 1
36
+ const now = () => new Date().toISOString()
37
+
38
+ /**
39
+ * Durable server-side runs of app operations, started by hand or by an interval/cron schedule.
40
+ * A run outlives the request that started it; one found still running at start was cut off by a
41
+ * stop, so it becomes `interrupted` and waits for someone to retry or dismiss it — never replayed.
42
+ */
43
+ export function createJobs(deps: Deps) {
44
+ const { store } = deps
45
+ const definition = (name: string) => deps.definitions().find((one) => one.name === name)
46
+ const active = new Map<string, AbortController>()
47
+ const timers = new Map<string, NodeJS.Timeout>()
48
+ let closed = false
49
+ // Claims (a slot firing, a retry, an unschedule) read and write run state in one step, one at a time,
50
+ // so two callers never both see a run as unclaimed. Operations themselves run outside this queue.
51
+ let queue: Promise<unknown> = Promise.resolve()
52
+ const serial = <T>(step: () => Promise<T>): Promise<T> => {
53
+ const result = queue.then(step)
54
+ queue = result.catch(() => {})
55
+ return result
56
+ }
57
+
58
+ async function all(collection: string, filter?: Record<string, string | null>): Promise<Row[]> {
59
+ const rows: Row[] = []
60
+ let cursor: string | null = null
61
+ do {
62
+ const page: Awaited<ReturnType<RecordStore['list']>> = await store.list(collection, { filter, cursor, limit: 500 })
63
+ rows.push(...page.rows)
64
+ cursor = page.nextCursor
65
+ } while (cursor)
66
+ return rows
67
+ }
68
+ const write = async <T>(result: Promise<T>) => { const value = await result; deps.emit(); return value }
69
+
70
+ function nextAfter(schedule: Record<string, unknown>, from: Date): string {
71
+ if (typeof schedule.every === 'number') return new Date(from.getTime() + (schedule.every as number) * 1000).toISOString()
72
+ const next = new Cron(String(schedule.cron), { timezone: String(schedule.timezone), paused: true }).nextRun(from)
73
+ if (!next) throw new InvalidError('This cron expression never runs again')
74
+ return next.toISOString()
75
+ }
76
+
77
+ function job(name: string): JobDefinition {
78
+ const found = definition(name)
79
+ if (!found) throw new NotFoundError(`Unknown job: ${name}`)
80
+ return found
81
+ }
82
+
83
+ /** The job as it was when the run or schedule was made: a removed job, or one now pointing at another operation, never runs. */
84
+ function unchanged(row: Row): JobDefinition {
85
+ const found = job(String(row.job))
86
+ if (found.operation !== row.operation) throw new RecordRefusedError(`Job ${found.name} now runs ${found.operation}, not ${String(row.operation)}; schedule it again`)
87
+ return found
88
+ }
89
+
90
+ /** Runs are owned by the account that started them; nobody reads or acts on someone else's. */
91
+ function owned(row: Row | null, principal: Principal): Row {
92
+ const owner = principal.kind === 'user' ? principal.id : null
93
+ if (!row || (row.accountId ?? null) !== owner) throw new NotFoundError('No such job run or schedule')
94
+ return row
95
+ }
96
+
97
+ function actor(definition: JobDefinition, principal: Principal): string | null {
98
+ if (principal.kind === 'user') {
99
+ if (!deps.hasAccounts) throw new ForbiddenError('Jobs that act for a person need local accounts')
100
+ return principal.id
101
+ }
102
+ if (deps.hasAccounts && !definition.anonymous) throw new ForbiddenError(`Sign in to run ${definition.name}`)
103
+ return null
104
+ }
105
+
106
+ async function execute(run: Row): Promise<void> {
107
+ const controller = new AbortController()
108
+ active.set(run.id, controller)
109
+ const context: JobContext = {
110
+ runId: run.id,
111
+ key: String(run.key),
112
+ signal: controller.signal,
113
+ progress: async (progress) => { await write(store.update(RUNS, run.id, { progress })) },
114
+ }
115
+ let patch: Record<string, unknown>
116
+ try {
117
+ const definition = unchanged(run)
118
+ // Current roles and groups on every run: a removed account or a lost role stops its jobs.
119
+ const principal = run.accountId ? await deps.resolveAccount(String(run.accountId)) : anonymous
120
+ const result = await deps.invoke(definition.operation, run.input, principal, context)
121
+ patch = { status: 'succeeded', result: result ?? null }
122
+ } catch (error) {
123
+ const failure = error instanceof Error ? `${error.name}: ${error.message}` : String(error)
124
+ patch = { status: controller.signal.aborted ? 'cancelled' : 'failed', error: failure }
125
+ } finally {
126
+ active.delete(run.id)
127
+ }
128
+ if (!closed) await write(store.update(RUNS, run.id, { ...patch, finishedAt: now() })).catch((error) => console.error(error))
129
+ }
130
+
131
+ async function begin(fields: { job: string; operation: string; input: unknown; accountId: string | null; scheduleId: string | null; key?: string; retryOf?: string }): Promise<Row> {
132
+ const id = crypto.randomUUID()
133
+ const run = await write(store.create(RUNS, { id, ...fields, key: fields.key ?? id, status: 'running', progress: null, startedAt: now(), cancelRequested: false }))
134
+ void execute(run)
135
+ return run
136
+ }
137
+
138
+ const tick = (scheduleId: string) => serial(async () => {
139
+ timers.delete(scheduleId)
140
+ if (closed) return
141
+ const schedule = await store.get(SCHEDULES, scheduleId)
142
+ if (!schedule) return
143
+ const slot = String(schedule.nextRunAt)
144
+ if (Date.parse(slot) > Date.now()) return arm(schedule)
145
+ await fire(schedule, slot, new Date())
146
+ })
147
+
148
+ /**
149
+ * One scheduled slot. Overlap is skipped: while a run of this schedule is still running (a
150
+ * cancelled one included, until its operation returns), or was interrupted and nobody has retried
151
+ * or dismissed it, the slot is recorded as skipped instead. So is a slot whose job was removed or changed.
152
+ */
153
+ async function fire(schedule: Row, slot: string, from: Date): Promise<void> {
154
+ const pending = (await all(RUNS, { scheduleId: schedule.id })).some((run) => run.status === 'running' || (run.status === 'interrupted' && !run.resolution))
155
+ let problem: string | null = null
156
+ try { unchanged(schedule) } catch (error) { problem = (error as Error).message }
157
+ const skip = pending || problem !== null
158
+ const next = await store.update(SCHEDULES, schedule.id, { nextRunAt: nextAfter(schedule, from), error: problem, ...(skip ? { lastSkippedAt: slot } : { lastRunAt: slot }) })
159
+ deps.emit()
160
+ if (!skip) await begin({ job: String(schedule.job), operation: String(schedule.operation), input: schedule.input, accountId: (schedule.accountId as string | null) ?? null, scheduleId: schedule.id, key: `${schedule.id}-${Date.parse(slot)}` })
161
+ arm(next)
162
+ }
163
+
164
+ function arm(schedule: Row): void {
165
+ clearTimeout(timers.get(schedule.id))
166
+ if (closed) return
167
+ const delay = Math.min(Math.max(Date.parse(String(schedule.nextRunAt)) - Date.now(), 0), maxDelay)
168
+ const timer = setTimeout(() => void tick(schedule.id).catch((error) => console.error(error)), delay)
169
+ timers.set(schedule.id, timer.unref())
170
+ }
171
+
172
+ async function start(): Promise<void> {
173
+ for (const run of await all(RUNS, { status: 'running' })) {
174
+ await store.update(RUNS, run.id, { status: 'interrupted', finishedAt: now(), error: 'The server stopped while this run was in progress. Its effects may be partial; retry or dismiss it.' })
175
+ }
176
+ for (const schedule of await all(SCHEDULES)) {
177
+ const slot = String(schedule.nextRunAt)
178
+ if (Date.parse(slot) > Date.now()) { arm(schedule); continue }
179
+ if (definition(String(schedule.job))?.missed === 'once') { await serial(() => fire(schedule, slot, new Date())); continue }
180
+ arm(await store.update(SCHEDULES, schedule.id, { nextRunAt: nextAfter(schedule, new Date()), lastMissedAt: slot }))
181
+ }
182
+ deps.emit()
183
+ }
184
+
185
+ const ready = start().catch((error) => { console.error('Jobs failed to start', error) })
186
+
187
+ const runRecord = (input: { id: string }) => ({ collection: RUNS, id: input.id })
188
+ const scheduleRecord = (input: { id: string }) => ({ collection: SCHEDULES, id: input.id })
189
+ const row = z.looseObject({ id: z.string(), version: z.number() })
190
+ const id = z.string().min(1)
191
+ const input = z.unknown().optional()
192
+
193
+ // Management goes through `invoke`, so the app's `authorize` sees each call (and the run or schedule as `record`).
194
+ const operations: Operation[] = [
195
+ defineOperation({
196
+ name: 'jobs.list', description: 'List the app jobs and your schedules.',
197
+ input: z.object({}), output: z.object({ jobs: z.array(z.object({ name: z.string(), description: z.string() })), schedules: z.array(row) }),
198
+ async run(_, { principal, permits }) {
199
+ await ready
200
+ const owner = principal.kind === 'user' ? principal.id : null
201
+ const mine = (await all(SCHEDULES, { accountId: owner }))
202
+ const visible = await Promise.all(mine.map(permits))
203
+ return { jobs: deps.definitions().map(({ name, description }) => ({ name, description })), schedules: mine.filter((_, index) => visible[index]) }
204
+ },
205
+ }),
206
+ defineOperation({
207
+ name: 'jobs.start', description: 'Start one run of a job now. It keeps running if the browser goes away.',
208
+ input: z.object({ job: z.string(), input }), output: row,
209
+ async run(request, { principal }) {
210
+ await ready
211
+ const definition = job(request.job)
212
+ return begin({ job: definition.name, operation: definition.operation, input: request.input ?? {}, accountId: actor(definition, principal), scheduleId: null })
213
+ },
214
+ }),
215
+ defineOperation({
216
+ name: 'jobs.schedule', description: 'Run a job every N seconds, or on a cron expression in an explicit IANA timezone.',
217
+ input: z.union([
218
+ z.object({ job: z.string(), input, every: z.number().int().min(10) }),
219
+ z.object({ job: z.string(), input, cron: z.string().max(120), timezone: z.string().max(64) }),
220
+ ]),
221
+ output: row,
222
+ async run(request, { principal }) {
223
+ await ready
224
+ const definition = job(request.job)
225
+ const timing = 'every' in request ? { every: request.every } : { cron: request.cron, timezone: request.timezone }
226
+ let nextRunAt: string
227
+ try { nextRunAt = nextAfter(timing, new Date()) } catch (error) { throw new InvalidError(`Invalid schedule: ${(error as Error).message}`) }
228
+ const schedule = await write(store.create(SCHEDULES, { job: definition.name, operation: definition.operation, input: request.input ?? {}, accountId: actor(definition, principal), ...timing, nextRunAt }))
229
+ arm(schedule)
230
+ return schedule
231
+ },
232
+ }),
233
+ defineOperation({
234
+ name: 'jobs.unschedule', description: 'Stop a schedule. Runs it already started are unaffected.',
235
+ input: z.object({ id }), output: z.null(), record: scheduleRecord,
236
+ run: (request, { principal }) => serial(async () => {
237
+ owned(await store.get(SCHEDULES, request.id), principal)
238
+ clearTimeout(timers.get(request.id))
239
+ timers.delete(request.id)
240
+ await write(store.remove(SCHEDULES, request.id))
241
+ return null
242
+ }),
243
+ }),
244
+ defineOperation({
245
+ name: 'jobs.runs', description: 'Your recent job runs, newest first, with status, progress, result and error.',
246
+ input: z.object({ job: z.string().optional(), scheduleId: z.string().optional(), limit: z.number().int().min(1).max(200).optional() }),
247
+ output: z.array(row),
248
+ async run(request, { principal, permits }) {
249
+ await ready
250
+ const filter: Record<string, string | null> = { accountId: principal.kind === 'user' ? principal.id : null }
251
+ if (request.job) filter.job = request.job
252
+ if (request.scheduleId) filter.scheduleId = request.scheduleId
253
+ const page = await store.list(RUNS, { filter, sort: { field: 'startedAt', direction: 'desc' }, limit: request.limit ?? 50 })
254
+ const visible = await Promise.all(page.rows.map(permits))
255
+ return page.rows.filter((_, index) => visible[index])
256
+ },
257
+ }),
258
+ defineOperation({
259
+ name: 'jobs.cancel', description: 'Ask a running job to stop. The operation stops at its next check; work it already did stays done.',
260
+ input: z.object({ id }), output: row, record: runRecord,
261
+ async run(request, { principal }) {
262
+ const run = owned(await store.get(RUNS, request.id), principal)
263
+ const controller = active.get(run.id)
264
+ if (run.status !== 'running' || !controller) throw new RecordRefusedError('Only a running job can be cancelled')
265
+ const updated = await write(store.update(RUNS, run.id, { cancelRequested: true }))
266
+ controller.abort(new Error('Cancelled'))
267
+ return updated
268
+ },
269
+ }),
270
+ defineOperation({
271
+ name: 'jobs.resolve', description: 'Settle an interrupted run: retry starts a new run with the same input and idempotency key; dismiss leaves it as is.',
272
+ input: z.object({ id, action: z.enum(['retry', 'dismiss']) }), output: row, record: runRecord,
273
+ run: (request, { principal }) => serial(async () => {
274
+ const run = owned(await store.get(RUNS, request.id), principal)
275
+ if (run.status !== 'interrupted' || run.resolution) throw new RecordRefusedError('Only an unsettled interrupted run can be retried or dismissed')
276
+ if (request.action === 'dismiss') return write(store.update(RUNS, run.id, { resolution: 'dismissed' }))
277
+ // Re-checks who may start the job now: the retry acts as the same account with its current roles.
278
+ actor(unchanged(run), principal)
279
+ // The retry exists before the old run is marked settled: a stop in between leaves both visible, never neither.
280
+ const retry = await begin({ job: String(run.job), operation: String(run.operation), input: run.input, accountId: (run.accountId as string | null) ?? null, scheduleId: (run.scheduleId as string | null) ?? null, key: String(run.key), retryOf: run.id })
281
+ await write(store.update(RUNS, run.id, { resolution: 'retried', retryId: retry.id }))
282
+ return retry
283
+ }),
284
+ }),
285
+ ]
286
+
287
+ return {
288
+ operations,
289
+ /** Resolves once interrupted runs are marked and schedules armed. */
290
+ ready,
291
+ /**
292
+ * Stops the timers and aborts every running operation's signal. Nothing more is recorded: those
293
+ * runs stay `running` and become `interrupted` at the next start, whatever their operations did meanwhile.
294
+ */
295
+ close() {
296
+ closed = true
297
+ for (const timer of timers.values()) clearTimeout(timer)
298
+ timers.clear()
299
+ for (const controller of active.values()) controller.abort(new Error('The server is stopping'))
300
+ },
301
+ }
302
+ }
@@ -0,0 +1,87 @@
1
+ import { randomUUID } from 'node:crypto'
2
+ import { mkdir, open, readFile, rename, writeFile } from 'node:fs/promises'
3
+ import { join } from 'node:path'
4
+ import { NotFoundError, RecordRefusedError, validCollection, validId, type RecordStore, type Row } from '../operations.ts'
5
+ import { json, nextVersion, page } from './rules.ts'
6
+
7
+ type Entry = { put: Row } | { remove: string }
8
+
9
+ /**
10
+ * One append-only JSONL log per collection, replayed into memory on first use and fsynced per write.
11
+ * Single-process only and one write at a time; use the SQLite store for anything busier.
12
+ */
13
+ export async function jsonlStore(directory: string): Promise<RecordStore & { native: string }> {
14
+ await mkdir(directory, { recursive: true })
15
+ const collections = new Map<string, Map<string, Row>>()
16
+ let queue: Promise<unknown> = Promise.resolve()
17
+ const serial = <T>(work: () => Promise<T>): Promise<T> => {
18
+ const run = queue.then(work)
19
+ queue = run.catch(() => {})
20
+ return run
21
+ }
22
+ const file = (collection: string) => join(directory, `${validCollection(collection)}.jsonl`)
23
+
24
+ async function load(collection: string): Promise<Map<string, Row>> {
25
+ const loaded = collections.get(collection)
26
+ if (loaded) return loaded
27
+ const rows = new Map<string, Row>()
28
+ let text = ''
29
+ try { text = await readFile(file(collection), 'utf8') } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error }
30
+ // A crash can leave one torn line at the end; everything before the last newline was acknowledged.
31
+ const complete = text.slice(0, text.lastIndexOf('\n') + 1)
32
+ const lines = complete.split('\n').filter(Boolean)
33
+ for (const line of lines) {
34
+ const entry = JSON.parse(line) as Entry
35
+ if ('put' in entry) rows.set(entry.put.id, entry.put)
36
+ else rows.delete(entry.remove)
37
+ }
38
+ if (complete.length !== text.length || lines.length > rows.size * 2 + 100) {
39
+ const temporary = `${file(collection)}.${process.pid}.tmp`
40
+ await writeFile(temporary, [...rows.values()].map((row) => `${JSON.stringify({ put: row })}\n`).join(''), { flush: true })
41
+ await rename(temporary, file(collection))
42
+ }
43
+ collections.set(collection, rows)
44
+ return rows
45
+ }
46
+
47
+ async function append(collection: string, entry: Entry): Promise<void> {
48
+ const handle = await open(file(collection), 'a')
49
+ try {
50
+ await handle.appendFile(`${JSON.stringify(entry)}\n`)
51
+ await handle.datasync()
52
+ } finally {
53
+ await handle.close()
54
+ }
55
+ }
56
+
57
+ return {
58
+ native: directory,
59
+ async list(collection, query) { return structuredClone(page([...(await serial(() => load(collection))).values()], query)) },
60
+ async get(collection, id) { return structuredClone((await serial(() => load(collection))).get(validId(id)) ?? null) },
61
+ create: (collection, data) => serial(async () => {
62
+ const rows = await load(collection)
63
+ const id = data.id === undefined ? randomUUID() : validId(String(data.id))
64
+ if (rows.has(id)) throw new RecordRefusedError(`A record with id ${id} already exists`, [{ field: 'id', message: 'This id is already taken.' }])
65
+ const now = new Date().toISOString()
66
+ const row: Row = { ...json(data), id, version: 1, createdAt: now, updatedAt: now }
67
+ await append(collection, { put: row })
68
+ rows.set(id, row)
69
+ return structuredClone(row)
70
+ }),
71
+ update: (collection, id, patch, options) => serial(async () => {
72
+ const rows = await load(collection)
73
+ const current = rows.get(validId(id))
74
+ const row = nextVersion(collection, id, current, patch, options)
75
+ await append(collection, { put: row })
76
+ rows.set(id, row)
77
+ return structuredClone(row)
78
+ }),
79
+ remove: (collection, id) => serial(async () => {
80
+ const rows = await load(collection)
81
+ if (!rows.has(validId(id))) throw new NotFoundError(`No ${collection} record ${id}`)
82
+ await append(collection, { remove: id })
83
+ rows.delete(id)
84
+ }),
85
+ close: () => serial(async () => { collections.clear() }),
86
+ }
87
+ }
@@ -0,0 +1,264 @@
1
+ import { createHash, randomUUID } from 'node:crypto'
2
+ import { constants } from 'node:fs'
3
+ import { link, lstat, mkdir, open, readdir, realpath, rename, unlink, writeFile } from 'node:fs/promises'
4
+ import { dirname, join, relative, sep } from 'node:path'
5
+ import {
6
+ defineOperation, ForbiddenError, InvalidError, NotFoundError, validCollection, VersionConflictError, z,
7
+ type Operation, type RecordStore, type Row,
8
+ } from '../operations.ts'
9
+
10
+ /** Internal collection holding one revision row per file: the numeric version Editor saves against and the last sha256 seen. */
11
+ export const KNOWLEDGE_COLLECTION = '_knowledge'
12
+
13
+ /** Knowledge roots an app's server module opts into: root name → directory relative to the app root. */
14
+ export type KnowledgeRoots = Record<string, string>
15
+
16
+ /** One markdown file as operations return it. `id` is the path, so it plugs into golem-ui's Records contract. */
17
+ export type KnowledgeFile = Row & { root: string; path: string; body: string; sha256: string; type: string | null; title: string }
18
+
19
+ const maxBytes = 2_000_000
20
+
21
+ // One queue per absolute file path, shared across server-module reloads.
22
+ // In-process only: a second server process on the same app is not covered.
23
+ const locks = new Map<string, Promise<unknown>>()
24
+ function serial<T>(key: string, work: () => Promise<T>): Promise<T> {
25
+ const next = (locks.get(key) ?? Promise.resolve()).then(work, work)
26
+ const settled = next.catch(() => {})
27
+ locks.set(key, settled)
28
+ void settled.then(() => { if (locks.get(key) === settled) locks.delete(key) })
29
+ return next
30
+ }
31
+ const segment = /^[^/\\\0.][^/\\\0]{0,127}$/
32
+
33
+ /** A relative `.md` path inside a root: no `..`, no hidden segments, no backslashes. */
34
+ export function validPath(value: string): string {
35
+ const parts = typeof value === 'string' ? value.split('/') : []
36
+ if (!parts.length || value.length > 512 || !value.endsWith('.md') || !parts.every((part) => segment.test(part))) {
37
+ throw new InvalidError(`Invalid knowledge path: ${JSON.stringify(value)}`)
38
+ }
39
+ return value
40
+ }
41
+
42
+ /** What `authorize` sees as `record` for every knowledge operation, whether or not the file exists yet. */
43
+ export const knowledgeEntry = (root: string, path: string): Row => ({ id: `${root}/${path}`, root, path, version: 0, createdAt: '', updatedAt: '' })
44
+
45
+ /**
46
+ * Markdown files under trusted roots, versioned for Editor. Golem's own writes are serialized per
47
+ * file and compare the disk bytes against the version they were given right before the rename;
48
+ * an edit made on disk since the last read shows up as a new version. Editors that do not go through
49
+ * Golem are not locked out: one that writes in the instant between that check and the rename is overwritten.
50
+ */
51
+ export function knowledgeOperations(appRoot: string, roots: KnowledgeRoots): Operation[] {
52
+ if (!roots || typeof roots !== 'object' || Array.isArray(roots)) throw new Error('knowledge must map root names to directories')
53
+ for (const [name, directory] of Object.entries(roots)) {
54
+ validCollection(name)
55
+ if (typeof directory !== 'string' || !directory || directory.startsWith('/') || directory.split(/[/\\]/).includes('..')) {
56
+ throw new Error(`knowledge root ${name} must be a directory inside the app`)
57
+ }
58
+ }
59
+ // Policy: no symlink anywhere between the app root and a file, so a path is also the file's one identity.
60
+ async function base(root: string): Promise<string> {
61
+ if (!Object.hasOwn(roots, root)) throw new NotFoundError(`No knowledge root ${root}`)
62
+ const app = await realpath(appRoot)
63
+ const top = join(app, roots[root])
64
+ if (!(await unlinked(app, top))) throw new NotFoundError(`Knowledge root ${root} has no directory`)
65
+ return top
66
+ }
67
+
68
+ /** Absolute path of a file under the root; every folder on the way is a real directory, and the file is opened without following links. */
69
+ async function locate(root: string, path: string): Promise<string> {
70
+ const top = await base(root)
71
+ const file = join(top, validPath(path))
72
+ await unlinked(top, dirname(file))
73
+ return file
74
+ }
75
+
76
+ async function readDisk(file: string): Promise<Buffer | null> {
77
+ let handle
78
+ try {
79
+ handle = await open(file, constants.O_RDONLY | constants.O_NOFOLLOW)
80
+ } catch (error) {
81
+ const code = (error as NodeJS.ErrnoException).code
82
+ if (code === 'ENOENT') return null
83
+ if (code === 'ELOOP') throw new ForbiddenError('Knowledge files may not be symlinks')
84
+ throw error
85
+ }
86
+ try {
87
+ const stat = await handle.stat()
88
+ // A second hard link would be a second name for the same bytes, possibly outside the root.
89
+ if (!stat.isFile() || stat.nlink > 1) throw new ForbiddenError('Not a knowledge file')
90
+ const bytes = await handle.readFile()
91
+ if (bytes.byteLength > maxBytes) throw new InvalidError('Knowledge file is too large')
92
+ return bytes
93
+ } finally {
94
+ await handle.close()
95
+ }
96
+ }
97
+
98
+ /** The revision row for these bytes: a sha the store has not seen yet bumps the version. */
99
+ async function revision(records: RecordStore, root: string, path: string, bytes: Buffer): Promise<Row> {
100
+ const id = createHash('sha256').update(`${root}\0${path}`).digest('hex').slice(0, 40)
101
+ const sha256 = hash(bytes)
102
+ const row = await records.get(KNOWLEDGE_COLLECTION, id)
103
+ if (!row) return records.create(KNOWLEDGE_COLLECTION, { id, root, path, sha256 })
104
+ return row.sha256 === sha256 ? row : records.update(KNOWLEDGE_COLLECTION, id, { sha256 })
105
+ }
106
+
107
+ async function read(records: RecordStore, root: string, path: string): Promise<KnowledgeFile> {
108
+ const file = await locate(root, path)
109
+ return serial(file, async () => {
110
+ const bytes = await readDisk(file)
111
+ if (!bytes) throw new NotFoundError(`No knowledge file ${root}/${path}`)
112
+ return toFile(root, path, bytes, await revision(records, root, path, bytes))
113
+ })
114
+ }
115
+
116
+ async function write(records: RecordStore, root: string, path: string, body: string, expectedVersion: number): Promise<KnowledgeFile> {
117
+ const file = await locate(root, path)
118
+ return serial(file, async () => {
119
+ const before = await readDisk(file)
120
+ const current = before && await revision(records, root, path, before)
121
+ const conflict = async () => new VersionConflictError(`${root}/${path} changed since it was read`, before && current ? toFile(root, path, before, current) : null)
122
+ if (current ? current.version !== expectedVersion : expectedVersion !== 0) throw await conflict()
123
+ const bytes = Buffer.from(body, 'utf8')
124
+ if (bytes.byteLength > maxBytes) throw new InvalidError('Knowledge file is too large')
125
+ if (!before) await mkdirInside(await base(root), dirname(file))
126
+ const temporary = join(dirname(file), `.${randomUUID()}.golem-write`)
127
+ await writeFile(temporary, bytes, { flush: true, flag: 'wx' })
128
+ try {
129
+ if (!before) {
130
+ // link() refuses an existing name: a file created meanwhile is a conflict, not overwritten.
131
+ await link(temporary, file).catch(async (error) => { throw error.code === 'EEXIST' ? await conflict() : error })
132
+ } else {
133
+ const latest = await readDisk(file)
134
+ if (!latest || hash(latest) !== hash(before)) throw new VersionConflictError(`${root}/${path} changed on disk while saving`, latest ? toFile(root, path, latest, await revision(records, root, path, latest)) : null)
135
+ await rename(temporary, file)
136
+ }
137
+ } finally {
138
+ await unlink(temporary).catch(() => {})
139
+ }
140
+ return toFile(root, path, bytes, await revision(records, root, path, bytes))
141
+ })
142
+ }
143
+
144
+ async function* walk(top: string, folder: string): AsyncGenerator<string> {
145
+ const entries = await readdir(join(top, folder), { withFileTypes: true }).catch(() => [])
146
+ for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
147
+ if (entry.name.startsWith('.')) continue
148
+ const path = folder ? `${folder}/${entry.name}` : entry.name
149
+ // Symlinked entries are skipped, so a link never pulls outside content into a listing.
150
+ if (entry.isDirectory()) yield* walk(top, path)
151
+ else if (entry.isFile() && entry.name.endsWith('.md')) yield path
152
+ }
153
+ }
154
+
155
+ const root = z.string().min(1)
156
+ const path = z.string().transform((value, context) => {
157
+ try { return validPath(value) } catch (error) { context.addIssue({ code: 'custom', message: (error as Error).message }); return z.NEVER }
158
+ })
159
+ const entry = (input: { root: string; path: string }) => ({ row: knowledgeEntry(input.root, input.path) })
160
+ const fileRow = z.looseObject({ id: z.string(), root: z.string(), path: z.string(), version: z.number(), body: z.string() })
161
+ const listed = z.looseObject({ id: z.string(), root: z.string(), path: z.string(), version: z.number(), title: z.string(), type: z.string().nullable() })
162
+
163
+ // Folder paths are validated like file paths, minus the extension.
164
+ const folder = z.string().refine((value) => value === '' || value.split('/').every((part) => segment.test(part)), 'must be a relative folder')
165
+ async function files(input: { root: string; folder?: string }, permits: (row: Row) => Promise<boolean>) {
166
+ const top = await base(input.root)
167
+ if (input.folder && !(await unlinked(top, join(top, input.folder)))) return []
168
+ const found: string[] = []
169
+ for await (const one of walk(top, input.folder ?? '')) {
170
+ if (await permits(knowledgeEntry(input.root, one))) found.push(one)
171
+ }
172
+ return found
173
+ }
174
+
175
+ return [
176
+ defineOperation({
177
+ name: 'knowledge.list',
178
+ description: 'List markdown files in a knowledge root (optionally one folder), with their title, OKF type and version. Start here to find sources.',
179
+ input: z.object({ root, folder: folder.optional() }),
180
+ output: z.object({ rows: z.array(listed), nextCursor: z.null() }),
181
+ async run(input, { records, permits }) {
182
+ const rows = []
183
+ for (const one of await files(input, permits)) {
184
+ const file = await read(records, input.root, one).catch(() => null)
185
+ if (file) rows.push(summary(file))
186
+ }
187
+ return { rows, nextCursor: null }
188
+ },
189
+ }),
190
+ defineOperation({
191
+ name: 'knowledge.search',
192
+ description: 'Find lines containing some text across a knowledge root. Returns path, 1-based line number and that line, so an answer can cite its source.',
193
+ input: z.object({ root, text: z.string().min(2).max(200), limit: z.number().int().min(1).max(100).optional() }),
194
+ output: z.array(z.object({ path: z.string(), line: z.number(), text: z.string() })),
195
+ async run(input, { records, permits }) {
196
+ const needle = input.text.toLowerCase()
197
+ const hits: Array<{ path: string; line: number; text: string }> = []
198
+ for (const one of await files(input, permits)) {
199
+ const file = await read(records, input.root, one).catch(() => null)
200
+ file?.body.split('\n').forEach((text, index) => { if (text.toLowerCase().includes(needle)) hits.push({ path: one, line: index + 1, text: text.slice(0, 400) }) })
201
+ if (hits.length >= (input.limit ?? 30)) break
202
+ }
203
+ return hits.slice(0, input.limit ?? 30)
204
+ },
205
+ }),
206
+ defineOperation({
207
+ name: 'knowledge.read',
208
+ description: 'Read one markdown file: its whole text (frontmatter included) as `body`, and the `version` a write must send back.',
209
+ input: z.object({ root, path }), output: fileRow, record: entry,
210
+ run: (input, { records }) => read(records, input.root, input.path),
211
+ }),
212
+ defineOperation({
213
+ name: 'knowledge.write',
214
+ description: 'Replace one markdown file with `body`. Send the `version` you read (0 to create a new file); a file changed since then is refused with the current text.',
215
+ input: z.object({ root, path, body: z.string(), expectedVersion: z.number().int().min(0) }), output: fileRow, record: entry,
216
+ run: (input, { records }) => write(records, input.root, input.path, input.body, input.expectedVersion),
217
+ }),
218
+ ]
219
+ }
220
+
221
+ /** True when every step from `top` down to `target` exists as a real directory; a symlink on the way is refused. */
222
+ async function unlinked(top: string, target: string): Promise<boolean> {
223
+ let at = top
224
+ for (const part of relative(top, target).split(sep).filter(Boolean)) {
225
+ at = join(at, part)
226
+ const stat = await lstat(at).catch(() => null)
227
+ if (!stat) return false
228
+ if (!stat.isDirectory()) throw new ForbiddenError('Path leaves the knowledge root')
229
+ }
230
+ return true
231
+ }
232
+
233
+ async function mkdirInside(top: string, directory: string): Promise<void> {
234
+ const parts = relative(top, directory).split(sep).filter(Boolean)
235
+ let at = top
236
+ for (const part of parts) {
237
+ at = join(at, part)
238
+ const stat = await lstat(at).catch(() => null)
239
+ if (!stat) await mkdir(at).catch((error) => { if (error.code !== 'EEXIST') throw error })
240
+ else if (!stat.isDirectory()) throw new ForbiddenError('Path leaves the knowledge root')
241
+ }
242
+ }
243
+
244
+ const hash = (bytes: Uint8Array) => createHash('sha256').update(bytes).digest('hex')
245
+
246
+ function toFile(root: string, path: string, bytes: Buffer, revision: Row): KnowledgeFile {
247
+ const body = bytes.toString('utf8')
248
+ const meta = frontmatter(body)
249
+ const title = meta.title || /^#\s+(.+)$/m.exec(body)?.[1]?.trim() || path.split('/').pop()!.replace(/\.md$/, '')
250
+ return { id: path, root, path, body, sha256: revision.sha256 as string, type: meta.type || null, title, version: revision.version, createdAt: revision.createdAt, updatedAt: revision.updatedAt }
251
+ }
252
+
253
+ const summary = ({ body: _body, ...rest }: KnowledgeFile) => rest
254
+
255
+ /** Top-level scalar keys of a YAML frontmatter block; enough for OKF's `type`, `title` and `description`. */
256
+ function frontmatter(text: string): Record<string, string> {
257
+ const block = /^---\r?\n([\s\S]*?)\r?\n---\r?\n/.exec(text)?.[1]
258
+ const out: Record<string, string> = {}
259
+ for (const line of block?.split(/\r?\n/) ?? []) {
260
+ const match = /^([A-Za-z_][\w-]*):\s*(.*?)\s*$/.exec(line)
261
+ if (match && match[2]) out[match[1]] = match[2].replace(/^(['"])(.*)\1$/, '$2')
262
+ }
263
+ return out
264
+ }