@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.
- package/README.md +114 -1
- package/dist/event-store.d.ts +48 -0
- package/dist/event-store.d.ts.map +1 -0
- package/dist/event-store.js +206 -0
- package/dist/event-store.js.map +1 -0
- package/dist/fs.d.ts +19 -0
- package/dist/fs.d.ts.map +1 -0
- package/dist/fs.js +200 -0
- package/dist/fs.js.map +1 -0
- package/dist/git.d.ts +12 -0
- package/dist/git.d.ts.map +1 -0
- package/dist/git.js +70 -0
- package/dist/git.js.map +1 -0
- package/dist/index.d.ts +36 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +57 -0
- package/dist/index.js.map +1 -0
- package/dist/llm-call-log.d.ts +23 -0
- package/dist/llm-call-log.d.ts.map +1 -0
- package/dist/llm-call-log.js +135 -0
- package/dist/llm-call-log.js.map +1 -0
- package/dist/llm-call-payloads.d.ts +12 -0
- package/dist/llm-call-payloads.d.ts.map +1 -0
- package/dist/llm-call-payloads.js +77 -0
- package/dist/llm-call-payloads.js.map +1 -0
- package/dist/process.d.ts +14 -0
- package/dist/process.d.ts.map +1 -0
- package/dist/process.js +25 -0
- package/dist/process.js.map +1 -0
- package/dist/session-log.d.ts +22 -0
- package/dist/session-log.d.ts.map +1 -0
- package/dist/session-log.js +65 -0
- package/dist/session-log.js.map +1 -0
- package/dist/shell.d.ts +11 -0
- package/dist/shell.d.ts.map +1 -0
- package/dist/shell.js +36 -0
- package/dist/shell.js.map +1 -0
- package/dist/testing/index.d.ts +2 -0
- package/dist/testing/index.d.ts.map +1 -0
- package/dist/testing/index.js +2 -0
- package/dist/testing/index.js.map +1 -0
- package/dist/testing/storage.d.ts +29 -0
- package/dist/testing/storage.d.ts.map +1 -0
- package/dist/testing/storage.js +132 -0
- package/dist/testing/storage.js.map +1 -0
- package/package.json +44 -4
- package/src/event-store.ts +250 -0
- package/src/fs.ts +232 -0
- package/src/git.ts +74 -0
- package/src/index.ts +83 -0
- package/src/llm-call-log.ts +204 -0
- package/src/llm-call-payloads.ts +95 -0
- package/src/process.ts +28 -0
- package/src/session-log.ts +79 -0
- package/src/shell.ts +49 -0
- package/src/testing/index.ts +1 -0
- 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
|
+
}
|