@roj-ai/kompjutr-platform 0.0.0 → 0.2.10

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 (57) hide show
  1. package/README.md +114 -1
  2. package/dist/event-store.d.ts +48 -0
  3. package/dist/event-store.d.ts.map +1 -0
  4. package/dist/event-store.js +206 -0
  5. package/dist/event-store.js.map +1 -0
  6. package/dist/fs.d.ts +19 -0
  7. package/dist/fs.d.ts.map +1 -0
  8. package/dist/fs.js +200 -0
  9. package/dist/fs.js.map +1 -0
  10. package/dist/git.d.ts +12 -0
  11. package/dist/git.d.ts.map +1 -0
  12. package/dist/git.js +70 -0
  13. package/dist/git.js.map +1 -0
  14. package/dist/index.d.ts +36 -0
  15. package/dist/index.d.ts.map +1 -0
  16. package/dist/index.js +57 -0
  17. package/dist/index.js.map +1 -0
  18. package/dist/llm-call-log.d.ts +23 -0
  19. package/dist/llm-call-log.d.ts.map +1 -0
  20. package/dist/llm-call-log.js +135 -0
  21. package/dist/llm-call-log.js.map +1 -0
  22. package/dist/llm-call-payloads.d.ts +12 -0
  23. package/dist/llm-call-payloads.d.ts.map +1 -0
  24. package/dist/llm-call-payloads.js +77 -0
  25. package/dist/llm-call-payloads.js.map +1 -0
  26. package/dist/process.d.ts +14 -0
  27. package/dist/process.d.ts.map +1 -0
  28. package/dist/process.js +25 -0
  29. package/dist/process.js.map +1 -0
  30. package/dist/session-log.d.ts +22 -0
  31. package/dist/session-log.d.ts.map +1 -0
  32. package/dist/session-log.js +65 -0
  33. package/dist/session-log.js.map +1 -0
  34. package/dist/shell.d.ts +11 -0
  35. package/dist/shell.d.ts.map +1 -0
  36. package/dist/shell.js +36 -0
  37. package/dist/shell.js.map +1 -0
  38. package/dist/testing/index.d.ts +2 -0
  39. package/dist/testing/index.d.ts.map +1 -0
  40. package/dist/testing/index.js +2 -0
  41. package/dist/testing/index.js.map +1 -0
  42. package/dist/testing/storage.d.ts +29 -0
  43. package/dist/testing/storage.d.ts.map +1 -0
  44. package/dist/testing/storage.js +132 -0
  45. package/dist/testing/storage.js.map +1 -0
  46. package/package.json +44 -4
  47. package/src/event-store.ts +250 -0
  48. package/src/fs.ts +232 -0
  49. package/src/git.ts +74 -0
  50. package/src/index.ts +83 -0
  51. package/src/llm-call-log.ts +204 -0
  52. package/src/llm-call-payloads.ts +95 -0
  53. package/src/process.ts +28 -0
  54. package/src/session-log.ts +79 -0
  55. package/src/shell.ts +49 -0
  56. package/src/testing/index.ts +1 -0
  57. package/src/testing/storage.ts +141 -0
package/src/fs.ts ADDED
@@ -0,0 +1,232 @@
1
+ /**
2
+ * `FileSystem` port over a kompjutr workspace.
3
+ *
4
+ * kompjutr ships a node:fs-shaped shim (`NodeFsCompat`) over its own bulk-first
5
+ * `Filesystem`, and the shim already answers the port's optional verbs in the
6
+ * shape the port asks for. So most of this file forwards. What it does not
7
+ * forward is what the shim does not do, or does with different semantics than
8
+ * the port states — `appendFile`, a recursive `cp`, and a walk's symlink sizes.
9
+ */
10
+
11
+ import type { Filesystem, NodeFsCompat, Stat } from '@kompjutr/do'
12
+ import type { FileSystem, ReadableFileHandle, ReadFilesEntry, WalkEntry, WalkOptions, WriteFilesEntry, WriteFilesOptions } from '@roj-ai/sdk/platform'
13
+
14
+ /** Pages a subtree scan. kompjutr caps a page at 1,000 rows. */
15
+ const SCAN_PAGE = 1_000
16
+
17
+ /** What `Filesystem.copyFiles` accepts in one call. */
18
+ const COPY_BATCH = 1_000
19
+
20
+ export interface KompjutrFileSystemOptions {
21
+ /** The node:fs-shaped shim. `Workspace.fs`. */
22
+ compat: NodeFsCompat
23
+ /** The bulk API underneath it, over the same database. `Workspace.filesystem`. */
24
+ filesystem: Filesystem
25
+ }
26
+
27
+ function toBytes(data: string | Uint8Array): Uint8Array {
28
+ return typeof data === 'string' ? new TextEncoder().encode(data) : data
29
+ }
30
+
31
+ function fileError(code: string, path: string, message: string): Error {
32
+ return Object.assign(new Error(`${message}: ${path}`), { code, path })
33
+ }
34
+
35
+ /** The port's `type`, which names a directory in full and admits things kompjutr cannot produce. */
36
+ function walkType(type: Stat['type']): WalkEntry['type'] {
37
+ if (type === 'dir') return 'directory'
38
+ return type
39
+ }
40
+
41
+ export function createKompjutrFileSystem(options: KompjutrFileSystemOptions): FileSystem {
42
+ const { compat, filesystem } = options
43
+
44
+ /** Every path under `root`, parents before children. Excludes `root` itself. */
45
+ const scanSubtree = (root: string): string[] => {
46
+ const paths: string[] = []
47
+ let after: string | undefined
48
+ for (;;) {
49
+ const page = filesystem.scan(root, { after, limit: SCAN_PAGE })
50
+ if (page.length === 0) return paths
51
+ for (const entry of page) paths.push(entry.path)
52
+ if (page.length < SCAN_PAGE) return paths
53
+ after = page[page.length - 1]?.path
54
+ }
55
+ }
56
+
57
+ /** Copies inside SQLite, so file bytes never enter the isolate. */
58
+ const copyEntries = (entries: { source: string; destination: string }[], force = true): void => {
59
+ let pending = entries.filter(({ source, destination }) => {
60
+ const sourceStat = filesystem.stat(source)
61
+ const destinationStat = filesystem.stat(destination)
62
+ if (sourceStat !== null && destinationStat !== null && sourceStat.ino === destinationStat.ino) {
63
+ throw fileError('ERR_FS_CP_EINVAL', destination, 'source and destination are the same file')
64
+ }
65
+ if (sourceStat?.type === 'dir' && destinationStat !== null && destinationStat.type !== 'dir') {
66
+ throw fileError('ERR_FS_CP_DIR_TO_NON_DIR', destination, 'cannot overwrite a non-directory with a directory')
67
+ }
68
+ if (sourceStat?.type !== 'dir' && destinationStat?.type === 'dir') {
69
+ throw fileError('ERR_FS_CP_NON_DIR_TO_DIR', destination, 'cannot overwrite a directory with a non-directory')
70
+ }
71
+ return force || destinationStat === null || sourceStat?.type === 'dir'
72
+ })
73
+ while (pending.length > 0) {
74
+ const batch = filesystem.copyFiles(pending.slice(0, COPY_BATCH), {
75
+ parents: true,
76
+ })
77
+ const deferred = [...batch.remaining, ...pending.slice(COPY_BATCH)]
78
+ if (deferred.length >= pending.length) throw fileError('EIO', pending[0]?.source ?? '', 'copy made no progress')
79
+ pending = deferred
80
+ }
81
+ }
82
+
83
+ const remove = (path: string, rmOptions?: { recursive?: boolean; force?: boolean }): void => {
84
+ if (!rmOptions?.recursive && filesystem.stat(path)?.type === 'dir') {
85
+ throw fileError('ERR_FS_EISDIR', path, 'is a directory')
86
+ }
87
+ compat.rmSync(path, rmOptions)
88
+ }
89
+
90
+ return {
91
+ // Every method here is `async`, and deliberately so: kompjutr's shim reaches
92
+ // its synchronous core through `Promise.resolve(xSync(...))`, so a failure
93
+ // throws where the port says it rejects. `async` puts that back.
94
+ readFile: (async (path: string, encoding?: 'utf-8' | 'utf8') =>
95
+ encoding ? compat.readFileSync(path, { encoding }) : compat.readFileSync(path)) as FileSystem['readFile'],
96
+
97
+ writeFile: async (path, data) => {
98
+ compat.writeFileSync(path, data)
99
+ },
100
+
101
+ // The shim's own appendFile is ENOSYS; write at the current end instead.
102
+ appendFile: async (path, data) => {
103
+ const stat = filesystem.statTarget(path)
104
+ if (stat === null) {
105
+ compat.writeFileSync(path, data)
106
+ return
107
+ }
108
+ filesystem.writeRange(path, toBytes(data), stat.size)
109
+ },
110
+
111
+ mkdir: async (path, mkdirOptions) => {
112
+ compat.mkdirSync(path, mkdirOptions)
113
+ },
114
+
115
+ readdir: (async (path: string, readdirOptions?: { withFileTypes: true }) =>
116
+ readdirOptions?.withFileTypes ? compat.readdirSync(path, { withFileTypes: true }) : compat.readdirSync(path)) as FileSystem['readdir'],
117
+
118
+ stat: async (path) => compat.statSync(path),
119
+ lstat: async (path) => compat.lstatSync(path),
120
+ access: async (path, mode) => {
121
+ compat.accessSync(path, mode)
122
+ },
123
+
124
+ unlink: async (path) => {
125
+ compat.unlinkSync(path)
126
+ },
127
+
128
+ rm: async (path, rmOptions) => {
129
+ remove(path, rmOptions)
130
+ },
131
+
132
+ cp: async (source, dest, cpOptions) => {
133
+ const stat = filesystem.stat(source)
134
+ if (stat === null) throw fileError('ENOENT', source, 'no such file or directory')
135
+ if (stat.type !== 'dir') {
136
+ copyEntries([{ source, destination: dest }], cpOptions?.force)
137
+ return
138
+ }
139
+ if (!cpOptions?.recursive) throw fileError('EISDIR', source, 'is a directory')
140
+ const root = filesystem.realpath(source)
141
+ copyEntries(
142
+ [
143
+ { source: root, destination: dest },
144
+ ...scanSubtree(root).map((path) => ({
145
+ source: path,
146
+ destination: `${dest}${path.slice(root.length)}`,
147
+ })),
148
+ ],
149
+ cpOptions?.force,
150
+ )
151
+ },
152
+
153
+ // The shim exposes fds, not handles; wrap one in the read subset the port uses.
154
+ open: async (path, flags): Promise<ReadableFileHandle> => {
155
+ const fd = compat.openSync(path, flags ?? 'r')
156
+ return {
157
+ stat: async () => compat.fstatSync(fd),
158
+ read: async (buffer, offset, length, position) => ({
159
+ bytesRead: compat.readSync(fd, buffer, offset, length, position),
160
+ buffer,
161
+ }),
162
+ close: async () => {
163
+ compat.closeSync(fd)
164
+ },
165
+ }
166
+ },
167
+
168
+ exists: async (path) => compat.existsSync(path),
169
+ realpath: async (path) => compat.realpathSync(path),
170
+
171
+ walk: async (dir: string, walkOptions?: WalkOptions): Promise<WalkEntry[]> => {
172
+ const entries = await compat.walk(dir, walkOptions)
173
+ return entries.map((entry) => ({
174
+ path: entry.path,
175
+ type: walkType(entry.type),
176
+ // The shim reports a symlink's own size; the port wants the target's,
177
+ // because the readdir-and-stat loop it replaces follows the link.
178
+ size: entry.type === 'symlink' ? (filesystem.statTarget(entry.path)?.size ?? 0) : entry.size,
179
+ mtime: entry.mtime,
180
+ }))
181
+ },
182
+
183
+ readFiles: async (paths: readonly string[]): Promise<ReadFilesEntry[]> => {
184
+ try {
185
+ const entries = await compat.readFiles(paths)
186
+ return entries.map((entry, index) => ({
187
+ path: paths[index] ?? entry.path,
188
+ ...(entry.content === undefined ? {} : { content: Buffer.from(entry.content) }),
189
+ ...(entry.error === undefined ? {} : { error: entry.error }),
190
+ }))
191
+ } catch (error) {
192
+ if (!(error instanceof Error) || !('code' in error) || (error.code !== 'ENOTDIR' && error.code !== 'ELOOP')) throw error
193
+ // Path resolution can abort the upstream batch before it reports individual failures.
194
+ return Promise.all(
195
+ paths.map(async (path): Promise<ReadFilesEntry> => {
196
+ try {
197
+ const [entry] = await compat.readFiles([path])
198
+ if (!entry) throw new Error('readFiles returned no entry')
199
+ return {
200
+ path,
201
+ ...(entry.content === undefined ? {} : { content: Buffer.from(entry.content) }),
202
+ ...(entry.error === undefined ? {} : { error: entry.error }),
203
+ }
204
+ } catch (entryError) {
205
+ if (entryError instanceof Error && 'code' in entryError && typeof entryError.code === 'string') {
206
+ return { path, error: entryError.code }
207
+ }
208
+ throw entryError
209
+ }
210
+ }),
211
+ )
212
+ }
213
+ },
214
+
215
+ // The bulk primitive replaces final links; resolve them as single writes do.
216
+ writeFiles: async (entries: readonly WriteFilesEntry[], writeOptions?: WriteFilesOptions) => {
217
+ filesystem.writeFiles(
218
+ entries.map((entry) => ({
219
+ path: filesystem.realpath(entry.path),
220
+ bytes: toBytes(entry.content),
221
+ })),
222
+ { parents: writeOptions?.createParents ?? false },
223
+ )
224
+ },
225
+
226
+ rmFiles: async (paths: readonly string[], rmOptions?: { recursive?: boolean; force?: boolean }) => {
227
+ for (const path of paths) remove(path, rmOptions)
228
+ },
229
+
230
+ scopeReads: (fn) => filesystem.withReadScope(fn),
231
+ }
232
+ }
package/src/git.ts ADDED
@@ -0,0 +1,74 @@
1
+ /**
2
+ * `GitClient` port over kompjutr's git runtime.
3
+ *
4
+ * The port names four questions `git-status` asks, and kompjutr answers each
5
+ * one directly rather than by walking a tree: status reads its index tracker,
6
+ * and `divergence` counts both sides of a fork in one call. Two of the four had
7
+ * no honest answer over the binding this replaces.
8
+ */
9
+
10
+ import type { GitClient, GitCommit, GitCountAheadOptions, GitLogOptions, GitRepoOptions, GitStatusEntry } from '@roj-ai/sdk/platform'
11
+ import type { Git, StatusEntry } from '@kompjutr/do'
12
+
13
+ /**
14
+ * kompjutr's ordinary codes are already the port's alphabet. Three are not:
15
+ * a rename is an add at the new path, an unmerged path is a modification, and
16
+ * an ignored one is not a change anybody asked about.
17
+ */
18
+ function toStatusEntry(entry: StatusEntry): GitStatusEntry | undefined {
19
+ if (entry.index === '!' || entry.worktree === '!') return undefined
20
+ const index = entry.index === 'R' ? 'A' : entry.index === 'U' ? 'M' : entry.index
21
+ const worktree = entry.worktree === 'U' ? 'M' : entry.worktree
22
+ return { path: entry.path, index, worktree }
23
+ }
24
+
25
+ export function createKompjutrGitClient(git: Git): GitClient {
26
+ return {
27
+ async status(options: GitRepoOptions): Promise<GitStatusEntry[]> {
28
+ const entries = await git.status({ dir: options.dir })
29
+ return entries.map(toStatusEntry).filter((entry): entry is GitStatusEntry => entry !== undefined)
30
+ },
31
+
32
+ async log(options: GitLogOptions): Promise<GitCommit[]> {
33
+ const commits = await git.log({
34
+ dir: options.dir,
35
+ ref: options.ref,
36
+ depth: options.depth,
37
+ })
38
+ return commits.map((commit) => ({
39
+ oid: commit.oid,
40
+ message: commit.message,
41
+ // Git counts seconds; every SDK timestamp is milliseconds.
42
+ committedAt: commit.committer.timestamp * 1000,
43
+ }))
44
+ },
45
+
46
+ async countAhead(options: GitCountAheadOptions): Promise<number> {
47
+ if (options.missingBase === 'all' && (await git.tryRevParse({ dir: options.dir, ref: options.base })) === undefined) {
48
+ const commits = await git.log({ dir: options.dir, ref: options.ref ?? 'HEAD' })
49
+ return commits.length
50
+ }
51
+ const result = await git.divergence({
52
+ dir: options.dir,
53
+ current: options.ref ?? 'HEAD',
54
+ upstream: options.base,
55
+ })
56
+ return result.ahead
57
+ },
58
+
59
+ async defaultBranch(options: GitRepoOptions): Promise<string | undefined> {
60
+ try {
61
+ const ref = await git.readRef({
62
+ dir: options.dir,
63
+ ref: 'refs/remotes/origin/HEAD',
64
+ })
65
+ if (ref.kind !== 'symbolic') return undefined
66
+ const prefix = 'refs/remotes/origin/'
67
+ return ref.target.startsWith(prefix) ? ref.target.slice(prefix.length) || undefined : undefined
68
+ } catch {
69
+ // "undefined means unknown, so the caller applies its own default."
70
+ return undefined
71
+ }
72
+ },
73
+ }
74
+ }
package/src/index.ts ADDED
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Platform adapter backed by a kompjutr workspace.
3
+ *
4
+ * Lets `@roj-ai/sdk` run against a filesystem and git runtime that are SQLite
5
+ * rows rather than files on a disk — inside a Durable Object, or anywhere else
6
+ * that can hand kompjutr a SQL storage.
7
+ */
8
+
9
+ import type { Platform, Scheduler } from '@roj-ai/sdk/platform'
10
+ import { createTimerScheduler } from '@roj-ai/sdk/platform'
11
+ import type { Git, Workspace } from '@kompjutr/do'
12
+ import { KompjutrEventStore } from './event-store.js'
13
+ import { createKompjutrFileSystem } from './fs.js'
14
+ import { createKompjutrGitClient } from './git.js'
15
+ import { KompjutrLLMCallLog } from './llm-call-log.js'
16
+ import type { KompjutrLLMCallLogOptions } from './llm-call-log.js'
17
+ import { createUnsupportedProcessRunner } from './process.js'
18
+ import { KompjutrSessionLog } from './session-log.js'
19
+ import { createKompjutrShellRunner } from './shell.js'
20
+
21
+ export interface KompjutrPlatformOptions {
22
+ /** Scratch directory, created if it is not there. Defaults to `/tmp`. */
23
+ tmpDir?: string
24
+ /**
25
+ * Drives the agent loop's re-entry. Defaults to timers, which is correct
26
+ * while the process lives and lost when it does not. A Durable Object passes
27
+ * an alarm-backed scheduler so a wake survives its isolate.
28
+ */
29
+ scheduler?: Scheduler
30
+ /** Retention and column ceiling for the LLM call log. */
31
+ llmCallLog?: KompjutrLLMCallLogOptions
32
+ /** How the workspace itself is isolated. Defaults to `none`. */
33
+ shellConfinement?: 'host' | 'none'
34
+ }
35
+
36
+ export function createKompjutrPlatform(workspace: Workspace, options: KompjutrPlatformOptions = {}): Platform {
37
+ const tmpDir = options.tmpDir ?? '/tmp'
38
+ // A fresh workspace holds only `/`, and the port promises tmpDir is there.
39
+ workspace.filesystem.mkdir(tmpDir, { recursive: true })
40
+
41
+ const git = workspaceGit(workspace)
42
+
43
+ return {
44
+ fs: createKompjutrFileSystem({
45
+ compat: workspace.fs,
46
+ filesystem: workspace.filesystem,
47
+ }),
48
+ process: createUnsupportedProcessRunner(),
49
+ shell: createKompjutrShellRunner({
50
+ filesystem: workspace.filesystem,
51
+ git,
52
+ confinement: options.shellConfinement,
53
+ }),
54
+ git: git && createKompjutrGitClient(git),
55
+ // Omit fsRevision: kompjutr 0.1.2 does not bump it for Git-only mutations.
56
+ // Rows, not files: both logs live in the same database as the filesystem,
57
+ // and an append there is one statement against a ranged write through it.
58
+ sessionLog: new KompjutrSessionLog(workspace.db),
59
+ llmCallLog: new KompjutrLLMCallLog(workspace.db, options.llmCallLog),
60
+ scheduler: options.scheduler ?? createTimerScheduler(),
61
+ tmpDir,
62
+ }
63
+ }
64
+
65
+ /** The getter throws unless `WorkspaceOptions.git` was set — the only signal a workspace gives. */
66
+ function workspaceGit(workspace: Workspace): Git | undefined {
67
+ try {
68
+ return workspace.git
69
+ } catch {
70
+ return undefined
71
+ }
72
+ }
73
+
74
+ export { KompjutrEventStore } from './event-store.js'
75
+ export { createKompjutrFileSystem } from './fs.js'
76
+ export type { KompjutrFileSystemOptions } from './fs.js'
77
+ export { createKompjutrGitClient } from './git.js'
78
+ export { KompjutrLLMCallLog } from './llm-call-log.js'
79
+ export type { KompjutrLLMCallLogOptions } from './llm-call-log.js'
80
+ export { createUnsupportedProcessRunner } from './process.js'
81
+ export { KompjutrSessionLog } from './session-log.js'
82
+ export { createKompjutrShellRunner } from './shell.js'
83
+ export type { KompjutrShellRunnerOptions } from './shell.js'
@@ -0,0 +1,204 @@
1
+ import type { SqlDatabase } from '@kompjutr/do'
2
+ import type { LLMCallOutcome, LLMCallPage, LLMCallRow, LLMCallStatus, LLMCallStore } from '@roj-ai/sdk/platform'
3
+ import { LLMCallPayloads, storedNumber, storedString } from './llm-call-payloads.js'
4
+
5
+ const CALLS_TABLE = 'roj_llm_call'
6
+ const PAYLOAD_FORMAT = 1
7
+ const DEFAULT_MAX_CALLS_PER_SESSION = 200
8
+
9
+ export interface KompjutrLLMCallLogOptions {
10
+ /** Calls retained per session. Default 200; `0` keeps every call. */
11
+ maxCallsPerSession?: number
12
+ /** Opt-in caller request clamp in UTF-8 bytes; undefined or <= 0 disables it. Does not control storage chunk size. */
13
+ maxBlobBytes?: number
14
+ }
15
+
16
+ interface CallRecord {
17
+ call_rowid: unknown
18
+ payload_format: unknown
19
+ call_id: unknown
20
+ agent_id: unknown
21
+ created_at: unknown
22
+ status: unknown
23
+ model: unknown
24
+ request: unknown
25
+ completed_at: unknown
26
+ duration_ms: unknown
27
+ provider_request_id: unknown
28
+ response: unknown
29
+ metrics: unknown
30
+ error: unknown
31
+ }
32
+
33
+ const COLUMNS = 'call_id, agent_id, created_at, status, model, request, completed_at, duration_ms, provider_request_id, response, metrics, error'
34
+ const READ_COLUMNS = `rowid AS call_rowid, payload_format, ${COLUMNS}`
35
+
36
+ function toStatus(value: unknown): LLMCallStatus {
37
+ if (value === 'running' || value === 'success' || value === 'error') return value
38
+ throw new Error('Invalid LLM call status')
39
+ }
40
+
41
+ function optionalString(value: unknown): string | undefined {
42
+ return value === null ? undefined : storedString(value)
43
+ }
44
+
45
+ function optionalNumber(value: unknown): number | undefined {
46
+ return value === null ? undefined : storedNumber(value)
47
+ }
48
+
49
+ function format(value: unknown): number {
50
+ if (value === 0 || value === PAYLOAD_FORMAT) return value
51
+ throw new Error('Unsupported LLM call payload format')
52
+ }
53
+
54
+ export class KompjutrLLMCallLog implements LLMCallStore {
55
+ readonly maxBlobBytes: number | undefined
56
+ readonly #maxCallsPerSession: number
57
+ readonly #payloads: LLMCallPayloads
58
+
59
+ constructor(
60
+ private readonly db: SqlDatabase,
61
+ options: KompjutrLLMCallLogOptions = {},
62
+ ) {
63
+ this.#maxCallsPerSession = options.maxCallsPerSession ?? DEFAULT_MAX_CALLS_PER_SESSION
64
+ this.maxBlobBytes = options.maxBlobBytes !== undefined && options.maxBlobBytes > 0 ? options.maxBlobBytes : undefined
65
+ this.#payloads = this.db.transactionSync(() => {
66
+ this.db.run(`CREATE TABLE IF NOT EXISTS ${CALLS_TABLE} (
67
+ session_id TEXT NOT NULL, call_id TEXT NOT NULL, agent_id TEXT NOT NULL, created_at INTEGER NOT NULL,
68
+ status TEXT NOT NULL, model TEXT NOT NULL, request TEXT NOT NULL, completed_at INTEGER, duration_ms INTEGER,
69
+ provider_request_id TEXT, response TEXT, metrics TEXT, error TEXT, payload_format INTEGER NOT NULL DEFAULT 0
70
+ )`)
71
+ const columns = this.db.all<{ name: unknown }>(`PRAGMA table_info(${CALLS_TABLE})`)
72
+ if (!columns.some((column) => storedString(column.name) === 'payload_format')) {
73
+ this.db.run(`ALTER TABLE ${CALLS_TABLE} ADD COLUMN payload_format INTEGER NOT NULL DEFAULT 0`)
74
+ }
75
+ this.db.run(`CREATE UNIQUE INDEX IF NOT EXISTS ${CALLS_TABLE}_by_id ON ${CALLS_TABLE} (session_id, call_id)`)
76
+ return new LLMCallPayloads(db)
77
+ })
78
+ }
79
+
80
+ async create(sessionId: string, row: LLMCallRow): Promise<void> {
81
+ this.db.transactionSync(() => {
82
+ const record = this.db.one<{ call_rowid: unknown }>(
83
+ `INSERT INTO ${CALLS_TABLE} (session_id, ${COLUMNS}, payload_format)
84
+ VALUES (?, ?, '', ?, ?, '', '', ?, ?, NULL, NULL, NULL, NULL, ?) RETURNING rowid AS call_rowid`,
85
+ sessionId,
86
+ row.callId,
87
+ row.createdAt,
88
+ row.status,
89
+ row.completedAt ?? null,
90
+ row.durationMs ?? null,
91
+ PAYLOAD_FORMAT,
92
+ )
93
+ if (record === undefined) throw new Error('Missing inserted LLM call')
94
+ const rowid = storedNumber(record.call_rowid)
95
+ this.#writeRequest(rowid, row)
96
+ this.#writeOutcome(rowid, row)
97
+ this.#trim(sessionId)
98
+ })
99
+ }
100
+
101
+ async complete(sessionId: string, callId: string, outcome: LLMCallOutcome): Promise<void> {
102
+ this.db.transactionSync(() => {
103
+ const record = this.db.one<{ call_rowid: unknown; payload_format: unknown }>(
104
+ `SELECT rowid AS call_rowid, payload_format FROM ${CALLS_TABLE} WHERE session_id = ? AND call_id = ?`,
105
+ sessionId,
106
+ callId,
107
+ )
108
+ if (record === undefined) return
109
+ const rowid = storedNumber(record.call_rowid)
110
+ if (format(record.payload_format) === 0) {
111
+ const legacy = this.#get(sessionId, callId)
112
+ if (legacy === null) throw new Error('Missing legacy LLM call')
113
+ this.#writeRequest(rowid, legacy)
114
+ this.db.run(`UPDATE ${CALLS_TABLE} SET agent_id = '', model = '', request = '' WHERE rowid = ?`, rowid)
115
+ }
116
+ this.#writeOutcome(rowid, outcome)
117
+ this.db.run(
118
+ `UPDATE ${CALLS_TABLE} SET status = ?, completed_at = ?, duration_ms = ?, payload_format = ?,
119
+ provider_request_id = NULL, response = NULL, metrics = NULL, error = NULL WHERE rowid = ?`,
120
+ outcome.status,
121
+ outcome.completedAt,
122
+ outcome.durationMs,
123
+ PAYLOAD_FORMAT,
124
+ rowid,
125
+ )
126
+ })
127
+ }
128
+
129
+ async get(sessionId: string, callId: string): Promise<LLMCallRow | null> {
130
+ return this.db.transactionSync(() => this.#get(sessionId, callId))
131
+ }
132
+
133
+ async list(sessionId: string, options: { limit: number; offset: number }): Promise<LLMCallPage> {
134
+ return this.db.transactionSync(() => {
135
+ const records = this.db.all<CallRecord>(
136
+ `SELECT ${READ_COLUMNS} FROM ${CALLS_TABLE} WHERE session_id = ? ORDER BY call_id DESC LIMIT ? OFFSET ?`,
137
+ sessionId,
138
+ options.limit,
139
+ options.offset,
140
+ )
141
+ return { calls: records.map((record) => this.#toRow(record)), total: this.#count(sessionId) }
142
+ })
143
+ }
144
+
145
+ async delete(sessionId: string): Promise<number> {
146
+ return this.db.transactionSync(() => {
147
+ const calls = this.#count(sessionId)
148
+ this.db.run(`DELETE FROM ${CALLS_TABLE} WHERE session_id = ?`, sessionId)
149
+ return calls
150
+ })
151
+ }
152
+
153
+ #get(sessionId: string, callId: string): LLMCallRow | null {
154
+ const record = this.db.one<CallRecord>(`SELECT ${READ_COLUMNS} FROM ${CALLS_TABLE} WHERE session_id = ? AND call_id = ?`, sessionId, callId)
155
+ return record === undefined ? null : this.#toRow(record)
156
+ }
157
+
158
+ #toRow(record: CallRecord): LLMCallRow {
159
+ const rowid = storedNumber(record.call_rowid)
160
+ const chunked = format(record.payload_format) === PAYLOAD_FORMAT
161
+ return {
162
+ callId: storedString(record.call_id),
163
+ agentId: storedString(chunked ? this.#payloads.read(rowid, 'agent_id') : record.agent_id),
164
+ createdAt: storedNumber(record.created_at),
165
+ status: toStatus(record.status),
166
+ model: storedString(chunked ? this.#payloads.read(rowid, 'model') : record.model),
167
+ request: storedString(chunked ? this.#payloads.read(rowid, 'request') : record.request),
168
+ completedAt: optionalNumber(record.completed_at),
169
+ durationMs: optionalNumber(record.duration_ms),
170
+ providerRequestId: chunked ? this.#payloads.read(rowid, 'provider_request_id') : optionalString(record.provider_request_id),
171
+ response: chunked ? this.#payloads.read(rowid, 'response') : optionalString(record.response),
172
+ metrics: chunked ? this.#payloads.read(rowid, 'metrics') : optionalString(record.metrics),
173
+ error: chunked ? this.#payloads.read(rowid, 'error') : optionalString(record.error),
174
+ }
175
+ }
176
+
177
+ #writeRequest(rowid: number, row: LLMCallRow): void {
178
+ this.#payloads.write(rowid, 'agent_id', row.agentId)
179
+ this.#payloads.write(rowid, 'model', row.model)
180
+ this.#payloads.write(rowid, 'request', row.request)
181
+ }
182
+
183
+ #writeOutcome(rowid: number, outcome: Pick<LLMCallOutcome, 'providerRequestId' | 'response' | 'metrics' | 'error'>): void {
184
+ this.#payloads.write(rowid, 'provider_request_id', outcome.providerRequestId)
185
+ this.#payloads.write(rowid, 'response', outcome.response)
186
+ this.#payloads.write(rowid, 'metrics', outcome.metrics)
187
+ this.#payloads.write(rowid, 'error', outcome.error)
188
+ }
189
+
190
+ #trim(sessionId: string): void {
191
+ if (this.#maxCallsPerSession <= 0) return
192
+ this.db.run(
193
+ `DELETE FROM ${CALLS_TABLE} WHERE session_id = ?
194
+ AND call_id < (SELECT call_id FROM ${CALLS_TABLE} WHERE session_id = ? ORDER BY call_id DESC LIMIT 1 OFFSET ?)`,
195
+ sessionId,
196
+ sessionId,
197
+ this.#maxCallsPerSession - 1,
198
+ )
199
+ }
200
+
201
+ #count(sessionId: string): number {
202
+ return storedNumber(this.db.scalar(`SELECT COUNT(*) FROM ${CALLS_TABLE} WHERE session_id = ?`, sessionId))
203
+ }
204
+ }
@@ -0,0 +1,95 @@
1
+ import type { SqlDatabase } from '@kompjutr/do'
2
+
3
+ const CHUNK_BYTES = 256 * 1024
4
+ const encoder = new TextEncoder()
5
+ const decoder = new TextDecoder('utf-8', { fatal: true })
6
+
7
+ type PayloadField = 'agent_id' | 'model' | 'request' | 'provider_request_id' | 'response' | 'metrics' | 'error'
8
+
9
+ export function storedString(value: unknown): string {
10
+ if (typeof value !== 'string') throw new Error('Invalid LLM call string')
11
+ return value
12
+ }
13
+
14
+ export function storedNumber(value: unknown): number {
15
+ if (typeof value !== 'number' || !Number.isFinite(value)) throw new Error('Invalid LLM call number')
16
+ return value
17
+ }
18
+
19
+ function storedInteger(value: unknown): number {
20
+ const number = storedNumber(value)
21
+ if (!Number.isSafeInteger(number) || number < 0) throw new Error('Invalid LLM payload integer')
22
+ return number
23
+ }
24
+
25
+ export class LLMCallPayloads {
26
+ constructor(private readonly db: SqlDatabase) {
27
+ db.run(`CREATE TABLE IF NOT EXISTS roj_llm_payload (
28
+ call_rowid INTEGER NOT NULL, field TEXT NOT NULL, chunk_count INTEGER NOT NULL, byte_length INTEGER,
29
+ PRIMARY KEY (call_rowid, field)
30
+ ) WITHOUT ROWID`)
31
+ db.run(`CREATE TABLE IF NOT EXISTS roj_llm_payload_chunk (
32
+ call_rowid INTEGER NOT NULL, field TEXT NOT NULL, chunk_index INTEGER NOT NULL, data BLOB NOT NULL,
33
+ PRIMARY KEY (call_rowid, field, chunk_index)
34
+ ) WITHOUT ROWID`)
35
+ // Explicit cleanup also works when the host leaves SQLite foreign keys disabled.
36
+ db.run(`CREATE TRIGGER IF NOT EXISTS roj_llm_call_payload_delete AFTER DELETE ON roj_llm_call BEGIN
37
+ DELETE FROM roj_llm_payload_chunk WHERE call_rowid = OLD.rowid;
38
+ DELETE FROM roj_llm_payload WHERE call_rowid = OLD.rowid;
39
+ END`)
40
+ }
41
+
42
+ write(rowid: number, field: PayloadField, value: string | undefined): void {
43
+ // JSON string encoding preserves even lone UTF-16 surrogates through UTF-8 storage.
44
+ const bytes = value === undefined ? undefined : encoder.encode(JSON.stringify(value))
45
+ const count = bytes === undefined ? 0 : Math.ceil(bytes.byteLength / CHUNK_BYTES)
46
+ this.db.run('DELETE FROM roj_llm_payload_chunk WHERE call_rowid = ? AND field = ?', rowid, field)
47
+ this.db.run(
48
+ 'INSERT OR REPLACE INTO roj_llm_payload (call_rowid, field, chunk_count, byte_length) VALUES (?, ?, ?, ?)',
49
+ rowid,
50
+ field,
51
+ count,
52
+ bytes?.byteLength ?? null,
53
+ )
54
+ if (bytes === undefined) return
55
+ for (let index = 0; index < count; index++) {
56
+ this.db.run(
57
+ 'INSERT INTO roj_llm_payload_chunk (call_rowid, field, chunk_index, data) VALUES (?, ?, ?, ?)',
58
+ rowid,
59
+ field,
60
+ index,
61
+ bytes.slice(index * CHUNK_BYTES, (index + 1) * CHUNK_BYTES).buffer,
62
+ )
63
+ }
64
+ }
65
+
66
+ read(rowid: number, field: PayloadField): string | undefined {
67
+ const metadata = this.db.one<{ chunk_count: unknown; byte_length: unknown }>(
68
+ 'SELECT chunk_count, byte_length FROM roj_llm_payload WHERE call_rowid = ? AND field = ?',
69
+ rowid,
70
+ field,
71
+ )
72
+ if (metadata === undefined) throw new Error(`Missing LLM payload metadata: ${field}`)
73
+ const count = storedInteger(metadata.chunk_count)
74
+ const length = metadata.byte_length === null ? null : storedInteger(metadata.byte_length)
75
+ if (count !== (length === null ? 0 : Math.ceil(length / CHUNK_BYTES)) || length === 0) throw new Error(`Invalid LLM payload size: ${field}`)
76
+ const chunks = this.db.all<{ chunk_index: unknown; data: unknown }>(
77
+ 'SELECT chunk_index, data FROM roj_llm_payload_chunk WHERE call_rowid = ? AND field = ? ORDER BY chunk_index',
78
+ rowid,
79
+ field,
80
+ )
81
+ if (chunks.length !== count) throw new Error(`Missing or extra LLM payload chunks: ${field}`)
82
+ if (length === null) return undefined
83
+ const bytes = new Uint8Array(length)
84
+ for (const [index, chunk] of chunks.entries()) {
85
+ if (storedInteger(chunk.chunk_index) !== index) throw new Error(`Invalid LLM payload order: ${field}`)
86
+ const data = chunk.data instanceof ArrayBuffer ? new Uint8Array(chunk.data) : chunk.data
87
+ if (!(data instanceof Uint8Array) || data.byteLength !== Math.min(CHUNK_BYTES, length - index * CHUNK_BYTES)) {
88
+ throw new Error(`Invalid LLM payload chunk: ${field}`)
89
+ }
90
+ bytes.set(data, index * CHUNK_BYTES)
91
+ }
92
+ const value: unknown = JSON.parse(decoder.decode(bytes))
93
+ return storedString(value)
94
+ }
95
+ }