@llm4ts/runner 3.4.0 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/dist/Connectors.d.ts +0 -6
  2. package/dist/Connectors.d.ts.map +1 -1
  3. package/dist/Connectors.js +0 -9
  4. package/dist/Connectors.js.map +1 -1
  5. package/dist/EventPresentation.d.ts.map +1 -1
  6. package/dist/EventPresentation.js +0 -1
  7. package/dist/EventPresentation.js.map +1 -1
  8. package/dist/FlowRunner.d.ts +10 -3
  9. package/dist/FlowRunner.d.ts.map +1 -1
  10. package/dist/FlowRunner.js +20 -2
  11. package/dist/FlowRunner.js.map +1 -1
  12. package/dist/NodeGeminiCliExecutor.d.ts.map +1 -1
  13. package/dist/NodeGeminiCliExecutor.js +1 -0
  14. package/dist/NodeGeminiCliExecutor.js.map +1 -1
  15. package/dist/NodeGeminiModelBridge.d.ts.map +1 -1
  16. package/dist/NodeGeminiModelBridge.js +1 -0
  17. package/dist/NodeGeminiModelBridge.js.map +1 -1
  18. package/dist/NodeHttpClient.d.ts.map +1 -1
  19. package/dist/NodeHttpClient.js +1 -0
  20. package/dist/NodeHttpClient.js.map +1 -1
  21. package/dist/Otel.d.ts.map +1 -1
  22. package/dist/Otel.js +1 -0
  23. package/dist/Otel.js.map +1 -1
  24. package/dist/Package.d.ts.map +1 -1
  25. package/dist/Package.js +1 -0
  26. package/dist/Package.js.map +1 -1
  27. package/dist/Terminal.d.ts +1 -2
  28. package/dist/Terminal.d.ts.map +1 -1
  29. package/dist/Terminal.js +1 -4
  30. package/dist/Terminal.js.map +1 -1
  31. package/dist/WindowsCommand.d.ts.map +1 -1
  32. package/dist/WindowsCommand.js +1 -0
  33. package/dist/WindowsCommand.js.map +1 -1
  34. package/dist/index.d.ts +1 -2
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +1 -2
  37. package/dist/index.js.map +1 -1
  38. package/package.json +8 -21
  39. package/src/Connectors.ts +0 -10
  40. package/src/EventPresentation.ts +0 -1
  41. package/src/FlowRunner.ts +26 -6
  42. package/src/NodeGeminiCliExecutor.ts +1 -0
  43. package/src/NodeGeminiModelBridge.ts +1 -0
  44. package/src/NodeHttpClient.ts +1 -0
  45. package/src/Otel.ts +1 -0
  46. package/src/Package.ts +1 -0
  47. package/src/Terminal.ts +1 -5
  48. package/src/WindowsCommand.ts +1 -0
  49. package/src/index.ts +0 -2
  50. package/dist/Costs.d.ts +0 -52
  51. package/dist/Costs.d.ts.map +0 -1
  52. package/dist/Costs.js +0 -95
  53. package/dist/Costs.js.map +0 -1
  54. package/dist/McpStdio.d.ts +0 -7
  55. package/dist/McpStdio.d.ts.map +0 -1
  56. package/dist/McpStdio.js +0 -19
  57. package/dist/McpStdio.js.map +0 -1
  58. package/dist/Profile.d.ts +0 -313
  59. package/dist/Profile.d.ts.map +0 -1
  60. package/dist/Profile.js +0 -937
  61. package/dist/Profile.js.map +0 -1
  62. package/dist/ReviewFingerprint.d.ts +0 -2
  63. package/dist/ReviewFingerprint.d.ts.map +0 -1
  64. package/dist/ReviewFingerprint.js +0 -13
  65. package/dist/ReviewFingerprint.js.map +0 -1
  66. package/dist/RunReaders.d.ts +0 -70
  67. package/dist/RunReaders.d.ts.map +0 -1
  68. package/dist/RunReaders.js +0 -236
  69. package/dist/RunReaders.js.map +0 -1
  70. package/dist/StoryReaders.d.ts +0 -16
  71. package/dist/StoryReaders.d.ts.map +0 -1
  72. package/dist/StoryReaders.js +0 -107
  73. package/dist/StoryReaders.js.map +0 -1
  74. package/dist/Watch.d.ts +0 -68
  75. package/dist/Watch.d.ts.map +0 -1
  76. package/dist/Watch.js +0 -261
  77. package/dist/Watch.js.map +0 -1
  78. package/src/Costs.ts +0 -148
  79. package/src/McpStdio.ts +0 -41
  80. package/src/Profile.ts +0 -1196
  81. package/src/ReviewFingerprint.ts +0 -13
  82. package/src/RunReaders.ts +0 -370
  83. package/src/StoryReaders.ts +0 -157
  84. package/src/Watch.ts +0 -446
package/src/Watch.ts DELETED
@@ -1,446 +0,0 @@
1
- import { readdirSync, existsSync, statSync } from "node:fs"
2
- import { join } from "node:path"
3
- import * as Clock from "effect/Clock"
4
- import * as Duration from "effect/Duration"
5
- import * as Effect from "effect/Effect"
6
- import * as Queue from "effect/Queue"
7
- import * as Ref from "effect/Ref"
8
- import * as Schema from "effect/Schema"
9
- import * as Stream from "effect/Stream"
10
- import { Board, BoardVersion } from "@llm4ts/flow/BoardSync"
11
- import { readEpicRuns, type EpicRun } from "@llm4ts/flow/EpicRuns"
12
- import type { FlowError } from "@llm4ts/flow/FlowError"
13
- import { loadVersioned, type PlainFileStoreShape } from "@llm4ts/flow/Persistence"
14
- import { readTrace } from "@llm4ts/flow/Replay"
15
- import {
16
- emptyTree,
17
- initialView,
18
- onTreeKey,
19
- reduceTree,
20
- renderTree,
21
- tailTargetOf,
22
- treeInputsOfTrace,
23
- type TreeInput,
24
- type TreeState,
25
- type TreeView
26
- } from "./AgentTree.ts"
27
- import { enterScreen, leaveScreen, makeScreenPainter, nodeTreeKeys } from "./AgentTreeSurface.ts"
28
- import {
29
- loadTranscript,
30
- nodeTranscriptFiles,
31
- renderTranscript,
32
- tailLinesOf,
33
- transcriptFileOf,
34
- type TailTarget,
35
- type TranscriptFiles
36
- } from "./Transcripts.ts"
37
- import { nodeListTraces, TraceDirectoryName } from "./Costs.ts"
38
- import { nodePlainFileStore } from "./NodePlainFileStore.ts"
39
-
40
- /**
41
- * `llm4ts watch` (ADR 0022): the agent tree over a trace file — a live run
42
- * followed as it grows, a finished run's last frame, or a replay on the
43
- * trace's own timestamps. The trace is the only source; the epic's board
44
- * adds the stories that have not started.
45
- */
46
-
47
- export interface WatchOptions {
48
- readonly repo: string
49
- /** A trace file; the newest in the repository's `.llm4ts/` when omitted. */
50
- readonly trace?: string
51
- /** An epic id: its latest recorded run. */
52
- readonly epic?: string
53
- readonly replay?: boolean
54
- /** Replay speed-up (default 10). */
55
- readonly speed?: number
56
- /** A live lane with no event for this long is marked idle (`LLM4TS_IDLE_AFTER`). */
57
- readonly idleAfterMs?: number
58
- /** Print this story's or executor's transcript as it grows, instead of the tree. */
59
- readonly tail?: string
60
- }
61
-
62
- export interface WatchOutput {
63
- readonly write: (text: string) => Effect.Effect<void>
64
- readonly columns: () => number | undefined
65
- readonly rows: () => number | undefined
66
- /** A terminal a person looks at: full-screen, keys, following. */
67
- readonly interactive: boolean
68
- readonly colour: boolean
69
- }
70
-
71
- export interface WatchDependencies {
72
- readonly files: PlainFileStoreShape
73
- readonly listTraces: (directory: string) => ReadonlyArray<string>
74
- /** Epic folder names under `.llm4ts/epics/`. */
75
- readonly listEpics: (directory: string) => ReadonlyArray<string>
76
- readonly output: WatchOutput
77
- /** Key names as `onTreeKey` reads them: "up", "enter", "q", … */
78
- readonly keys: Stream.Stream<string>
79
- /** A run's transcript files (`--transcript`); the disk by default. */
80
- readonly transcripts?: TranscriptFiles
81
- }
82
-
83
- export class WatchTargetMissing extends Schema.TaggedError<WatchTargetMissing>()(
84
- "WatchTargetMissing",
85
- { message: Schema.String }
86
- ) {}
87
-
88
- /** Where a run's trace and its epic are read from. */
89
- export type TraceSources = Pick<WatchDependencies, "files" | "listTraces" | "listEpics">
90
-
91
- /** A trace to read: given, an epic's latest run, or the newest in the repository. */
92
- export type TraceChoice = Pick<WatchOptions, "repo" | "trace" | "epic">
93
-
94
- export interface WatchTarget {
95
- readonly tracePath: string
96
- readonly epicDir: string | undefined
97
- readonly run: EpicRun | undefined
98
- }
99
-
100
- const epicsDirOf = (repo: string): string => join(repo, TraceDirectoryName, "epics")
101
-
102
- /** The epic whose `runs.jsonl` names this trace, if one does. */
103
- const epicOfTrace = (
104
- tracePath: string,
105
- repo: string,
106
- dependencies: TraceSources
107
- ): Effect.Effect<{ readonly dir: string; readonly run: EpicRun } | undefined, FlowError> =>
108
- Effect.gen(function* () {
109
- const epicsDir = epicsDirOf(repo)
110
- for (const epic of dependencies.listEpics(epicsDir)) {
111
- const dir = join(epicsDir, epic)
112
- const run = [...(yield* readEpicRuns(dependencies.files, dir))]
113
- .reverse()
114
- .find((candidate) => candidate.tracePath === tracePath)
115
- if (run !== undefined) {
116
- return { dir, run }
117
- }
118
- }
119
- return undefined
120
- })
121
-
122
- export const resolveTraceTarget = (
123
- options: TraceChoice,
124
- dependencies: TraceSources
125
- ): Effect.Effect<WatchTarget, FlowError | WatchTargetMissing> =>
126
- Effect.gen(function* () {
127
- if (options.epic !== undefined) {
128
- const dir = join(epicsDirOf(options.repo), options.epic)
129
- const run = (yield* readEpicRuns(dependencies.files, dir)).at(-1)
130
- if (run === undefined) {
131
- return yield* WatchTargetMissing.make({
132
- message: `epic ${options.epic} has no recorded run in ${dir}/runs.jsonl`
133
- })
134
- }
135
- return { tracePath: run.tracePath, epicDir: dir, run }
136
- }
137
- const tracePath =
138
- options.trace ?? dependencies.listTraces(join(options.repo, TraceDirectoryName)).at(-1)
139
- if (tracePath === undefined) {
140
- return yield* WatchTargetMissing.make({
141
- message: `no trace in ${join(options.repo, TraceDirectoryName)}; pass a trace file or --epic`
142
- })
143
- }
144
- const epic = yield* epicOfTrace(tracePath, options.repo, dependencies)
145
- return { tracePath, epicDir: epic?.dir, run: epic?.run }
146
- })
147
-
148
- const actionWords = (run: EpicRun | undefined): string | undefined => {
149
- if (run === undefined) {
150
- return undefined
151
- }
152
- switch (run.action) {
153
- case "RunPlan":
154
- return "run plan"
155
- case "RunRound":
156
- return `round ${run.round ?? "?"}`
157
- case "PlanRound":
158
- return `plan round ${run.round ?? "?"}`
159
- case "Land":
160
- return "land"
161
- default:
162
- return run.action
163
- }
164
- }
165
-
166
- /** The tree before any event: the epic's board, when the trace belongs to one. */
167
- const startingTree = (
168
- target: WatchTarget,
169
- files: PlainFileStoreShape,
170
- idleAfterMs: number | undefined
171
- ): Effect.Effect<TreeState, FlowError> =>
172
- Effect.gen(function* () {
173
- const action = actionWords(target.run)
174
- const idle = idleAfterMs === undefined ? {} : { idleAfterMs }
175
- if (target.epicDir === undefined) {
176
- return emptyTree({ ...idle, ...(action === undefined ? {} : { action }) })
177
- }
178
- const boardDir =
179
- target.run?.round === undefined
180
- ? target.epicDir
181
- : join(target.epicDir, "rounds", String(target.run.round))
182
- const board = yield* loadVersioned(files, join(boardDir, "board.json"), BoardVersion, Board)
183
- const epicId = target.epicDir.split("/").at(-1) ?? "epic"
184
- return emptyTree({
185
- title: board === undefined ? `epic ${epicId}` : board.title.replace(/^Epic:\s*/u, "epic "),
186
- stories: (board?.items ?? []).map((item) => ({ id: item.id, status: item.status })),
187
- ...idle,
188
- ...(action === undefined ? {} : { action })
189
- })
190
- })
191
-
192
- const readInputs = (
193
- target: WatchTarget,
194
- files: PlainFileStoreShape
195
- ): Effect.Effect<ReadonlyArray<TreeInput>, FlowError> =>
196
- Effect.map(readTrace(files, target.tracePath), treeInputsOfTrace)
197
-
198
- const pollInterval = Duration.millis(500)
199
- const replayGapCap = 2_000
200
-
201
- const draw = (
202
- state: TreeState,
203
- view: TreeView,
204
- output: WatchOutput,
205
- height?: number,
206
- tail?: ReadonlyArray<string>
207
- ): string =>
208
- renderTree(state, {
209
- width: Math.max(90, output.columns() ?? 90),
210
- colour: output.colour,
211
- view,
212
- ...(height === undefined ? {} : { height }),
213
- ...(tail === undefined ? {} : { tail })
214
- }).join("\n")
215
-
216
- /** A full-screen frame: no taller than the terminal. */
217
- const screenLines = (
218
- state: TreeState,
219
- view: TreeView,
220
- output: WatchOutput,
221
- tail?: ReadonlyArray<string>
222
- ): ReadonlyArray<string> => draw(state, view, output, output.rows(), tail).split("\n")
223
-
224
- /** Rows the tail pane shows. */
225
- const tailRows = 16
226
-
227
- /** Where a trace's run kept its transcript: `.llm4ts/transcripts/<run-id>/`. */
228
- const transcriptDirOf = (
229
- repo: string,
230
- target: WatchTarget,
231
- files: PlainFileStoreShape
232
- ): Effect.Effect<string | undefined, FlowError> =>
233
- Effect.map(readTrace(files, target.tracePath), (lines) => {
234
- const runId = lines[0]?.runId
235
- return runId === undefined ? undefined : join(repo, TraceDirectoryName, "transcripts", runId)
236
- })
237
-
238
- export const makeWatchProgram = Effect.fn("@llm4ts/runner/Watch.make")(function* (
239
- options: WatchOptions,
240
- dependencies: WatchDependencies
241
- ): Effect.fn.Return<void, FlowError | WatchTargetMissing> {
242
- const target = yield* resolveTraceTarget(options, dependencies)
243
- const { files, output } = dependencies
244
- const fold = (inputs: ReadonlyArray<TreeInput>): Effect.Effect<TreeState, FlowError> =>
245
- Effect.map(startingTree(target, files, options.idleAfterMs), (start) =>
246
- inputs.reduce(reduceTree, start)
247
- )
248
-
249
- const transcripts = dependencies.transcripts ?? nodeTranscriptFiles
250
- const transcriptDir = yield* transcriptDirOf(options.repo, target, files)
251
-
252
- if (options.tail !== undefined) {
253
- if (transcriptDir === undefined || transcripts.list(transcriptDir).length === 0) {
254
- return yield* WatchTargetMissing.make({
255
- message: "no transcript for this run — start it with llm4ts run … --transcript"
256
- })
257
- }
258
- return yield* followTail(options.tail, transcriptDir, transcripts, output, () =>
259
- Effect.map(readInputs(target, files), (inputs) =>
260
- inputs.some((input) => input._tag === "RunEnded")
261
- )
262
- )
263
- }
264
-
265
- /** The open tail's lines, for the view's selection. */
266
- const tailFor = (view: TreeView): Effect.Effect<ReadonlyArray<string> | undefined> => {
267
- const selected = tailTargetOf(view)
268
- return !view.tail || selected === undefined || transcriptDir === undefined
269
- ? Effect.succeed(undefined)
270
- : Effect.map(loadTranscript(transcripts, transcriptDir, selected), (entries) =>
271
- tailLinesOf(
272
- entries,
273
- selected,
274
- view.tailRole,
275
- Math.max(90, output.columns() ?? 90) - 32,
276
- tailRows,
277
- view.tailBack
278
- )
279
- )
280
- }
281
-
282
- if (!output.interactive) {
283
- const state = yield* Effect.flatMap(readInputs(target, files), fold)
284
- return yield* output.write(`${draw(state, initialView, output)}\n`)
285
- }
286
-
287
- yield* Effect.scoped(
288
- Effect.gen(function* () {
289
- const keys = yield* Queue.unbounded<string>()
290
- yield* Effect.forkScoped(
291
- Stream.runForEach(dependencies.keys, (key) => Queue.offer(keys, key))
292
- )
293
- const view = yield* Ref.make<TreeView>(initialView)
294
- const all = yield* readInputs(target, files)
295
- // Replay steps through the inputs on their timestamps; otherwise the
296
- // whole trace is shown and re-read while the run goes on.
297
- const shown = yield* Ref.make(options.replay === true ? Math.min(1, all.length) : all.length)
298
- const inputs = yield* Ref.make(all)
299
- const speed = Math.max(1, options.speed ?? 10)
300
-
301
- /** Waits for a key or for `wait`; true when the key was `q`. */
302
- const awaitKey = (wait: Duration.Duration): Effect.Effect<boolean> =>
303
- Effect.raceFirst(
304
- Effect.flatMap(Queue.take(keys), (key) =>
305
- Effect.gen(function* () {
306
- const state = yield* Effect.orDie(
307
- Effect.flatMap(Ref.get(inputs), (current) =>
308
- Effect.flatMap(Ref.get(shown), (count) => fold(current.slice(0, count)))
309
- )
310
- )
311
- const next = onTreeKey(yield* Ref.get(view), key, state)
312
- if (next === "quit") {
313
- return true
314
- }
315
- yield* Ref.set(view, next)
316
- return false
317
- })
318
- ),
319
- Effect.as(Effect.sleep(wait), false)
320
- )
321
-
322
- const painter = yield* makeScreenPainter
323
- yield* output.write(enterScreen)
324
- yield* Effect.gen(function* () {
325
- while (true) {
326
- const current = yield* Ref.get(inputs)
327
- const count = yield* Ref.get(shown)
328
- const folded = yield* fold(current.slice(0, count))
329
- // Following a live run, the clock moves between events; a replay
330
- // and a finished run keep the trace's own time.
331
- const state =
332
- options.replay !== true && folded.ended === undefined
333
- ? reduceTree(folded, { _tag: "Tick", at: yield* Clock.currentTimeMillis })
334
- : folded
335
- const text = yield* painter.paint(
336
- screenLines(state, yield* Ref.get(view), output, yield* tailFor(yield* Ref.get(view))),
337
- `${output.columns()}x${output.rows()}`
338
- )
339
- if (text.length > 0) {
340
- yield* output.write(text)
341
- }
342
- const replaying = options.replay === true && count < current.length
343
- const gap = replaying
344
- ? Math.min(replayGapCap, (current[count]?.at ?? 0) - (current[count - 1]?.at ?? 0)) /
345
- speed
346
- : Duration.toMillis(pollInterval)
347
- if (yield* awaitKey(Duration.millis(Math.max(0, gap)))) {
348
- return
349
- }
350
- if (replaying) {
351
- yield* Ref.update(shown, (shownCount) => shownCount + 1)
352
- } else if (state.ended === undefined) {
353
- const reread = yield* readInputs(target, files)
354
- yield* Ref.set(inputs, reread)
355
- yield* Ref.set(shown, reread.length)
356
- }
357
- }
358
- }).pipe(Effect.ensuring(output.write(leaveScreen)))
359
- })
360
- )
361
- })
362
-
363
- // ── node wiring ─────────────────────────────────────────────────────────────
364
-
365
- /** Folder names under an `epics/` directory; none when it is absent. */
366
- export const nodeListEpics = (directory: string): ReadonlyArray<string> =>
367
- existsSync(directory)
368
- ? readdirSync(directory).filter((name) => statSync(join(directory, name)).isDirectory())
369
- : []
370
-
371
- export const nodeTraceSources: TraceSources = {
372
- files: nodePlainFileStore,
373
- listTraces: nodeListTraces,
374
- listEpics: nodeListEpics
375
- }
376
-
377
- export const nodeWatchDependencies = (
378
- environment: Readonly<Record<string, string | undefined>> = process.env
379
- ): WatchDependencies => {
380
- const interactive = process.stdout.isTTY === true && process.stdin.isTTY === true
381
- return {
382
- files: nodePlainFileStore,
383
- listTraces: nodeListTraces,
384
- listEpics: nodeListEpics,
385
- output: {
386
- write: (text) => Effect.sync(() => void process.stdout.write(text)),
387
- columns: () => process.stdout.columns,
388
- rows: () => process.stdout.rows,
389
- interactive,
390
- colour: process.stdout.isTTY === true && environment.NO_COLOR === undefined
391
- },
392
- keys: nodeTreeKeys("detach")
393
- }
394
- }
395
-
396
- // ── tail ────────────────────────────────────────────────────────────────────
397
-
398
- /**
399
- * `llm4ts watch --tail <story|executor>`: the transcript printed as it grows,
400
- * like `tail -f` — a call's reply streams in as written — until the run ends.
401
- * A name with its own file is a story; any other is an executor.
402
- */
403
- const followTail = (
404
- name: string,
405
- directory: string,
406
- transcripts: TranscriptFiles,
407
- output: WatchOutput,
408
- ended: () => Effect.Effect<boolean, FlowError>
409
- ): Effect.Effect<void, FlowError> =>
410
- Effect.gen(function* () {
411
- const target: TailTarget = transcripts.list(directory).includes(transcriptFileOf(name))
412
- ? { lane: name }
413
- : { executor: name }
414
- const width = Math.max(60, output.columns() ?? 100)
415
- let shown = 0
416
- let start: number | undefined
417
- const wanted = new Set<string>()
418
- let replying: string | undefined
419
- while (true) {
420
- const done = yield* ended()
421
- const entries = (yield* loadTranscript(transcripts, directory, target)) ?? []
422
- for (const entry of entries.slice(shown)) {
423
- start = start ?? entry.at
424
- if (entry._tag === "Call" && ("lane" in target || entry.executor === target.executor)) {
425
- wanted.add(entry.call)
426
- }
427
- if (!wanted.has(entry.call)) {
428
- continue
429
- }
430
- if (entry._tag === "Reply") {
431
- // A reply streams: its text as it arrived, on one growing paragraph.
432
- yield* output.write(`${replying === entry.call ? "" : "◀ "}${entry.text}`)
433
- replying = entry.call
434
- continue
435
- }
436
- const lines = renderTranscript([entry], { width, since: start })
437
- yield* output.write(`${replying === undefined ? "" : "\n"}${lines.join("\n")}\n`)
438
- replying = undefined
439
- }
440
- shown = entries.length
441
- if (done) {
442
- return
443
- }
444
- yield* Effect.sleep(pollInterval)
445
- }
446
- })