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,269 @@
|
|
|
1
|
+
import { EventEmitter } from 'node:events'
|
|
2
|
+
import type { IncomingMessage } from 'node:http'
|
|
3
|
+
import {
|
|
4
|
+
anonymous, defineOperation, ForbiddenError, InvalidError, NotFoundError, UnauthorizedError, validCollection, z,
|
|
5
|
+
type Authorize, type FileStore, type JobContext, type Operation, type Principal, type RecordStore, type Row, type Via,
|
|
6
|
+
} from '../operations.ts'
|
|
7
|
+
import { FILES_COLLECTION } from './files.ts'
|
|
8
|
+
import { knowledgeOperations, type KnowledgeRoots } from './knowledge.ts'
|
|
9
|
+
import { createViews, type Views } from './views.ts'
|
|
10
|
+
import { createJobs, JOBS_CHANGE, type JobDefinition } from './jobs.ts'
|
|
11
|
+
import type { ModelConfig } from '../config.ts'
|
|
12
|
+
import { openModel } from './model.ts'
|
|
13
|
+
|
|
14
|
+
/** What an app's src/server/index.ts may default-export. Every field is optional. */
|
|
15
|
+
export type AppServerModule = {
|
|
16
|
+
operations?: Operation[]
|
|
17
|
+
/** Defaults to allowing everything: the local anonymous mode every existing app runs in. */
|
|
18
|
+
authorize?: Authorize
|
|
19
|
+
/** Trusted server-side identity for an HTTP request. Defaults to `anonymous`. */
|
|
20
|
+
resolvePrincipal?: (request: IncomingMessage) => Principal | Promise<Principal>
|
|
21
|
+
/** Markdown knowledge roots: name → directory relative to the app root. Adds the `knowledge.*` operations. */
|
|
22
|
+
knowledge?: KnowledgeRoots
|
|
23
|
+
/** Server-side runs of these operations, started or scheduled through the `jobs.*` operations. */
|
|
24
|
+
jobs?: JobDefinition[]
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type AgentTool = { name: string; description: string; inputSchema: unknown; call(input: unknown): Promise<unknown> }
|
|
28
|
+
|
|
29
|
+
export type App = {
|
|
30
|
+
readonly operations: Operation[]
|
|
31
|
+
/** Swaps in a new server module's operations and hooks; stores, change stream and callers stay. */
|
|
32
|
+
use(module: AppServerModule): void
|
|
33
|
+
invoke(name: string, input: unknown, principal: Principal, via: Via): Promise<unknown>
|
|
34
|
+
/** Stops job timers; call before closing the store. */
|
|
35
|
+
close(): void
|
|
36
|
+
/**
|
|
37
|
+
* The operations as tools for an in-process agent acting for `principal`. Each call first
|
|
38
|
+
* refreshes the principal, so a signed-out session or a changed role takes effect at once.
|
|
39
|
+
*/
|
|
40
|
+
agentTools(principal: Principal, context?: { owner: string; conversation: string; view?: string }): AgentTool[]
|
|
41
|
+
/** Session-scoped view actions: agents offer, the person accepts in one browser view. */
|
|
42
|
+
views: Views
|
|
43
|
+
resolvePrincipal(request: IncomingMessage): Promise<Principal>
|
|
44
|
+
/** Re-reads a principal from trusted state; throws once its session has ended. Identity for anonymous-only apps. */
|
|
45
|
+
refresh(principal: Principal): Promise<Principal>
|
|
46
|
+
/** Server-owned work acting for a stored account (a scheduled job): its current roles and groups. */
|
|
47
|
+
resolveAccount(id: string): Promise<Principal>
|
|
48
|
+
/** Emits `change` with a collection name after every record write, files included (`_files`). */
|
|
49
|
+
changes: EventEmitter
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const allowAll: Authorize = () => true
|
|
53
|
+
|
|
54
|
+
/** Where principals come from when the app has local accounts; replaces the module's `resolvePrincipal`. */
|
|
55
|
+
export type Identity = {
|
|
56
|
+
/** guests: false — anonymous callers are refused on every path, HTTP, agent and server alike. */
|
|
57
|
+
requireUser: boolean
|
|
58
|
+
resolve(request: IncomingMessage): Promise<Principal>
|
|
59
|
+
refresh(principal: Principal): Promise<Principal>
|
|
60
|
+
resolveAccount(id: string): Promise<Principal>
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function createApp(stores: { records: RecordStore; files: (records: RecordStore) => FileStore; root?: string; model?: ModelConfig }, module: AppServerModule = {}, identity?: Identity): App {
|
|
64
|
+
const changes = new EventEmitter().setMaxListeners(0)
|
|
65
|
+
const records = watched(stores.records, (collection) => changes.emit('change', collection))
|
|
66
|
+
const files = stores.files(records)
|
|
67
|
+
// Job state lives in reserved collections of the raw store: only the `_jobs` name reaches the change stream.
|
|
68
|
+
const jobs = createJobs({
|
|
69
|
+
store: stores.records,
|
|
70
|
+
definitions: () => current.module.jobs ?? [],
|
|
71
|
+
hasAccounts: Boolean(identity),
|
|
72
|
+
resolveAccount: (id) => resolveAccount(id),
|
|
73
|
+
invoke: (name, input, principal, job) => invoke(name, input, principal, 'server', job),
|
|
74
|
+
emit: () => changes.emit('change', JOBS_CHANGE),
|
|
75
|
+
})
|
|
76
|
+
const model = openModel(stores.model)
|
|
77
|
+
const load = (next: AppServerModule) => compile(next, jobs.operations, stores.root)
|
|
78
|
+
let current = load(module)
|
|
79
|
+
|
|
80
|
+
async function resolveAccount(id: string): Promise<Principal> {
|
|
81
|
+
if (!identity) throw new ForbiddenError('This app has no accounts')
|
|
82
|
+
return identity.resolveAccount(id)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function invoke(name: string, raw: unknown, principal: Principal, via: Via, job?: JobContext): Promise<unknown> {
|
|
86
|
+
const { byName, authorize } = current
|
|
87
|
+
if (identity?.requireUser && principal.kind === 'anonymous') throw new UnauthorizedError('Sign in to use this app.')
|
|
88
|
+
const operation = byName.get(name)
|
|
89
|
+
if (!operation) throw new NotFoundError(`Unknown operation: ${name}`)
|
|
90
|
+
const parsed = operation.input.safeParse(raw)
|
|
91
|
+
if (!parsed.success) throw new InvalidError(`${name}: ${z.prettifyError(parsed.error)}`)
|
|
92
|
+
const input = parsed.data
|
|
93
|
+
const target = operation.record?.(input)
|
|
94
|
+
const record = !target ? null : 'row' in target ? target.row : await records.get(target.collection, target.id)
|
|
95
|
+
const request = { operation: name, input, principal, via }
|
|
96
|
+
if (!(await authorize({ ...request, record }))) throw new ForbiddenError(`Not allowed: ${name}`)
|
|
97
|
+
const permits = async (row: Row) => Boolean(await authorize({ ...request, record: row }))
|
|
98
|
+
return operation.output.parse(await operation.run(input, { principal, via, records, files, model, permits, ...(job ? { job } : {}) }))
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const views = createViews({
|
|
102
|
+
invoke,
|
|
103
|
+
refresh: async (principal) => identity ? identity.refresh(principal) : principal,
|
|
104
|
+
has: (name) => current.byName.has(name),
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
get operations() { return [...current.byName.values()] },
|
|
109
|
+
invoke,
|
|
110
|
+
changes,
|
|
111
|
+
use(next) { current = load(next) },
|
|
112
|
+
close: () => jobs.close(),
|
|
113
|
+
resolvePrincipal: async (request) => identity ? identity.resolve(request) : (await current.module.resolvePrincipal?.(request)) ?? anonymous,
|
|
114
|
+
refresh: async (principal) => identity ? identity.refresh(principal) : principal,
|
|
115
|
+
resolveAccount,
|
|
116
|
+
views,
|
|
117
|
+
agentTools: (principal, context) => {
|
|
118
|
+
const fresh = async () => identity ? identity.refresh(principal) : principal
|
|
119
|
+
const tools: AgentTool[] = [...current.byName.values()].map((operation) => ({
|
|
120
|
+
name: operation.name,
|
|
121
|
+
description: operation.description,
|
|
122
|
+
inputSchema: z.toJSONSchema(operation.input, { unrepresentable: 'any' }),
|
|
123
|
+
call: async (input: unknown) => invoke(operation.name, input, await fresh(), 'agent'),
|
|
124
|
+
}))
|
|
125
|
+
if (!context || !views.actions().length) return tools
|
|
126
|
+
const { owner, conversation, view } = context
|
|
127
|
+
return [...tools, {
|
|
128
|
+
name: 'view.actions',
|
|
129
|
+
description: 'List what you can offer to show in the person\'s view of this conversation, with each action\'s input.',
|
|
130
|
+
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
|
|
131
|
+
call: async () => views.actions(),
|
|
132
|
+
}, {
|
|
133
|
+
name: 'view.request',
|
|
134
|
+
description: 'Offer one view action (see view.actions) in this conversation. The person accepts or dismisses it; nothing opens until they accept.',
|
|
135
|
+
inputSchema: { type: 'object', properties: { action: { type: 'string' }, input: { type: 'object' } }, required: ['action', 'input'], additionalProperties: false },
|
|
136
|
+
call: async (raw: unknown) => {
|
|
137
|
+
const { action, input } = (raw ?? {}) as { action?: unknown; input?: unknown }
|
|
138
|
+
if (typeof action !== 'string') throw new InvalidError('view.request needs an action name')
|
|
139
|
+
return views.request({ principal: await fresh(), owner, conversation, view }, action, input)
|
|
140
|
+
},
|
|
141
|
+
}]
|
|
142
|
+
},
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Validates a server module into the lookup `invoke` reads; throws before anything is swapped. */
|
|
147
|
+
function compile(module: AppServerModule, jobOperations: Operation[], root?: string) {
|
|
148
|
+
if (!module || typeof module !== 'object') throw new Error('src/server/index.ts must default-export an object')
|
|
149
|
+
if (module.knowledge !== undefined && !root) throw new Error('knowledge roots need the app root')
|
|
150
|
+
const knowledge = module.knowledge === undefined ? [] : knowledgeOperations(root!, module.knowledge)
|
|
151
|
+
for (const hook of ['authorize', 'resolvePrincipal'] as const) {
|
|
152
|
+
if (module[hook] !== undefined && typeof module[hook] !== 'function') throw new Error(`${hook} must be a function`)
|
|
153
|
+
}
|
|
154
|
+
for (const list of ['operations', 'jobs'] as const) {
|
|
155
|
+
if (module[list] !== undefined && !Array.isArray(module[list])) throw new Error(`${list} must be an array`)
|
|
156
|
+
}
|
|
157
|
+
const byName = new Map<string, Operation>()
|
|
158
|
+
for (const operation of [...builtins, ...jobOperations, ...knowledge, ...(module.operations ?? [])]) {
|
|
159
|
+
const schemas = [operation?.input, operation?.output].every((schema) => typeof (schema as { safeParse?: unknown })?.safeParse === 'function')
|
|
160
|
+
if (typeof operation?.name !== 'string' || !operation.name || typeof operation.run !== 'function' || !schemas) {
|
|
161
|
+
throw new Error(`Operation ${operation?.name ?? '(unnamed)'} needs a name, input and output schemas, and a run function`)
|
|
162
|
+
}
|
|
163
|
+
if (byName.has(operation.name)) throw new Error(`Operation ${operation.name} is defined twice`)
|
|
164
|
+
byName.set(operation.name, operation)
|
|
165
|
+
}
|
|
166
|
+
const jobNames = new Set<string>()
|
|
167
|
+
for (const job of module.jobs ?? []) {
|
|
168
|
+
if (typeof job?.name !== 'string' || !job.name || typeof job.description !== 'string') throw new Error(`Job ${job?.name ?? '(unnamed)'} needs a name and a description`)
|
|
169
|
+
if (jobNames.has(job.name)) throw new Error(`Job ${job.name} is defined twice`)
|
|
170
|
+
if (!byName.has(job.operation) || job.operation.startsWith('jobs.')) throw new Error(`Job ${job.name} runs ${job.operation}, which is not an app operation`)
|
|
171
|
+
if (job.missed !== undefined && job.missed !== 'skip' && job.missed !== 'once') throw new Error(`Job ${job.name}: missed must be 'skip' or 'once'`)
|
|
172
|
+
jobNames.add(job.name)
|
|
173
|
+
}
|
|
174
|
+
return { module, byName, authorize: module.authorize ?? allowAll }
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function watched(store: RecordStore, emit: (collection: string) => void): RecordStore {
|
|
178
|
+
const after = <T>(collection: string, result: Promise<T>) => result.then((value) => { emit(collection); return value })
|
|
179
|
+
return {
|
|
180
|
+
native: store.native,
|
|
181
|
+
list: (collection, query) => store.list(collection, query),
|
|
182
|
+
get: (collection, id) => store.get(collection, id),
|
|
183
|
+
create: (collection, data) => after(collection, store.create(collection, data)),
|
|
184
|
+
update: (collection, id, patch, options) => after(collection, store.update(collection, id, patch, options)),
|
|
185
|
+
remove: (collection, id) => after(collection, store.remove(collection, id)),
|
|
186
|
+
close: () => store.close(),
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Builtin operations: the records and files adapters, over the same invoke/authorize path as app operations.
|
|
191
|
+
const collection = z.string().refine((value) => !value.startsWith('_') && Boolean(validCollection(value)), 'must be a public collection name')
|
|
192
|
+
const id = z.string().min(1)
|
|
193
|
+
const bytes = z.custom<Uint8Array>((value) => value instanceof Uint8Array, 'must be bytes')
|
|
194
|
+
const row = z.looseObject({ id: z.string(), version: z.number() })
|
|
195
|
+
const scalar = z.union([z.string(), z.number(), z.boolean(), z.null()])
|
|
196
|
+
const query = z.object({
|
|
197
|
+
filter: z.record(z.string(), z.union([scalar, z.array(scalar)])).optional(),
|
|
198
|
+
sort: z.object({ field: z.string(), direction: z.enum(['asc', 'desc']) }).optional(),
|
|
199
|
+
search: z.object({ text: z.string(), fields: z.array(z.string()) }).optional(),
|
|
200
|
+
cursor: z.string().nullable().optional(),
|
|
201
|
+
limit: z.number().int().optional(),
|
|
202
|
+
})
|
|
203
|
+
const fileRef = z.object({ id: z.string(), name: z.string(), contentType: z.string(), size: z.number(), folder: z.string(), uploadedAt: z.string(), caption: z.string().optional() })
|
|
204
|
+
const fileRecord = (input: { id: string }) => ({ collection: FILES_COLLECTION, id: input.id })
|
|
205
|
+
|
|
206
|
+
const builtins: Operation[] = [
|
|
207
|
+
defineOperation({
|
|
208
|
+
name: 'records.list', description: 'List records in a collection with optional equality filter, sort, search and paging.',
|
|
209
|
+
input: z.object({ collection, query: query.optional() }), output: z.object({ rows: z.array(row), nextCursor: z.string().nullable() }),
|
|
210
|
+
async run(input, { records, permits }) {
|
|
211
|
+
const page = await records.list(input.collection, input.query)
|
|
212
|
+
const visible = await Promise.all(page.rows.map(permits))
|
|
213
|
+
// Hidden rows make a page short; nextCursor still continues correctly.
|
|
214
|
+
return { rows: page.rows.filter((_, index) => visible[index]), nextCursor: page.nextCursor }
|
|
215
|
+
},
|
|
216
|
+
}),
|
|
217
|
+
defineOperation({
|
|
218
|
+
name: 'records.get', description: 'Read one record by id; null when it does not exist.',
|
|
219
|
+
input: z.object({ collection, id }), output: row.nullable(), record: (input) => input,
|
|
220
|
+
run: (input, { records }) => records.get(input.collection, input.id),
|
|
221
|
+
}),
|
|
222
|
+
defineOperation({
|
|
223
|
+
name: 'records.create', description: 'Create a record. The store mints id, version, createdAt and updatedAt unless id is given.',
|
|
224
|
+
input: z.object({ collection, data: z.record(z.string(), z.unknown()) }), output: row,
|
|
225
|
+
run: (input, { records }) => records.create(input.collection, input.data),
|
|
226
|
+
}),
|
|
227
|
+
defineOperation({
|
|
228
|
+
name: 'records.update', description: 'Merge a patch into a record. With expectedVersion, a record changed since it was read is refused.',
|
|
229
|
+
input: z.object({ collection, id, patch: z.record(z.string(), z.unknown()), expectedVersion: z.number().int().optional(), versionField: z.string().optional() }),
|
|
230
|
+
output: row, record: (input) => input,
|
|
231
|
+
run: (input, { records }) => records.update(input.collection, input.id, input.patch,
|
|
232
|
+
input.expectedVersion === undefined ? undefined : { expectedVersion: input.expectedVersion, versionField: input.versionField }),
|
|
233
|
+
}),
|
|
234
|
+
defineOperation({
|
|
235
|
+
name: 'records.remove', description: 'Delete a record by id.',
|
|
236
|
+
input: z.object({ collection, id }), output: z.null(), record: (input) => input,
|
|
237
|
+
run: async (input, { records }) => { await records.remove(input.collection, input.id); return null },
|
|
238
|
+
}),
|
|
239
|
+
defineOperation({
|
|
240
|
+
name: 'files.list', description: 'List stored files in a folder, newest first.',
|
|
241
|
+
input: z.object({ folder: z.string() }), output: z.array(fileRef),
|
|
242
|
+
async run(input, { files, records, permits }) {
|
|
243
|
+
const refs = await files.list(input.folder)
|
|
244
|
+
const rows = await Promise.all(refs.map((ref) => records.get(FILES_COLLECTION, ref.id)))
|
|
245
|
+
const visible = await Promise.all(rows.map((one) => one ? permits(one) : false))
|
|
246
|
+
return refs.filter((_, index) => visible[index])
|
|
247
|
+
},
|
|
248
|
+
}),
|
|
249
|
+
defineOperation({
|
|
250
|
+
name: 'files.upload', description: 'Store bytes in a folder and return the file reference.',
|
|
251
|
+
input: z.object({ folder: z.string(), name: z.string(), contentType: z.string(), bytes: bytes }), output: fileRef,
|
|
252
|
+
run: (input, { files }) => files.put(input),
|
|
253
|
+
}),
|
|
254
|
+
defineOperation({
|
|
255
|
+
name: 'files.read', description: 'Read a stored file reference and its bytes.',
|
|
256
|
+
input: z.object({ id }), output: z.object({ ref: fileRef, bytes: bytes }), record: fileRecord,
|
|
257
|
+
run: (input, { files }) => files.read(input.id),
|
|
258
|
+
}),
|
|
259
|
+
defineOperation({
|
|
260
|
+
name: 'files.caption', description: 'Write the caption on a stored file.',
|
|
261
|
+
input: z.object({ id, caption: z.string().max(2000) }), output: fileRef, record: fileRecord,
|
|
262
|
+
run: (input, { files }) => files.caption(input.id, input.caption),
|
|
263
|
+
}),
|
|
264
|
+
defineOperation({
|
|
265
|
+
name: 'files.remove', description: 'Delete a stored file.',
|
|
266
|
+
input: z.object({ id }), output: z.null(), record: fileRecord,
|
|
267
|
+
run: async (input, { files }) => { await files.remove(input.id); return null },
|
|
268
|
+
}),
|
|
269
|
+
]
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto'
|
|
2
|
+
import { mkdirSync } from 'node:fs'
|
|
3
|
+
import { readFile, rename, unlink, writeFile } from 'node:fs/promises'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import { InvalidError, NotFoundError, validFolder, validId, type FileRef, type FileStore, type RecordStore, type Row } from '../operations.ts'
|
|
6
|
+
|
|
7
|
+
/** Record collection holding file metadata; the leading underscore keeps it off the public records routes. */
|
|
8
|
+
export const FILES_COLLECTION = '_files'
|
|
9
|
+
|
|
10
|
+
/** Bytes named by id under one root directory, so no caller-supplied string ever becomes a path. */
|
|
11
|
+
export function diskFiles(directory: string, records: RecordStore): FileStore {
|
|
12
|
+
mkdirSync(directory, { recursive: true })
|
|
13
|
+
const path = (id: string) => join(directory, validId(id))
|
|
14
|
+
const meta = async (id: string) => {
|
|
15
|
+
const row = await records.get(FILES_COLLECTION, id)
|
|
16
|
+
if (!row) throw new NotFoundError(`No file ${id}`)
|
|
17
|
+
return toRef(row)
|
|
18
|
+
}
|
|
19
|
+
return {
|
|
20
|
+
async put({ folder, name, contentType, bytes }) {
|
|
21
|
+
validFolder(folder)
|
|
22
|
+
if (!name || name.length > 255 || /[/\\\0]/.test(name)) throw new InvalidError(`Invalid file name: ${JSON.stringify(name)}`)
|
|
23
|
+
const id = randomUUID()
|
|
24
|
+
const temporary = `${path(id)}.upload`
|
|
25
|
+
await writeFile(temporary, bytes, { flush: true })
|
|
26
|
+
await rename(temporary, path(id))
|
|
27
|
+
try {
|
|
28
|
+
return toRef(await records.create(FILES_COLLECTION, { id, name, contentType: mediaType(contentType), size: bytes.byteLength, folder, uploadedAt: new Date().toISOString() }))
|
|
29
|
+
} catch (error) {
|
|
30
|
+
await unlink(path(id)).catch(() => {})
|
|
31
|
+
throw error
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
async read(id) {
|
|
35
|
+
const ref = await meta(id)
|
|
36
|
+
return { ref, bytes: await readFile(path(id)) }
|
|
37
|
+
},
|
|
38
|
+
async list(folder) {
|
|
39
|
+
const refs: FileRef[] = []
|
|
40
|
+
let cursor: string | null = null
|
|
41
|
+
do {
|
|
42
|
+
const found: Awaited<ReturnType<RecordStore['list']>> = await records.list(FILES_COLLECTION, { filter: { folder: validFolder(folder) }, sort: { field: 'uploadedAt', direction: 'desc' }, cursor, limit: 500 })
|
|
43
|
+
refs.push(...found.rows.map(toRef))
|
|
44
|
+
cursor = found.nextCursor
|
|
45
|
+
} while (cursor)
|
|
46
|
+
return refs
|
|
47
|
+
},
|
|
48
|
+
async caption(id, caption) {
|
|
49
|
+
await meta(id)
|
|
50
|
+
return toRef(await records.update(FILES_COLLECTION, id, { caption }))
|
|
51
|
+
},
|
|
52
|
+
async remove(id) {
|
|
53
|
+
await records.remove(FILES_COLLECTION, validId(id))
|
|
54
|
+
await unlink(path(id)).catch((error: NodeJS.ErrnoException) => { if (error.code !== 'ENOENT') throw error })
|
|
55
|
+
},
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function toRef(row: Row): FileRef {
|
|
60
|
+
const { id, name, contentType, size, folder, uploadedAt, caption } = row as Row & FileRef
|
|
61
|
+
return { id, name, contentType, size, folder, uploadedAt, ...(caption === undefined ? {} : { caption }) }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Only a bare `type/subtype` is kept; anything else is served as opaque bytes. */
|
|
65
|
+
function mediaType(value: string): string {
|
|
66
|
+
const type = value.split(';')[0].trim().toLowerCase()
|
|
67
|
+
return /^[\w.+-]+\/[\w.+-]+$/.test(type) ? type : 'application/octet-stream'
|
|
68
|
+
}
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
3
|
+
import { isAbsolute, join } from 'node:path'
|
|
4
|
+
import { pathToFileURL } from 'node:url'
|
|
5
|
+
import { loadAppConfig, type AppConfig } from '../config.ts'
|
|
6
|
+
import { build } from 'vite'
|
|
7
|
+
import { AppError, InvalidError, UnauthorizedError, type Principal, type RecordStore } from '../operations.ts'
|
|
8
|
+
import { createAccounts, type Accounts } from './accounts.ts'
|
|
9
|
+
import { createApp, type App, type AppServerModule } from './app.ts'
|
|
10
|
+
import { diskFiles } from './files.ts'
|
|
11
|
+
import { jsonlStore } from './jsonl.ts'
|
|
12
|
+
import { resolveSourceModule } from '../source-mode.ts'
|
|
13
|
+
|
|
14
|
+
const maxJson = 1_000_000
|
|
15
|
+
// Uploads are buffered in memory; stream to disk if apps need files past this size.
|
|
16
|
+
const maxUpload = 25_000_000
|
|
17
|
+
|
|
18
|
+
export type AppBackend = {
|
|
19
|
+
app: App
|
|
20
|
+
config: AppConfig
|
|
21
|
+
/** Present when golem.config.ts turns on local accounts. */
|
|
22
|
+
accounts?: Accounts
|
|
23
|
+
/** Serves /api/app/* and /api/auth/*. */
|
|
24
|
+
handle(request: IncomingMessage, response: ServerResponse): Promise<void>
|
|
25
|
+
/** The configured `origin` when set, else same-origin by Host; requests without Origin are not from a browser page. */
|
|
26
|
+
mutationAllowed(request: IncomingMessage): boolean
|
|
27
|
+
/** Where links handed out by this server point: the configured origin, else this Host authority. */
|
|
28
|
+
origin(authority?: string): string
|
|
29
|
+
/** Re-reads src/server/index.ts and its local imports; on failure the running module stays. */
|
|
30
|
+
reload(): Promise<void>
|
|
31
|
+
close(): Promise<void>
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Loads golem.config.ts storage and the optional app-owned src/server/index.ts, and serves /api/app/*. */
|
|
35
|
+
export async function createAppBackend(appRoot: string, dataDirectory: string): Promise<AppBackend> {
|
|
36
|
+
const config = await loadAppConfig(appRoot)
|
|
37
|
+
const { storage } = config
|
|
38
|
+
const records: RecordStore = storage === 'sqlite'
|
|
39
|
+
? (await import('./sqlite.ts')).sqliteStore(join(dataDirectory, 'records.sqlite'))
|
|
40
|
+
: await jsonlStore(join(dataDirectory, 'records'))
|
|
41
|
+
// One load at a time: each rebuilds the same output directory.
|
|
42
|
+
let loading: Promise<unknown> = Promise.resolve()
|
|
43
|
+
const load = () => {
|
|
44
|
+
const next = loading.then(() => loadServerModule(appRoot, join(dataDirectory, '..', 'server')))
|
|
45
|
+
loading = next.catch(() => {})
|
|
46
|
+
return next
|
|
47
|
+
}
|
|
48
|
+
// Accounts write the raw store, so nothing about them reaches the public change stream.
|
|
49
|
+
const accounts = config.accounts ? createAccounts(records, config.accounts) : undefined
|
|
50
|
+
const cookie = `${config.origin?.startsWith('https:') ? '__Host-' : ''}golem-session-${config.port}`
|
|
51
|
+
const identity = accounts && {
|
|
52
|
+
requireUser: !accounts.config.guests,
|
|
53
|
+
resolve: (request: IncomingMessage) => accounts.fromToken(readCookie(request, cookie)),
|
|
54
|
+
refresh: accounts.refresh,
|
|
55
|
+
resolveAccount: accounts.resolveAccount,
|
|
56
|
+
}
|
|
57
|
+
// FileStore writes metadata through the app's watched store, so file changes reach subscribers.
|
|
58
|
+
const app = createApp({ records, files: (watched) => diskFiles(join(dataDirectory, 'files'), watched), root: appRoot, ...(config.model ? { model: config.model } : {}) }, await load(), identity)
|
|
59
|
+
const server = { app, accounts, config, cookie }
|
|
60
|
+
return {
|
|
61
|
+
app,
|
|
62
|
+
config,
|
|
63
|
+
accounts,
|
|
64
|
+
handle: (request, response) => handle(server, request, response),
|
|
65
|
+
mutationAllowed: (request) => mutationAllowed(request, config.origin),
|
|
66
|
+
origin: (authority) => originOf(config, authority),
|
|
67
|
+
reload: async () => app.use(await load()),
|
|
68
|
+
close: () => { app.close(); return records.close() },
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
let generation = 0
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Bundles the app's own server files into one module and imports it under a fresh URL, so an edit
|
|
76
|
+
* to any local file is picked up. Packages stay external and shared with the running server.
|
|
77
|
+
*/
|
|
78
|
+
async function loadServerModule(appRoot: string, outDir: string): Promise<AppServerModule> {
|
|
79
|
+
const entry = join(appRoot, 'src/server/index.ts')
|
|
80
|
+
if (!existsSync(entry)) return {}
|
|
81
|
+
await build({
|
|
82
|
+
configFile: false, root: appRoot, logLevel: 'silent',
|
|
83
|
+
// Source mode: the app's `golem-kit/server` is the checkout's file, and stays external so the
|
|
84
|
+
// app and the running server share one module instance and `instanceof` still holds. A plugin
|
|
85
|
+
// rather than `resolve.alias` because rollup asks `external` about a bare specifier first.
|
|
86
|
+
plugins: [{ name: 'golem-source-modules', enforce: 'pre', resolveId: (source: string) => {
|
|
87
|
+
const file = resolveSourceModule(source)
|
|
88
|
+
return file ? { id: file, external: true } : undefined
|
|
89
|
+
} }],
|
|
90
|
+
build: {
|
|
91
|
+
ssr: entry, outDir, emptyOutDir: true, minify: false,
|
|
92
|
+
rollupOptions: { external: (id) => !resolveSourceModule(id) && !id.startsWith('.') && !isAbsolute(id) && !id.startsWith('\0'), output: { entryFileNames: 'index.mjs' } },
|
|
93
|
+
},
|
|
94
|
+
})
|
|
95
|
+
const loaded = (await import(`${pathToFileURL(join(outDir, 'index.mjs')).href}?generation=${++generation}`)).default as unknown
|
|
96
|
+
if (!loaded || typeof loaded !== 'object') throw new Error('src/server/index.ts must default-export an object')
|
|
97
|
+
return loaded as AppServerModule
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
type Server = { app: App; accounts?: Accounts; config: AppConfig; cookie: string }
|
|
101
|
+
|
|
102
|
+
async function handle({ app, accounts, config, cookie }: Server, request: IncomingMessage, response: ServerResponse): Promise<void> {
|
|
103
|
+
try {
|
|
104
|
+
const url = new URL(request.url ?? '/', 'http://127.0.0.1')
|
|
105
|
+
if (request.method !== 'GET' && !mutationAllowed(request, config.origin)) return send(response, 403, { error: 'Cross-origin mutations are not allowed' })
|
|
106
|
+
const principal = await app.resolvePrincipal(request)
|
|
107
|
+
if (url.pathname.startsWith('/api/auth/')) return await handleAuth({ app, accounts, config, cookie }, principal, request, response, url.pathname.slice('/api/auth/'.length))
|
|
108
|
+
// guests: false keeps every signed-out caller out of app data; guests: true sends them through authorize as anonymous.
|
|
109
|
+
if (accounts && !accounts.config.guests && principal.kind === 'anonymous') throw new UnauthorizedError('Sign in to use this app.')
|
|
110
|
+
const operation = url.pathname.match(/^\/api\/app\/operations\/([A-Za-z0-9_.-]+)$/)
|
|
111
|
+
if (request.method === 'POST' && operation) {
|
|
112
|
+
const text = (await read(request, maxJson)).toString('utf8')
|
|
113
|
+
let input: unknown
|
|
114
|
+
try { input = JSON.parse(text || '{}') } catch { return send(response, 400, { error: 'Request body must be valid JSON' }) }
|
|
115
|
+
return send(response, 200, { result: await app.invoke(operation[1], input, principal, 'http') })
|
|
116
|
+
}
|
|
117
|
+
if (request.method === 'PUT' && url.pathname === '/api/app/files') {
|
|
118
|
+
const bytes = await read(request, maxUpload)
|
|
119
|
+
const input = { folder: url.searchParams.get('folder') ?? '', name: url.searchParams.get('name') ?? '', contentType: request.headers['content-type'] ?? 'application/octet-stream', bytes }
|
|
120
|
+
return send(response, 201, { result: await app.invoke('files.upload', input, principal, 'http') })
|
|
121
|
+
}
|
|
122
|
+
const file = url.pathname.match(/^\/api\/app\/files\/([^/]+)$/)
|
|
123
|
+
if (request.method === 'GET' && file) {
|
|
124
|
+
const { ref, bytes } = await app.invoke('files.read', { id: decodeURIComponent(file[1]) }, principal, 'http') as { ref: { name: string; contentType: string }; bytes: Uint8Array }
|
|
125
|
+
response.writeHead(200, {
|
|
126
|
+
'Content-Type': ref.contentType,
|
|
127
|
+
'Content-Length': bytes.byteLength,
|
|
128
|
+
'Content-Disposition': `${url.searchParams.has('download') ? 'attachment' : 'inline'}; filename*=UTF-8''${encodeURIComponent(ref.name)}`,
|
|
129
|
+
// Uploaded bytes are untrusted: never let them run as a page on this origin.
|
|
130
|
+
'Content-Security-Policy': 'sandbox',
|
|
131
|
+
'X-Content-Type-Options': 'nosniff',
|
|
132
|
+
'Cache-Control': 'no-store',
|
|
133
|
+
})
|
|
134
|
+
response.end(bytes)
|
|
135
|
+
return
|
|
136
|
+
}
|
|
137
|
+
if (request.method === 'POST' && url.pathname === '/api/app/views') {
|
|
138
|
+
const { conversation } = await readJson(request) as { conversation?: unknown }
|
|
139
|
+
return send(response, 201, { result: await app.views.open(request, principal, conversation as string) })
|
|
140
|
+
}
|
|
141
|
+
const view = url.pathname.match(/^\/api\/app\/views\/([A-Za-z0-9_-]+)(\/answer)?$/)
|
|
142
|
+
if (view && request.method === 'POST' && view[2]) {
|
|
143
|
+
const { offer, accept } = await readJson(request) as { offer?: unknown; accept?: unknown }
|
|
144
|
+
if (typeof offer !== 'string' || typeof accept !== 'boolean') throw new InvalidError('An answer needs { offer, accept }')
|
|
145
|
+
await app.views.answer(request, principal, view[1], offer, accept)
|
|
146
|
+
return send(response, 200, { result: null })
|
|
147
|
+
}
|
|
148
|
+
if (view && request.method === 'GET' && !view[2]) {
|
|
149
|
+
// Throws before any header when this view is not the caller's; events only arrive on later ticks.
|
|
150
|
+
const close = await app.views.connect(request, principal, view[1], (event) => response.write(`data: ${JSON.stringify(event)}\n\n`))
|
|
151
|
+
response.writeHead(200, { 'Content-Type': 'text/event-stream; charset=utf-8', 'Cache-Control': 'no-cache', Connection: 'keep-alive' })
|
|
152
|
+
response.flushHeaders()
|
|
153
|
+
// A signed-out or removed reader loses the view at once.
|
|
154
|
+
const recheck = (accountId: string) => {
|
|
155
|
+
if (principal.kind === 'user' && accountId === principal.id) void app.refresh(principal).catch(() => response.end())
|
|
156
|
+
}
|
|
157
|
+
accounts?.changes.on('change', recheck)
|
|
158
|
+
request.on('close', () => { close(); accounts?.changes.off('change', recheck) })
|
|
159
|
+
return
|
|
160
|
+
}
|
|
161
|
+
if (request.method === 'GET' && url.pathname === '/api/app/changes') {
|
|
162
|
+
response.writeHead(200, { 'Content-Type': 'text/event-stream; charset=utf-8', 'Cache-Control': 'no-cache', Connection: 'keep-alive' })
|
|
163
|
+
response.flushHeaders()
|
|
164
|
+
const write = (collection: string) => response.write(`data: ${JSON.stringify({ collection })}\n\n`)
|
|
165
|
+
// When this reader's account changes, re-resolve the same request: a signed-out reader
|
|
166
|
+
// loses the stream, everyone else is told to re-read who they are and what they may see.
|
|
167
|
+
const recheck = (accountId: string) => {
|
|
168
|
+
if (principal.kind !== 'user' || accountId !== principal.id) return
|
|
169
|
+
void app.resolvePrincipal(request).then((now) => {
|
|
170
|
+
// Tell the page first, so a signed-out tab drops what it shows instead of waiting for a reload.
|
|
171
|
+
response.write(`data: ${JSON.stringify({ identity: true })}\n\n`)
|
|
172
|
+
if (now.kind === 'anonymous' && !accounts?.config.guests) response.end()
|
|
173
|
+
}, () => response.end())
|
|
174
|
+
}
|
|
175
|
+
app.changes.on('change', write)
|
|
176
|
+
accounts?.changes.on('change', recheck)
|
|
177
|
+
request.on('close', () => { app.changes.off('change', write); accounts?.changes.off('change', recheck) })
|
|
178
|
+
return
|
|
179
|
+
}
|
|
180
|
+
send(response, 404, { error: 'Unknown API route' })
|
|
181
|
+
} catch (error) {
|
|
182
|
+
const status = error instanceof Error ? statuses[error.name] ?? (error instanceof AppError && error.status === 413 ? 413 : undefined) : undefined
|
|
183
|
+
if (status) {
|
|
184
|
+
const { current, fields } = error as Error & { current?: unknown; fields?: unknown }
|
|
185
|
+
return send(response, status, { error: (error as Error).message, name: (error as Error).name, current, fields })
|
|
186
|
+
}
|
|
187
|
+
console.error(error)
|
|
188
|
+
send(response, 500, { error: 'The operation failed on the server; see the server log.' })
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async function handleAuth({ accounts, config, cookie }: Server, principal: Principal, request: IncomingMessage, response: ServerResponse, route: string): Promise<void> {
|
|
193
|
+
if (request.method === 'GET' && route === 'me') {
|
|
194
|
+
const settings = accounts && { guests: accounts.config.guests, allowSignUp: accounts.config.allowSignUp, roles: accounts.config.roles }
|
|
195
|
+
return send(response, 200, { result: { user: accounts ? await accounts.me(principal) : null, canBuild: accounts ? accounts.canBuild(principal) : true, accounts: settings ?? null } })
|
|
196
|
+
}
|
|
197
|
+
if (!accounts) return send(response, 404, { error: 'This app has no accounts' })
|
|
198
|
+
const secure = config.origin?.startsWith('https:') ? '; Secure' : ''
|
|
199
|
+
const session = (token: string, maxAge: number) => ({ 'Set-Cookie': `${cookie}=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAge}${secure}` })
|
|
200
|
+
if (request.method === 'GET' && route === 'members') return send(response, 200, { result: await accounts.members(principal) })
|
|
201
|
+
if (request.method !== 'POST') return send(response, 404, { error: 'Unknown API route' })
|
|
202
|
+
const input = await readJson(request)
|
|
203
|
+
if (route === 'sign-in' || route === 'sign-up') {
|
|
204
|
+
const { user, token } = route === 'sign-in' ? await accounts.signIn(input, request.socket.remoteAddress ?? 'unknown') : await accounts.signUp(input)
|
|
205
|
+
return send(response, 200, { result: user }, session(token, 14 * 86_400))
|
|
206
|
+
}
|
|
207
|
+
if (route === 'sign-out') {
|
|
208
|
+
await accounts.signOut(principal)
|
|
209
|
+
return send(response, 200, { result: null }, session('', 0))
|
|
210
|
+
}
|
|
211
|
+
if (route === 'invites') return send(response, 200, { result: await accounts.invite(principal, input, originOf(config, request.headers.host)) })
|
|
212
|
+
const member = route.match(/^members\/([^/]+)\/(role|groups|remove)$/)
|
|
213
|
+
if (!member) return send(response, 404, { error: 'Unknown API route' })
|
|
214
|
+
const id = decodeURIComponent(member[1])
|
|
215
|
+
if (member[2] === 'role') await accounts.setRole(principal, id, input)
|
|
216
|
+
else if (member[2] === 'groups') await accounts.setGroups(principal, id, input)
|
|
217
|
+
else await accounts.remove(principal, id)
|
|
218
|
+
send(response, 200, { result: null })
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async function readJson(request: IncomingMessage): Promise<unknown> {
|
|
222
|
+
const text = (await read(request, maxJson)).toString('utf8')
|
|
223
|
+
try { return JSON.parse(text || '{}') } catch { throw new InvalidError('Request body must be valid JSON') }
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export function readCookie(request: IncomingMessage, name: string): string | undefined {
|
|
227
|
+
for (const part of (request.headers.cookie ?? '').split(';')) {
|
|
228
|
+
const [key, ...value] = part.trim().split('=')
|
|
229
|
+
if (key === name) return value.join('=') || undefined
|
|
230
|
+
}
|
|
231
|
+
return undefined
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function originOf(config: AppConfig, authority?: string): string {
|
|
235
|
+
if (config.origin) return config.origin
|
|
236
|
+
try { if (authority) return new URL(`http://${authority}`).origin } catch {}
|
|
237
|
+
return new URL(`http://${config.host.includes(':') ? `[${config.host}]` : config.host}:${config.port}`).origin
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// By name, not class: app code bundled at reload has its own copies of these error classes.
|
|
241
|
+
const statuses: Record<string, number> = { UnauthorizedError: 401, RateLimitedError: 429, InvalidError: 400, ForbiddenError: 403, NotFoundError: 404, VersionConflictError: 409, RecordRefusedError: 422 }
|
|
242
|
+
|
|
243
|
+
export function mutationAllowed(request: IncomingMessage, configured?: string): boolean {
|
|
244
|
+
const origin = request.headers.origin
|
|
245
|
+
if (!origin) return true
|
|
246
|
+
// Behind a proxy the Host header is the proxy's business, so a configured origin is the only one accepted.
|
|
247
|
+
if (configured) return origin === configured
|
|
248
|
+
const authority = request.headers.host
|
|
249
|
+
if (!authority) return false
|
|
250
|
+
try {
|
|
251
|
+
return origin === new URL(origin).origin && origin === new URL(`http://${authority}`).origin
|
|
252
|
+
} catch {
|
|
253
|
+
return false
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
async function read(request: IncomingMessage, limit: number): Promise<Buffer> {
|
|
258
|
+
const chunks: Buffer[] = []
|
|
259
|
+
let size = 0
|
|
260
|
+
for await (const chunk of request as AsyncIterable<Buffer>) {
|
|
261
|
+
size += chunk.length
|
|
262
|
+
if (size > limit) throw Object.assign(new AppError(`Request body is larger than ${limit} bytes`), { status: 413 })
|
|
263
|
+
chunks.push(chunk)
|
|
264
|
+
}
|
|
265
|
+
return Buffer.concat(chunks)
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function send(response: ServerResponse, status: number, body: unknown, headers: Record<string, string> = {}): void {
|
|
269
|
+
if (response.headersSent) { response.destroy(); return }
|
|
270
|
+
response.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store', ...headers })
|
|
271
|
+
// Bytes cross JSON as base64, so an HTTP caller of files.read gets them intact.
|
|
272
|
+
response.end(JSON.stringify(body, function (this: Record<string, unknown>, key, value) {
|
|
273
|
+
const raw = this[key]
|
|
274
|
+
return raw instanceof Uint8Array ? Buffer.from(raw).toString('base64') : value
|
|
275
|
+
}))
|
|
276
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// golem-kit/server: server-only. Import from an app's src/server/ only.
|
|
2
|
+
export { createApp, type AgentTool, type App, type AppServerModule } from './app.ts'
|
|
3
|
+
export { diskFiles } from './files.ts'
|
|
4
|
+
export type { JobDefinition } from './jobs.ts'
|
|
5
|
+
export { jsonlStore } from './jsonl.ts'
|
|
6
|
+
export { knowledgeOperations, validPath, type KnowledgeFile, type KnowledgeRoots } from './knowledge.ts'
|
|
7
|
+
export { openModel } from './model.ts'
|
|
8
|
+
export type { Conversations, ViewActionDoc, ViewBinding, ViewEvent, ViewOffer, Views } from './views.ts'
|
|
9
|
+
export { sqliteStore } from './sqlite.ts'
|
|
10
|
+
export * from '../operations.ts'
|