@llm4ts/runner 2.23.0 → 2.24.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.
@@ -0,0 +1,274 @@
1
+ import { appendFileSync, mkdirSync, readdirSync, readFileSync } from "node:fs"
2
+ import { join } from "node:path"
3
+ import * as Effect from "effect/Effect"
4
+ import * as Schema from "effect/Schema"
5
+ import { summariseToolArgs } from "@llm4ts/flow/Activity"
6
+ import { TranscriptEntry, type TranscriptSink } from "@llm4ts/flow/Transcript"
7
+ import { duration } from "./AgentTree.ts"
8
+
9
+ /**
10
+ * Transcripts on disk (`llm4ts run --transcript`): one JSON line per entry,
11
+ * one file per story (`<lane>.jsonl`) and `run.jsonl` for the rest, under
12
+ * `.llm4ts/transcripts/<run-id>/`. They hold the customer's code and the
13
+ * model's replies, so only their owner can read them, and a write that fails
14
+ * never fails the run.
15
+ */
16
+
17
+ /** `LLM4TS_TRANSCRIPT=on` (what `--transcript` sets); also `1`, `true`, `yes`. */
18
+ export const transcriptsWanted = (
19
+ environment: Readonly<Record<string, string | undefined>>
20
+ ): boolean => /^(on|1|true|yes)$/iu.test(environment.LLM4TS_TRANSCRIPT?.trim() ?? "")
21
+
22
+ export const transcriptFileOf = (lane: string | undefined): string =>
23
+ `${(lane ?? "run").replace(/[^A-Za-z0-9._-]/gu, "_")}.jsonl`
24
+
25
+ const encode = Schema.encodeSync(Schema.fromJsonString(TranscriptEntry))
26
+
27
+ export const nodeTranscriptSink = (directory: string): TranscriptSink => {
28
+ let made = false
29
+ return {
30
+ write: (lane, entry) =>
31
+ Effect.sync(() => {
32
+ try {
33
+ if (!made) {
34
+ mkdirSync(directory, { recursive: true, mode: 0o700 })
35
+ made = true
36
+ }
37
+ appendFileSync(join(directory, transcriptFileOf(lane)), `${encode(entry)}\n`, {
38
+ mode: 0o600
39
+ })
40
+ } catch {
41
+ // A transcript is a window on the run, never a reason to stop it.
42
+ }
43
+ })
44
+ }
45
+ }
46
+
47
+ // ── reading ─────────────────────────────────────────────────────────────────
48
+
49
+ export interface TranscriptView {
50
+ readonly width: number
51
+ /** Only calls made in this role (coder, reviewer, judge, …). */
52
+ readonly role?: string
53
+ /** Only calls served by this executor. */
54
+ readonly executor?: string
55
+ /** Keep the last lines only. */
56
+ readonly last?: number
57
+ /** Where the clock in the headers starts; the first entry's time by default. */
58
+ readonly since?: number
59
+ }
60
+
61
+ const clockOf = (ms: number): string => {
62
+ const seconds = Math.max(0, Math.floor(ms / 1_000))
63
+ return [Math.floor(seconds / 3_600), Math.floor(seconds / 60) % 60, seconds % 60]
64
+ .map((part) => String(part).padStart(2, "0"))
65
+ .join(":")
66
+ }
67
+
68
+ const cut = (line: string, width: number): string =>
69
+ [...line].length <= width ? line : `${[...line].slice(0, Math.max(0, width - 1)).join("")}…`
70
+
71
+ /** `text` as lines of at most `width`, the first after `prefix`, the rest indented. */
72
+ const wrapped = (prefix: string, text: string, width: number, limit = Infinity): Array<string> => {
73
+ const room = Math.max(10, width - prefix.length)
74
+ const lines: Array<string> = []
75
+ for (const paragraph of text.split("\n")) {
76
+ let line = ""
77
+ for (const word of paragraph.split(/\s+/u).filter((part) => part.length > 0)) {
78
+ if (line.length > 0 && line.length + 1 + word.length > room) {
79
+ lines.push(line)
80
+ line = ""
81
+ }
82
+ line = line.length === 0 ? cut(word, room) : `${line} ${word}`
83
+ }
84
+ lines.push(line)
85
+ }
86
+ const kept = lines.filter((line, index) => line.length > 0 || index < lines.length - 1)
87
+ const shown = kept.slice(0, limit)
88
+ const more = kept.length - shown.length
89
+ return [
90
+ ...shown.map((line, index) => `${index === 0 ? prefix : " ".repeat(prefix.length)}${line}`),
91
+ ...(more > 0 ? [`${" ".repeat(prefix.length)}… (${more} more lines)`] : [])
92
+ ]
93
+ }
94
+
95
+ const inputLines = 12
96
+
97
+ /**
98
+ * A transcript as the tail pane and `watch --tail` show it: each call under
99
+ * a header (when, who), its input, the reply as one text, every tool with
100
+ * the gist of its result, and how long the call took.
101
+ */
102
+ export const renderTranscript = (
103
+ entries: ReadonlyArray<TranscriptEntry>,
104
+ view: TranscriptView
105
+ ): ReadonlyArray<string> => {
106
+ const { width } = view
107
+ const start = view.since ?? entries[0]?.at ?? 0
108
+ const wanted = new Set(
109
+ entries.flatMap((entry) =>
110
+ entry._tag === "Call" &&
111
+ (view.role === undefined || entry.role === view.role) &&
112
+ (view.executor === undefined || entry.executor === view.executor)
113
+ ? [entry.call]
114
+ : []
115
+ )
116
+ )
117
+ const lines: Array<string> = []
118
+ let reply: { call: string; text: string } | undefined
119
+ const flushReply = () => {
120
+ if (reply !== undefined && reply.text.trim().length > 0) {
121
+ lines.push(...wrapped("◀ ", reply.text.trim(), width))
122
+ }
123
+ reply = undefined
124
+ }
125
+ // Only a filter needs the calls' headers; without one every entry shows,
126
+ // even one passed alone (the tail prints entries as they arrive).
127
+ const filtering = view.role !== undefined || view.executor !== undefined
128
+ for (const entry of entries) {
129
+ if (filtering && !wanted.has(entry.call)) {
130
+ continue
131
+ }
132
+ if (entry._tag !== "Reply") {
133
+ flushReply()
134
+ }
135
+ switch (entry._tag) {
136
+ case "Call": {
137
+ const title = `── ${clockOf(entry.at - start)} ${entry.role}${
138
+ entry.executor === undefined ? "" : ` · ${entry.executor}`
139
+ } `
140
+ lines.push(cut(title.padEnd(width, "─"), width))
141
+ if (entry.system !== undefined) {
142
+ const [first = "", ...more] = entry.system.split("\n")
143
+ lines.push(
144
+ cut(
145
+ `▶ system: ${first}${more.length === 0 ? "" : ` … (${more.length + 1} lines)`}`,
146
+ width
147
+ )
148
+ )
149
+ }
150
+ if (entry.earlier !== undefined && entry.earlier > 0) {
151
+ lines.push(`▶ (${entry.earlier} earlier messages)`)
152
+ }
153
+ lines.push(...wrapped("▶ ", entry.input, width, inputLines))
154
+ break
155
+ }
156
+ case "Reply":
157
+ reply =
158
+ reply?.call === entry.call
159
+ ? { call: entry.call, text: reply.text + entry.text }
160
+ : { call: entry.call, text: entry.text }
161
+ break
162
+ case "Tool":
163
+ lines.push(cut(`⚙ ${entry.tool} ${summariseToolArgs(entry.args)}`.trimEnd(), width))
164
+ break
165
+ case "ToolResult": {
166
+ const outputLines = entry.output
167
+ .split("\n")
168
+ .map((line) => line.trim())
169
+ .filter((line) => line.length > 0)
170
+ const gist = outputLines.slice(0, 3).join(" · ")
171
+ lines.push(
172
+ cut(
173
+ ` ↳ ${entry.failed === true ? "failed" : "ok"}${gist.length === 0 ? "" : `: ${gist}`}${
174
+ outputLines.length > 3 ? " …" : ""
175
+ }`,
176
+ width
177
+ )
178
+ )
179
+ break
180
+ }
181
+ case "End":
182
+ lines.push(` (${duration(entry.ms)}${entry.failed === true ? ", failed" : ""})`)
183
+ break
184
+ }
185
+ }
186
+ flushReply()
187
+ return view.last === undefined ? lines : lines.slice(-view.last)
188
+ }
189
+
190
+ /** How a host reaches a run's transcript files. */
191
+ export interface TranscriptFiles {
192
+ readonly read: (path: string) => Effect.Effect<string | undefined>
193
+ /** File names in a directory; none when it does not exist. */
194
+ readonly list: (directory: string) => ReadonlyArray<string>
195
+ }
196
+
197
+ const decodeEntry = Schema.decodeUnknownOption(Schema.fromJsonString(TranscriptEntry))
198
+
199
+ /** A transcript file's entries; a torn last line (a write in progress) is skipped. */
200
+ export const parseTranscript = (text: string): ReadonlyArray<TranscriptEntry> =>
201
+ text
202
+ .split("\n")
203
+ .filter((line) => line.trim().length > 0)
204
+ .flatMap((line) => {
205
+ const decoded = decodeEntry(line)
206
+ return decoded._tag === "Some" ? [decoded.value] : []
207
+ })
208
+
209
+ export type TailTarget = { readonly lane: string } | { readonly executor: string }
210
+
211
+ /**
212
+ * The entries a tail shows: a story's file, or every file for an executor
213
+ * (in time order). `undefined` when the run kept no transcript at all.
214
+ */
215
+ export const loadTranscript = (
216
+ files: TranscriptFiles,
217
+ directory: string,
218
+ target: TailTarget
219
+ ): Effect.Effect<ReadonlyArray<TranscriptEntry> | undefined> =>
220
+ Effect.gen(function* () {
221
+ const names = files.list(directory).filter((name) => name.endsWith(".jsonl"))
222
+ if (names.length === 0) {
223
+ return undefined
224
+ }
225
+ const wanted = "lane" in target ? [transcriptFileOf(target.lane)] : names
226
+ const all: Array<TranscriptEntry> = []
227
+ for (const name of wanted) {
228
+ const text = yield* files.read(join(directory, name))
229
+ all.push(...parseTranscript(text ?? ""))
230
+ }
231
+ return "lane" in target ? all : [...all].sort((left, right) => left.at - right.at)
232
+ })
233
+
234
+ /**
235
+ * `rows` lines of a tail, ending `back` lines before its end (scrolled), or
236
+ * `undefined` when the run has no transcript.
237
+ */
238
+ export const tailLinesOf = (
239
+ entries: ReadonlyArray<TranscriptEntry> | undefined,
240
+ target: TailTarget,
241
+ role: string | undefined,
242
+ width: number,
243
+ rows: number,
244
+ back = 0
245
+ ): ReadonlyArray<string> | undefined => {
246
+ if (entries === undefined) {
247
+ return undefined
248
+ }
249
+ const lines = renderTranscript(entries, {
250
+ width,
251
+ ...(role === undefined ? {} : { role }),
252
+ ...("executor" in target ? { executor: target.executor } : {})
253
+ })
254
+ const end = Math.max(Math.min(rows, lines.length), lines.length - back)
255
+ return lines.slice(Math.max(0, end - rows), end)
256
+ }
257
+
258
+ export const nodeTranscriptFiles: TranscriptFiles = {
259
+ read: (path) =>
260
+ Effect.sync(() => {
261
+ try {
262
+ return readFileSync(path, "utf8")
263
+ } catch {
264
+ return undefined
265
+ }
266
+ }),
267
+ list: (directory) => {
268
+ try {
269
+ return readdirSync(directory)
270
+ } catch {
271
+ return []
272
+ }
273
+ }
274
+ }
package/src/Watch.ts CHANGED
@@ -18,12 +18,22 @@ import {
18
18
  onTreeKey,
19
19
  reduceTree,
20
20
  renderTree,
21
+ tailTargetOf,
21
22
  treeInputsOfTrace,
22
23
  type TreeInput,
23
24
  type TreeState,
24
25
  type TreeView
25
26
  } from "./AgentTree.ts"
26
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"
27
37
  import { nodeListTraces, TraceDirectoryName } from "./Costs.ts"
28
38
  import { nodePlainFileStore } from "./NodePlainFileStore.ts"
29
39
 
@@ -45,6 +55,8 @@ export interface WatchOptions {
45
55
  readonly speed?: number
46
56
  /** A live lane with no event for this long is marked idle (`LLM4TS_IDLE_AFTER`). */
47
57
  readonly idleAfterMs?: number
58
+ /** Print this story's or executor's transcript as it grows, instead of the tree. */
59
+ readonly tail?: string
48
60
  }
49
61
 
50
62
  export interface WatchOutput {
@@ -64,6 +76,8 @@ export interface WatchDependencies {
64
76
  readonly output: WatchOutput
65
77
  /** Key names as `onTreeKey` reads them: "up", "enter", "q", … */
66
78
  readonly keys: Stream.Stream<string>
79
+ /** A run's transcript files (`--transcript`); the disk by default. */
80
+ readonly transcripts?: TranscriptFiles
67
81
  }
68
82
 
69
83
  export class WatchTargetMissing extends Schema.TaggedError<WatchTargetMissing>()(
@@ -184,20 +198,42 @@ const readInputs = (
184
198
  const pollInterval = Duration.millis(500)
185
199
  const replayGapCap = 2_000
186
200
 
187
- const draw = (state: TreeState, view: TreeView, output: WatchOutput, height?: number): string =>
201
+ const draw = (
202
+ state: TreeState,
203
+ view: TreeView,
204
+ output: WatchOutput,
205
+ height?: number,
206
+ tail?: ReadonlyArray<string>
207
+ ): string =>
188
208
  renderTree(state, {
189
209
  width: Math.max(90, output.columns() ?? 90),
190
210
  colour: output.colour,
191
211
  view,
192
- ...(height === undefined ? {} : { height })
212
+ ...(height === undefined ? {} : { height }),
213
+ ...(tail === undefined ? {} : { tail })
193
214
  }).join("\n")
194
215
 
195
216
  /** A full-screen frame: no taller than the terminal. */
196
217
  const screenLines = (
197
218
  state: TreeState,
198
219
  view: TreeView,
199
- output: WatchOutput
200
- ): ReadonlyArray<string> => draw(state, view, output, output.rows()).split("\n")
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
+ })
201
237
 
202
238
  export const makeWatchProgram = Effect.fn("@llm4ts/runner/Watch.make")(function* (
203
239
  options: WatchOptions,
@@ -210,6 +246,39 @@ export const makeWatchProgram = Effect.fn("@llm4ts/runner/Watch.make")(function*
210
246
  inputs.reduce(reduceTree, start)
211
247
  )
212
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
+
213
282
  if (!output.interactive) {
214
283
  const state = yield* Effect.flatMap(readInputs(target, files), fold)
215
284
  return yield* output.write(`${draw(state, initialView, output)}\n`)
@@ -264,7 +333,7 @@ export const makeWatchProgram = Effect.fn("@llm4ts/runner/Watch.make")(function*
264
333
  ? reduceTree(folded, { _tag: "Tick", at: yield* Clock.currentTimeMillis })
265
334
  : folded
266
335
  const text = yield* painter.paint(
267
- screenLines(state, yield* Ref.get(view), output),
336
+ screenLines(state, yield* Ref.get(view), output, yield* tailFor(yield* Ref.get(view))),
268
337
  `${output.columns()}x${output.rows()}`
269
338
  )
270
339
  if (text.length > 0) {
@@ -322,3 +391,55 @@ export const nodeWatchDependencies = (
322
391
  keys: nodeTreeKeys("detach")
323
392
  }
324
393
  }
394
+
395
+ // ── tail ────────────────────────────────────────────────────────────────────
396
+
397
+ /**
398
+ * `llm4ts watch --tail <story|executor>`: the transcript printed as it grows,
399
+ * like `tail -f` — a call's reply streams in as written — until the run ends.
400
+ * A name with its own file is a story; any other is an executor.
401
+ */
402
+ const followTail = (
403
+ name: string,
404
+ directory: string,
405
+ transcripts: TranscriptFiles,
406
+ output: WatchOutput,
407
+ ended: () => Effect.Effect<boolean, FlowError>
408
+ ): Effect.Effect<void, FlowError> =>
409
+ Effect.gen(function* () {
410
+ const target: TailTarget = transcripts.list(directory).includes(transcriptFileOf(name))
411
+ ? { lane: name }
412
+ : { executor: name }
413
+ const width = Math.max(60, output.columns() ?? 100)
414
+ let shown = 0
415
+ let start: number | undefined
416
+ const wanted = new Set<string>()
417
+ let replying: string | undefined
418
+ while (true) {
419
+ const done = yield* ended()
420
+ const entries = (yield* loadTranscript(transcripts, directory, target)) ?? []
421
+ for (const entry of entries.slice(shown)) {
422
+ start = start ?? entry.at
423
+ if (entry._tag === "Call" && ("lane" in target || entry.executor === target.executor)) {
424
+ wanted.add(entry.call)
425
+ }
426
+ if (!wanted.has(entry.call)) {
427
+ continue
428
+ }
429
+ if (entry._tag === "Reply") {
430
+ // A reply streams: its text as it arrived, on one growing paragraph.
431
+ yield* output.write(`${replying === entry.call ? "" : "◀ "}${entry.text}`)
432
+ replying = entry.call
433
+ continue
434
+ }
435
+ const lines = renderTranscript([entry], { width, since: start })
436
+ yield* output.write(`${replying === undefined ? "" : "\n"}${lines.join("\n")}\n`)
437
+ replying = undefined
438
+ }
439
+ shown = entries.length
440
+ if (done) {
441
+ return
442
+ }
443
+ yield* Effect.sleep(pollInterval)
444
+ }
445
+ })