@spunto/build 0.21.1 → 0.23.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,156 @@
1
+ // What the agent is writing *right now*, before it has finished saying it.
2
+ //
3
+ // The vocabulary (`agent-stream.ts`) is made of finished facts: a message lands once, whole. A
4
+ // harness that streams tokens says the same message a second time, piecemeal, ahead of it —
5
+ // Claude's `stream_event` lines (`includePartialMessages` in the Agent SDK,
6
+ // `--include-partial-messages` on the CLI). Those pieces are **not events**: storing one row per
7
+ // token would bury a session under thousands of rows that the finished message then repeats in
8
+ // full. So they fold into one transient value instead — the draft — which the finished message
9
+ // replaces the moment it lands, and which nothing persists.
10
+ //
11
+ // Pure like the rest of the package: lines in, draft out. Where the draft is kept between two
12
+ // reads (memory, for the API) is the caller's business.
13
+
14
+ import type { AgentProtocol } from "./agent-stream"
15
+
16
+ /** The text block being written. `null` = nothing in flight. */
17
+ export type AgentDraft = { text: string; truncated: boolean }
18
+
19
+ /**
20
+ * Same ceiling as a stored `message` (`clip` in `agent-stream.ts`): a draft that ran past what
21
+ * the finished message will keep would visibly shrink the instant it lands.
22
+ */
23
+ const MAX_DRAFT = 8_000
24
+
25
+ function append(draft: AgentDraft | null, text: string): AgentDraft {
26
+ const base = draft ?? { text: "", truncated: false }
27
+ if (base.truncated) return base
28
+ const next = base.text + text
29
+ return next.length > MAX_DRAFT ? { text: next.slice(0, MAX_DRAFT), truncated: true } : { text: next, truncated: false }
30
+ }
31
+
32
+ /** Claude's dialects print token deltas; Codex's prints the message so far on `item.updated`; the contract, `message.delta`. */
33
+ function streamsPartials(protocol: AgentProtocol): boolean {
34
+ return (
35
+ protocol === "agent-v1" ||
36
+ protocol === "claude-runner" ||
37
+ protocol === "claude-stream" ||
38
+ protocol === "claude-json" ||
39
+ protocol === "codex-runner"
40
+ )
41
+ }
42
+
43
+ /** What closes a draft in the contract: the finished text, or anything that comes after it in the turn. */
44
+ const CLOSES_DRAFT = new Set(["message", "thinking", "tool.call", "tool.result", "session.ended"])
45
+
46
+ /**
47
+ * The contract (`agent-v1`): `message.delta` with `start` opens a fresh draft, without it grows the
48
+ * one in flight. The runner already chose what is a draft (a subagent's words are not), so this
49
+ * only folds — whichever harness it was.
50
+ */
51
+ function foldContract(lines: string[], draft: AgentDraft | null): AgentDraft | null {
52
+ let current = draft
53
+ for (const line of lines) {
54
+ const trimmed = line.trim()
55
+ if (!trimmed.startsWith("{")) continue
56
+ // Cheap pre-filter, before parsing a line that may carry megabytes.
57
+ if (!trimmed.includes('"message') && !trimmed.includes('"thinking"') && !trimmed.includes('"tool.') && !trimmed.includes('"session.ended"')) continue
58
+ let obj: Record<string, unknown>
59
+ try {
60
+ const parsed = JSON.parse(trimmed)
61
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) continue
62
+ obj = parsed as Record<string, unknown>
63
+ } catch {
64
+ continue
65
+ }
66
+ if (obj.type === "message.delta" && typeof obj.text === "string") {
67
+ current = append(obj.start === true ? null : current, obj.text)
68
+ } else if (typeof obj.type === "string" && CLOSES_DRAFT.has(obj.type)) {
69
+ current = null
70
+ }
71
+ }
72
+ return current
73
+ }
74
+
75
+ /**
76
+ * Codex: an `agent_message` item carries its whole text so far, not a delta — `item.started` and
77
+ * `item.updated` *are* the draft, `item.completed` (stored as the `message`) or the end of the
78
+ * turn closes it. Whether the SDK sends updates of a message mid-write is its business; the runner
79
+ * prints every event it gets, so this costs nothing when it does not.
80
+ */
81
+ function foldCodex(lines: string[], draft: AgentDraft | null): AgentDraft | null {
82
+ let current = draft
83
+ for (const line of lines) {
84
+ const trimmed = line.trim()
85
+ if (!trimmed.startsWith("{")) continue
86
+ if (!trimmed.includes('"agent_message"') && !trimmed.includes('"turn.')) continue
87
+ let obj: Record<string, unknown>
88
+ try {
89
+ const parsed = JSON.parse(trimmed)
90
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) continue
91
+ obj = parsed as Record<string, unknown>
92
+ } catch {
93
+ continue
94
+ }
95
+ if (obj.type === "turn.completed" || obj.type === "turn.failed") {
96
+ current = null
97
+ continue
98
+ }
99
+ const item = (obj.item ?? {}) as Record<string, unknown>
100
+ if (item.type !== "agent_message") continue
101
+ if (obj.type === "item.completed") current = null
102
+ else if ((obj.type === "item.started" || obj.type === "item.updated") && typeof item.text === "string")
103
+ current = item.text ? append(null, item.text) : null
104
+ }
105
+ return current
106
+ }
107
+
108
+ /**
109
+ * Fold whole lines of a session's stdout into the draft, starting from `draft` (the value left
110
+ * by the previous read). Lines that say nothing about the text in flight leave it as it was.
111
+ *
112
+ * - a text block starting opens a fresh draft — one draft is one block, as one stored `message`
113
+ * is one block;
114
+ * - a `text_delta` grows it;
115
+ * - the finished `assistant` message, or the turn's `result`, closes it: from there on the
116
+ * timeline has the real thing.
117
+ *
118
+ * A subagent's partials (`parent_tool_use_id` set) are skipped: what it writes is not what the
119
+ * session says to the reader, and interleaving it would garble the draft of the main thread.
120
+ */
121
+ export function foldDraft(protocol: AgentProtocol, lines: string[], draft: AgentDraft | null): AgentDraft | null {
122
+ if (!streamsPartials(protocol)) return null
123
+ if (protocol === "agent-v1") return foldContract(lines, draft)
124
+ if (protocol === "codex-runner") return foldCodex(lines, draft)
125
+ let current = draft
126
+ for (const line of lines) {
127
+ const trimmed = line.trim()
128
+ // Cheap pre-filter: most lines are not about the draft, and some carry megabytes.
129
+ if (!trimmed.startsWith("{")) continue
130
+ if (!trimmed.includes('"stream_event"') && !trimmed.includes('"assistant"') && !trimmed.includes('"result"')) continue
131
+ let obj: Record<string, unknown>
132
+ try {
133
+ const parsed = JSON.parse(trimmed)
134
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) continue
135
+ obj = parsed as Record<string, unknown>
136
+ } catch {
137
+ continue
138
+ }
139
+ if (obj.parent_tool_use_id) continue
140
+
141
+ if (obj.type === "assistant" || obj.type === "result") {
142
+ current = null
143
+ continue
144
+ }
145
+ if (obj.type !== "stream_event") continue
146
+ const event = (obj.event ?? {}) as Record<string, unknown>
147
+ if (event.type === "content_block_start") {
148
+ const block = (event.content_block ?? {}) as Record<string, unknown>
149
+ if (block.type === "text") current = append(null, typeof block.text === "string" ? block.text : "")
150
+ } else if (event.type === "content_block_delta") {
151
+ const delta = (event.delta ?? {}) as Record<string, unknown>
152
+ if (delta.type === "text_delta" && typeof delta.text === "string") current = append(current, delta.text)
153
+ }
154
+ }
155
+ return current
156
+ }
@@ -33,6 +33,9 @@ export {
33
33
  TASK_EVENT_TYPES,
34
34
  userTurnLine,
35
35
  } from "./agent-stream"
36
+ // The text block a streaming harness is still writing — folded from its partial messages, never stored.
37
+ export { foldDraft } from "./draft"
38
+ export type { AgentDraft } from "./draft"
36
39
  // Turns that ended on a usage/rate limit rather than on finished work, and when the limit lifts.
37
40
  export { claudeTurnLimit, resetFromText, TURN_LIMIT_KINDS } from "./turn-limit"
38
41
  export type { TurnLimit, TurnLimitKind } from "./turn-limit"