typeclaw 0.8.0 → 0.9.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 (92) hide show
  1. package/README.md +6 -6
  2. package/package.json +5 -3
  3. package/scripts/require-parallel.ts +41 -0
  4. package/src/agent/index.ts +55 -6
  5. package/src/agent/live-sessions.ts +34 -0
  6. package/src/agent/plugin-tools.ts +2 -0
  7. package/src/agent/session-meta.ts +21 -2
  8. package/src/agent/subagent-completion-reminder.ts +89 -0
  9. package/src/agent/subagents.ts +3 -2
  10. package/src/agent/system-prompt.ts +10 -8
  11. package/src/bundled-plugins/explorer/explorer.ts +2 -2
  12. package/src/bundled-plugins/guard/index.ts +14 -1
  13. package/src/bundled-plugins/guard/policies/managed-config.ts +43 -13
  14. package/src/bundled-plugins/guard/policies/memory-retrieval-cache-write.ts +37 -0
  15. package/src/bundled-plugins/guard/policies/memory-topics-delete.ts +67 -0
  16. package/src/bundled-plugins/guard/policies/memory-topics-write.ts +33 -0
  17. package/src/bundled-plugins/guard/policies/non-workspace-write.ts +8 -2
  18. package/src/bundled-plugins/guard/policy.ts +7 -0
  19. package/src/bundled-plugins/memory/README.md +76 -62
  20. package/src/bundled-plugins/memory/append-tool.ts +3 -2
  21. package/src/bundled-plugins/memory/citation-superset.ts +49 -11
  22. package/src/bundled-plugins/memory/citations.ts +19 -8
  23. package/src/bundled-plugins/memory/delete-tool.ts +57 -0
  24. package/src/bundled-plugins/memory/dreaming-state.ts +1 -1
  25. package/src/bundled-plugins/memory/dreaming.ts +364 -146
  26. package/src/bundled-plugins/memory/frontmatter.ts +165 -0
  27. package/src/bundled-plugins/memory/index.ts +236 -16
  28. package/src/bundled-plugins/memory/injection-plan.ts +15 -0
  29. package/src/bundled-plugins/memory/load-memory.ts +102 -103
  30. package/src/bundled-plugins/memory/load-shards.ts +156 -0
  31. package/src/bundled-plugins/memory/memory-logger.ts +16 -15
  32. package/src/bundled-plugins/memory/memory-retrieval.ts +105 -0
  33. package/src/bundled-plugins/memory/migration.ts +282 -1
  34. package/src/bundled-plugins/memory/paths.ts +42 -0
  35. package/src/bundled-plugins/memory/search-tool.ts +232 -0
  36. package/src/bundled-plugins/memory/secret-detector.ts +2 -2
  37. package/src/bundled-plugins/memory/shard-snapshot.ts +51 -0
  38. package/src/bundled-plugins/memory/slug.ts +59 -0
  39. package/src/bundled-plugins/memory/stream-io.ts +110 -1
  40. package/src/bundled-plugins/memory/strength.ts +3 -3
  41. package/src/bundled-plugins/memory/topics.ts +70 -16
  42. package/src/bundled-plugins/security/index.ts +24 -0
  43. package/src/bundled-plugins/security/permissions.ts +4 -0
  44. package/src/bundled-plugins/security/policies/cron-promotion.ts +349 -0
  45. package/src/bundled-plugins/security/policies/git-exfil.ts +2 -0
  46. package/src/bundled-plugins/security/policies/prompt-injection.ts +3 -0
  47. package/src/bundled-plugins/security/policies/role-promotion.ts +419 -0
  48. package/src/bundled-plugins/security/policies/system-prompt-leak.ts +1 -0
  49. package/src/channels/adapters/kakaotalk-attachment.ts +7 -17
  50. package/src/channels/adapters/kakaotalk.ts +64 -37
  51. package/src/channels/adapters/slack-bot-classify.ts +2 -27
  52. package/src/channels/index.ts +5 -0
  53. package/src/channels/router.ts +201 -17
  54. package/src/channels/subagent-completion-bridge.ts +84 -0
  55. package/src/cli/builtins.ts +1 -0
  56. package/src/cli/index.ts +1 -0
  57. package/src/cli/init.ts +122 -14
  58. package/src/cli/inspect.ts +151 -0
  59. package/src/cron/consumer.ts +1 -1
  60. package/src/init/dockerfile.ts +268 -4
  61. package/src/init/hatching.ts +5 -6
  62. package/src/init/kakaotalk-auth.ts +6 -47
  63. package/src/init/validate-api-key.ts +121 -0
  64. package/src/inspect/index.ts +213 -0
  65. package/src/inspect/label.ts +50 -0
  66. package/src/inspect/live.ts +221 -0
  67. package/src/inspect/render.ts +163 -0
  68. package/src/inspect/replay.ts +265 -0
  69. package/src/inspect/session-list.ts +160 -0
  70. package/src/inspect/types.ts +110 -0
  71. package/src/plugin/hooks.ts +23 -1
  72. package/src/plugin/index.ts +2 -0
  73. package/src/plugin/manager.ts +1 -1
  74. package/src/plugin/registry.ts +1 -1
  75. package/src/plugin/types.ts +10 -0
  76. package/src/run/channel-session-factory.ts +7 -1
  77. package/src/run/index.ts +87 -21
  78. package/src/secrets/kakao-renewal.ts +3 -47
  79. package/src/server/index.ts +241 -60
  80. package/src/shared/index.ts +3 -0
  81. package/src/shared/protocol.ts +49 -0
  82. package/src/skills/typeclaw-channel-kakaotalk/SKILL.md +9 -9
  83. package/src/skills/typeclaw-claude-code/SKILL.md +57 -39
  84. package/src/skills/typeclaw-claude-code/references/stop-hook.md +2 -0
  85. package/src/skills/typeclaw-claude-code/references/tmux-driving.md +102 -16
  86. package/src/skills/typeclaw-config/SKILL.md +1 -1
  87. package/src/skills/typeclaw-cron/SKILL.md +1 -1
  88. package/src/skills/typeclaw-memory/SKILL.md +16 -163
  89. package/src/skills/typeclaw-permissions/SKILL.md +2 -2
  90. package/src/skills/typeclaw-plugins/SKILL.md +25 -14
  91. package/src/test-helpers/wait-for.ts +7 -1
  92. package/typeclaw.schema.json +7 -0
@@ -0,0 +1,213 @@
1
+ import { join } from 'node:path'
2
+
3
+ import { originLabel, shortSessionId } from './label'
4
+ import { renderEvent } from './render'
5
+ import { replayJsonl } from './replay'
6
+ import type { SessionSummary } from './session-list'
7
+ import { isSessionIdShape, listSessions, resolveSession } from './session-list'
8
+ import type { InspectEvent, InspectFilter } from './types'
9
+ import { matchesFilter, parseDuration, parseFilter } from './types'
10
+
11
+ export { listSessions, resolveSession } from './session-list'
12
+ export type { SessionSummary } from './session-list'
13
+ export { originLabel, shortSessionId } from './label'
14
+ export { renderEvent } from './render'
15
+ export { replayJsonl } from './replay'
16
+ export { streamLive } from './live'
17
+ export { parseDuration, parseFilter } from './types'
18
+ export type { InspectCategory, InspectEvent, InspectFilter } from './types'
19
+
20
+ export type RunInspectOptions = {
21
+ agentDir: string
22
+ sessionIdOrPrefix?: string
23
+ filter?: string
24
+ since?: string
25
+ json?: boolean
26
+ color: boolean
27
+ selectSession: SelectSession
28
+ stdout: (line: string) => void
29
+ stderr: (line: string) => void
30
+ liveSource?: LiveSourceFactory
31
+ signal?: AbortSignal
32
+ }
33
+
34
+ export type SelectSession = (sessions: SessionSummary[]) => Promise<SessionSummary | null>
35
+
36
+ export type LiveSourceFactory = (opts: {
37
+ sessionId: string
38
+ sinceMs?: number
39
+ signal?: AbortSignal
40
+ onSubscribed?: (sessionLive: boolean) => void
41
+ }) => AsyncIterable<InspectEvent>
42
+
43
+ export type RunInspectResult = { ok: true; exitCode: 0 } | { ok: false; exitCode: number; reason: string }
44
+
45
+ export async function runInspect(opts: RunInspectOptions): Promise<RunInspectResult> {
46
+ const filterResult = parseFilter(opts.filter)
47
+ if (!filterResult.ok) return { ok: false, exitCode: 2, reason: filterResult.reason }
48
+ const filter = filterResult.filter
49
+
50
+ let sinceMs: number | undefined
51
+ if (opts.since !== undefined) {
52
+ const d = parseDuration(opts.since)
53
+ if (!d.ok) return { ok: false, exitCode: 2, reason: d.reason }
54
+ sinceMs = Date.now() - d.ms
55
+ }
56
+
57
+ const sessionsDir = join(opts.agentDir, 'sessions')
58
+
59
+ const summary = await chooseSession(opts, sessionsDir, sinceMs)
60
+ if (!summary.ok) return summary
61
+
62
+ await streamSession({
63
+ summary: summary.summary,
64
+ filter,
65
+ sinceMs,
66
+ json: opts.json === true,
67
+ color: opts.color,
68
+ stdout: opts.stdout,
69
+ stderr: opts.stderr,
70
+ ...(opts.liveSource !== undefined ? { liveSource: opts.liveSource } : {}),
71
+ ...(opts.signal !== undefined ? { signal: opts.signal } : {}),
72
+ })
73
+ return { ok: true, exitCode: 0 }
74
+ }
75
+
76
+ async function chooseSession(
77
+ opts: RunInspectOptions,
78
+ sessionsDir: string,
79
+ sinceMs: number | undefined,
80
+ ): Promise<{ ok: true; summary: SessionSummary } | { ok: false; exitCode: number; reason: string }> {
81
+ if (opts.sessionIdOrPrefix !== undefined) {
82
+ if (opts.json === true && !looksLikeSessionId(opts.sessionIdOrPrefix)) {
83
+ return {
84
+ ok: false,
85
+ exitCode: 2,
86
+ reason: `--json requires an explicit session id (got "${opts.sessionIdOrPrefix}")`,
87
+ }
88
+ }
89
+ const resolved = await resolveSession(sessionsDir, opts.sessionIdOrPrefix, opts.stderr)
90
+ if (resolved.ok) return { ok: true, summary: resolved.summary }
91
+ if (resolved.reason === 'ambiguous') {
92
+ const lines = ['Ambiguous session prefix matches multiple sessions:']
93
+ for (const m of resolved.matches) {
94
+ lines.push(` ${m.sessionId} ${m.origin === null ? '(unknown origin)' : originLabel(m.origin)}`)
95
+ }
96
+ lines.push('Use the full id or run `typeclaw inspect` without args.')
97
+ return { ok: false, exitCode: 2, reason: lines.join('\n') }
98
+ }
99
+ return { ok: false, exitCode: 1, reason: `No session matching "${opts.sessionIdOrPrefix}" in ${sessionsDir}/` }
100
+ }
101
+
102
+ if (opts.json === true) {
103
+ return { ok: false, exitCode: 2, reason: '--json requires an explicit session id (interactive picker is disabled)' }
104
+ }
105
+
106
+ const listOpts: Parameters<typeof listSessions>[0] = {
107
+ sessionsDir,
108
+ limit: 20,
109
+ onWarn: opts.stderr,
110
+ }
111
+ if (sinceMs !== undefined) listOpts.sinceMs = sinceMs
112
+ const sessions = await listSessions(listOpts)
113
+ if (sessions.length === 0) {
114
+ return {
115
+ ok: false,
116
+ exitCode: 1,
117
+ reason: `No sessions found in ${sessionsDir}/.\nStart a session with \`typeclaw tui\` or send a message from a configured channel.`,
118
+ }
119
+ }
120
+ const picked = await opts.selectSession(sessions)
121
+ if (picked === null) return { ok: false, exitCode: 130, reason: 'cancelled' }
122
+ return { ok: true, summary: picked }
123
+ }
124
+
125
+ async function streamSession(opts: {
126
+ summary: SessionSummary
127
+ filter: InspectFilter
128
+ sinceMs: number | undefined
129
+ json: boolean
130
+ color: boolean
131
+ stdout: (line: string) => void
132
+ stderr: (line: string) => void
133
+ liveSource?: LiveSourceFactory
134
+ signal?: AbortSignal
135
+ }): Promise<void> {
136
+ if (!opts.json) writeHeader(opts.summary, opts.color, opts.stdout)
137
+ const emit = (event: InspectEvent): void => {
138
+ if (opts.sinceMs !== undefined && event.ts > 0 && event.ts < opts.sinceMs) return
139
+ if (!matchesFilter(event, opts.filter)) return
140
+ if (opts.json) {
141
+ opts.stdout(JSON.stringify({ sessionId: opts.summary.sessionId, ...event }))
142
+ } else {
143
+ opts.stdout(renderEvent(event, { color: opts.color }))
144
+ }
145
+ }
146
+
147
+ for await (const event of replayJsonl(opts.summary.sessionFile, { onWarn: opts.stderr })) {
148
+ emit(event)
149
+ }
150
+
151
+ if (opts.liveSource === undefined) {
152
+ if (!opts.json) opts.stdout('─── end of transcript ───')
153
+ return
154
+ }
155
+
156
+ let sessionLive = false
157
+ const liveIter = opts.liveSource({
158
+ sessionId: opts.summary.sessionId,
159
+ ...(opts.sinceMs !== undefined ? { sinceMs: opts.sinceMs } : {}),
160
+ ...(opts.signal !== undefined ? { signal: opts.signal } : {}),
161
+ onSubscribed: (live) => {
162
+ sessionLive = live
163
+ },
164
+ })
165
+
166
+ let liveAnnounced = false
167
+ try {
168
+ for await (const event of liveIter) {
169
+ if (!liveAnnounced && !opts.json) {
170
+ opts.stdout(
171
+ divider(opts.color, sessionLive ? '─── live ───' : '─── live (session not in registry; broadcasts only) ───'),
172
+ )
173
+ liveAnnounced = true
174
+ }
175
+ emit(event)
176
+ }
177
+ } catch (err) {
178
+ opts.stderr(`live tail ended: ${err instanceof Error ? err.message : String(err)}`)
179
+ }
180
+ if (!opts.json) opts.stdout('─── end of transcript ───')
181
+ }
182
+
183
+ function divider(color: boolean, text: string): string {
184
+ if (color) return `\u001b[2m${text}\u001b[0m`
185
+ return text
186
+ }
187
+
188
+ function writeHeader(summary: SessionSummary, color: boolean, stdout: (line: string) => void): void {
189
+ const id = shortSessionId(summary.sessionId)
190
+ const label = summary.origin === null ? '(unknown origin)' : originLabel(summary.origin)
191
+ const started = formatDate(summary.mtimeMs)
192
+ const headerLine = `─── ${id} · ${label} · last activity ${started} ───`
193
+ if (color) stdout(`\u001b[2m${headerLine}\u001b[0m`)
194
+ else stdout(headerLine)
195
+ }
196
+
197
+ function formatDate(ms: number): string {
198
+ if (ms === 0) return '--'
199
+ const d = new Date(ms)
200
+ const yyyy = d.getFullYear()
201
+ const mm = String(d.getMonth() + 1).padStart(2, '0')
202
+ const dd = String(d.getDate()).padStart(2, '0')
203
+ const hh = String(d.getHours()).padStart(2, '0')
204
+ const mi = String(d.getMinutes()).padStart(2, '0')
205
+ const ss = String(d.getSeconds()).padStart(2, '0')
206
+ return `${yyyy}-${mm}-${dd} ${hh}:${mi}:${ss}`
207
+ }
208
+
209
+ const MIN_EXPLICIT_ID_LENGTH = 8
210
+
211
+ function looksLikeSessionId(value: string): boolean {
212
+ return value.length >= MIN_EXPLICIT_ID_LENGTH && isSessionIdShape(value)
213
+ }
@@ -0,0 +1,50 @@
1
+ import type { MinimalSessionOrigin } from '@/agent/session-meta'
2
+
3
+ const ADAPTER_DISPLAY: Record<string, string> = {
4
+ 'slack-bot': 'Slack',
5
+ 'discord-bot': 'Discord',
6
+ github: 'GitHub',
7
+ 'telegram-bot': 'Telegram',
8
+ kakaotalk: 'KakaoTalk',
9
+ }
10
+
11
+ const SLACK_CHAT_PREFIX_ADAPTERS = new Set(['slack-bot', 'discord-bot'])
12
+
13
+ export function originLabel(origin: MinimalSessionOrigin): string {
14
+ switch (origin.kind) {
15
+ case 'tui':
16
+ return 'TUI'
17
+ case 'cron':
18
+ return `Cron ${origin.jobId} (${origin.jobKind})`
19
+ case 'subagent':
20
+ return `Subagent ${origin.subagent} ← ${shortSessionId(origin.parentSessionId)}`
21
+ case 'channel':
22
+ return channelLabel(origin)
23
+ }
24
+ }
25
+
26
+ function channelLabel(origin: Extract<MinimalSessionOrigin, { kind: 'channel' }>): string {
27
+ const platform = ADAPTER_DISPLAY[origin.adapter] ?? origin.adapter
28
+ const chatPart = renderChat(origin)
29
+ const wsPart = renderWorkspace(origin)
30
+ if (wsPart === '') return `${platform} ${chatPart}`
31
+ return `${platform} ${wsPart}/${chatPart}`
32
+ }
33
+
34
+ function renderChat(origin: Extract<MinimalSessionOrigin, { kind: 'channel' }>): string {
35
+ if (origin.chatName !== undefined && origin.chatName !== '') {
36
+ const prefix = SLACK_CHAT_PREFIX_ADAPTERS.has(origin.adapter) ? '#' : ''
37
+ return `${prefix}${origin.chatName}`
38
+ }
39
+ return origin.chat
40
+ }
41
+
42
+ function renderWorkspace(origin: Extract<MinimalSessionOrigin, { kind: 'channel' }>): string {
43
+ if (origin.workspaceName !== undefined && origin.workspaceName !== '') return origin.workspaceName
44
+ return origin.workspace
45
+ }
46
+
47
+ export function shortSessionId(sessionId: string): string {
48
+ if (sessionId.length <= 12) return sessionId
49
+ return sessionId.slice(0, 12)
50
+ }
@@ -0,0 +1,221 @@
1
+ import type { InspectClientMessage, InspectFramePayload, InspectServerMessage } from '@/shared'
2
+
3
+ import type { InspectEvent } from './types'
4
+
5
+ export type StreamLiveOptions = {
6
+ url: string
7
+ sessionId: string
8
+ sinceMs?: number
9
+ signal?: AbortSignal
10
+ WebSocketImpl?: typeof WebSocket
11
+ onSubscribed?: (live: boolean) => void
12
+ onError?: (message: string) => void
13
+ }
14
+
15
+ export async function* streamLive(opts: StreamLiveOptions): AsyncGenerator<InspectEvent> {
16
+ const WS = opts.WebSocketImpl ?? WebSocket
17
+ const ws = new WS(opts.url)
18
+ const buffer: InspectEvent[] = []
19
+ let resolveNext: ((value: { event: InspectEvent | null; done: boolean }) => void) | null = null
20
+ let closed = false
21
+ let pendingError: string | null = null
22
+
23
+ const accumulators = new Map<string, string>()
24
+
25
+ const wake = (): void => {
26
+ if (resolveNext !== null) {
27
+ const fn = resolveNext
28
+ resolveNext = null
29
+ const next = buffer.shift() ?? null
30
+ fn({ event: next, done: closed && buffer.length === 0 && next === null })
31
+ }
32
+ }
33
+
34
+ ws.addEventListener('message', (e) => {
35
+ let msg: InspectServerMessage
36
+ try {
37
+ msg = JSON.parse(String((e as MessageEvent).data)) as InspectServerMessage
38
+ } catch {
39
+ return
40
+ }
41
+ if (msg.type === 'subscribed') {
42
+ opts.onSubscribed?.(msg.sessionLive)
43
+ return
44
+ }
45
+ if (msg.type === 'error') {
46
+ opts.onError?.(msg.message)
47
+ pendingError = msg.message
48
+ closed = true
49
+ try {
50
+ ws.close()
51
+ } catch {
52
+ /* ignore */
53
+ }
54
+ wake()
55
+ return
56
+ }
57
+ if (msg.type !== 'frame') return
58
+ const event = frameToEvent(msg.payload, msg.ts, accumulators)
59
+ if (event !== null) {
60
+ buffer.push(event)
61
+ wake()
62
+ }
63
+ })
64
+
65
+ const onOpen = new Promise<void>((resolve, reject) => {
66
+ ws.addEventListener('open', () => resolve(), { once: true })
67
+ ws.addEventListener('error', () => reject(new Error('websocket connection failed')), { once: true })
68
+ })
69
+ ws.addEventListener('close', () => {
70
+ closed = true
71
+ wake()
72
+ })
73
+
74
+ if (opts.signal !== undefined) {
75
+ if (opts.signal.aborted) {
76
+ try {
77
+ ws.close()
78
+ } catch {
79
+ /* ignore */
80
+ }
81
+ } else {
82
+ opts.signal.addEventListener(
83
+ 'abort',
84
+ () => {
85
+ closed = true
86
+ try {
87
+ ws.close()
88
+ } catch {
89
+ /* ignore */
90
+ }
91
+ wake()
92
+ },
93
+ { once: true },
94
+ )
95
+ }
96
+ }
97
+
98
+ try {
99
+ await onOpen
100
+ } catch (err) {
101
+ closed = true
102
+ throw err
103
+ }
104
+
105
+ const subscribe: InspectClientMessage = {
106
+ type: 'subscribe',
107
+ sessionId: opts.sessionId,
108
+ ...(opts.sinceMs !== undefined ? { sinceMs: opts.sinceMs } : {}),
109
+ }
110
+ ws.send(JSON.stringify(subscribe))
111
+
112
+ while (true) {
113
+ if (buffer.length > 0) {
114
+ const next = buffer.shift()!
115
+ yield next
116
+ continue
117
+ }
118
+ if (closed) {
119
+ if (pendingError !== null) throw new Error(pendingError)
120
+ return
121
+ }
122
+ const { event, done } = await new Promise<{ event: InspectEvent | null; done: boolean }>((resolve) => {
123
+ resolveNext = resolve
124
+ })
125
+ if (event !== null) yield event
126
+ if (done) {
127
+ if (pendingError !== null) throw new Error(pendingError)
128
+ return
129
+ }
130
+ }
131
+ }
132
+
133
+ function frameToEvent(
134
+ payload: InspectFramePayload,
135
+ ts: number,
136
+ accumulators: Map<string, string>,
137
+ ): InspectEvent | null {
138
+ switch (payload.kind) {
139
+ case 'text_delta': {
140
+ const existing = accumulators.get(payload.sessionId) ?? ''
141
+ accumulators.set(payload.sessionId, existing + payload.delta)
142
+ return null
143
+ }
144
+ case 'tool_start':
145
+ return {
146
+ cat: 'tool',
147
+ ts,
148
+ phase: 'start',
149
+ toolCallId: payload.toolCallId,
150
+ name: payload.name,
151
+ ...(payload.args !== undefined ? { args: payload.args } : {}),
152
+ }
153
+ case 'tool_end':
154
+ return {
155
+ cat: 'tool',
156
+ ts,
157
+ phase: 'end',
158
+ toolCallId: payload.toolCallId,
159
+ name: payload.name,
160
+ ...(payload.result !== undefined ? { result: payload.result } : {}),
161
+ isError: payload.isError,
162
+ durationMs: payload.durationMs,
163
+ }
164
+ case 'message_end':
165
+ return messageEndToEvents(payload, ts, accumulators)
166
+ case 'broadcast':
167
+ return {
168
+ cat: 'broadcast',
169
+ ts,
170
+ payload: payload.payload,
171
+ ...(payload.meta !== undefined ? { meta: payload.meta } : {}),
172
+ }
173
+ case 'cron-fire':
174
+ return { cat: 'cron-fire', ts, jobId: payload.jobId, payload: payload.payload }
175
+ default:
176
+ return null
177
+ }
178
+ }
179
+
180
+ function messageEndToEvents(
181
+ payload: Extract<InspectFramePayload, { kind: 'message_end' }>,
182
+ ts: number,
183
+ accumulators: Map<string, string>,
184
+ ): InspectEvent | null {
185
+ if (payload.role === 'assistant') {
186
+ const text = takeAccumulator(accumulators, payload.sessionId)
187
+ if (text !== null && text !== '') {
188
+ const event: InspectEvent = {
189
+ cat: 'assistant',
190
+ ts,
191
+ text,
192
+ ...(payload.provider !== undefined ? { provider: payload.provider } : {}),
193
+ ...(payload.model !== undefined ? { model: payload.model } : {}),
194
+ }
195
+ if (payload.errorMessage !== undefined) {
196
+ return event
197
+ }
198
+ return event
199
+ }
200
+ if (payload.errorMessage !== undefined) {
201
+ return { cat: 'error', ts, message: payload.errorMessage }
202
+ }
203
+ if (payload.usage !== undefined && (payload.usage.totalTokens > 0 || payload.stopReason !== undefined)) {
204
+ return {
205
+ cat: 'done',
206
+ ts,
207
+ ...(payload.stopReason !== undefined ? { stopReason: payload.stopReason } : {}),
208
+ ...payload.usage,
209
+ }
210
+ }
211
+ return null
212
+ }
213
+ return null
214
+ }
215
+
216
+ function takeAccumulator(accumulators: Map<string, string>, sessionId: string): string | null {
217
+ const value = accumulators.get(sessionId)
218
+ if (value === undefined) return null
219
+ accumulators.delete(sessionId)
220
+ return value
221
+ }
@@ -0,0 +1,163 @@
1
+ import { styleText } from 'node:util'
2
+
3
+ import { originLabel } from './label'
4
+ import type { InspectEvent } from './types'
5
+
6
+ export type RenderOptions = {
7
+ color: boolean
8
+ maxTextLength?: number
9
+ }
10
+
11
+ export function renderEvent(event: InspectEvent, opts: RenderOptions): string {
12
+ const time = renderTime(event.ts, opts)
13
+ const tag = renderTag(event, opts)
14
+ const body = renderBody(event, opts)
15
+ return `${time} ${tag} ${body}`
16
+ }
17
+
18
+ const DEFAULT_MAX_TEXT = 200
19
+
20
+ function renderTime(ts: number, opts: RenderOptions): string {
21
+ if (ts === 0) return tint(opts, 'dim', '--:--:--')
22
+ const d = new Date(ts)
23
+ const hh = String(d.getHours()).padStart(2, '0')
24
+ const mm = String(d.getMinutes()).padStart(2, '0')
25
+ const ss = String(d.getSeconds()).padStart(2, '0')
26
+ return tint(opts, 'dim', `${hh}:${mm}:${ss}`)
27
+ }
28
+
29
+ function renderTag(event: InspectEvent, opts: RenderOptions): string {
30
+ switch (event.cat) {
31
+ case 'meta':
32
+ return tint(opts, 'magenta', padEnd('meta', 9))
33
+ case 'user':
34
+ return tint(opts, 'cyan', padEnd('user', 9))
35
+ case 'assistant':
36
+ return tint(opts, 'green', padEnd('assist', 9))
37
+ case 'tool':
38
+ return tint(opts, 'yellow', padEnd(event.phase === 'start' ? 'tool ▸' : 'tool ◂', 9))
39
+ case 'error':
40
+ return tint(opts, 'red', padEnd('error', 9))
41
+ case 'done':
42
+ return tint(opts, 'gray', padEnd('done', 9))
43
+ case 'broadcast':
44
+ return tint(opts, 'magenta', padEnd('bcast', 9))
45
+ case 'cron-fire':
46
+ return tint(opts, 'magenta', padEnd('cron', 9))
47
+ }
48
+ }
49
+
50
+ function renderBody(event: InspectEvent, opts: RenderOptions): string {
51
+ switch (event.cat) {
52
+ case 'meta':
53
+ return `origin: ${originLabel(event.origin)}`
54
+ case 'user':
55
+ return truncate(singleLine(event.text), opts.maxTextLength ?? DEFAULT_MAX_TEXT)
56
+ case 'assistant':
57
+ return truncate(singleLine(event.text), opts.maxTextLength ?? DEFAULT_MAX_TEXT)
58
+ case 'tool': {
59
+ if (event.phase === 'start') {
60
+ return `${event.name}(${renderArgs(event.args)})`
61
+ }
62
+ const dur = formatDuration(event.durationMs ?? 0)
63
+ const status = event.isError ? tint(opts, 'red', 'error') : 'ok'
64
+ const result = renderResult(event.result, opts.maxTextLength ?? DEFAULT_MAX_TEXT)
65
+ return `${event.name} → ${status}${result ? ` ${result}` : ''} (${dur})`
66
+ }
67
+ case 'error':
68
+ return tint(opts, 'red', truncate(singleLine(event.message), opts.maxTextLength ?? DEFAULT_MAX_TEXT))
69
+ case 'done':
70
+ return renderDone(event, opts)
71
+ case 'broadcast':
72
+ return renderBroadcastBody(event.payload, opts.maxTextLength ?? DEFAULT_MAX_TEXT)
73
+ case 'cron-fire':
74
+ return `${event.jobId} fired`
75
+ }
76
+ }
77
+
78
+ function renderBroadcastBody(payload: unknown, maxLen: number): string {
79
+ if (payload !== null && typeof payload === 'object') {
80
+ const kind = (payload as { kind?: unknown }).kind
81
+ if (typeof kind === 'string') {
82
+ const rest = renderArgs(payload)
83
+ return rest === '' ? kind : `${kind} ${rest}`
84
+ }
85
+ }
86
+ try {
87
+ const compact = JSON.stringify(payload)
88
+ if (compact === undefined) return ''
89
+ if (compact.length <= maxLen) return compact
90
+ return `${compact.slice(0, maxLen)}…`
91
+ } catch {
92
+ return ''
93
+ }
94
+ }
95
+
96
+ function renderDone(event: Extract<InspectEvent, { cat: 'done' }>, opts: RenderOptions): string {
97
+ const parts: string[] = []
98
+ if (event.totalTokens > 0) parts.push(`tokens: ${event.input} in / ${event.output} out`)
99
+ if (event.cost > 0) parts.push(`$${event.cost.toFixed(4)}`)
100
+ if (event.stopReason !== undefined) parts.push(`stop=${event.stopReason}`)
101
+ return tint(opts, 'dim', parts.join(' · ') || '(no usage)')
102
+ }
103
+
104
+ function renderArgs(args: unknown): string {
105
+ if (args === undefined) return ''
106
+ if (typeof args === 'string') return JSON.stringify(args)
107
+ try {
108
+ const compact = JSON.stringify(args)
109
+ if (compact === undefined) return ''
110
+ if (compact.length <= 120) return compact
111
+ return `${compact.slice(0, 120)}…`
112
+ } catch {
113
+ return '<unserializable>'
114
+ }
115
+ }
116
+
117
+ function renderResult(result: unknown, maxLen: number): string {
118
+ if (result === undefined || result === null) return ''
119
+ if (typeof result === 'string') {
120
+ const text = singleLine(result)
121
+ if (text === '') return ''
122
+ return `"${truncate(text, Math.min(80, maxLen))}"`
123
+ }
124
+ if (typeof result === 'number' || typeof result === 'boolean') return String(result)
125
+ try {
126
+ const compact = JSON.stringify(result)
127
+ if (compact === undefined) return ''
128
+ if (compact.length <= 80) return compact
129
+ return `${compact.slice(0, 80)}…`
130
+ } catch {
131
+ return ''
132
+ }
133
+ }
134
+
135
+ function truncate(text: string, maxLen: number): string {
136
+ if (text.length <= maxLen) return text
137
+ return `${text.slice(0, maxLen)}…`
138
+ }
139
+
140
+ function singleLine(text: string): string {
141
+ return text.replace(/\s+/g, ' ').trim()
142
+ }
143
+
144
+ function padEnd(text: string, width: number): string {
145
+ if (text.length >= width) return text
146
+ return text + ' '.repeat(width - text.length)
147
+ }
148
+
149
+ function formatDuration(ms: number): string {
150
+ if (ms < 1000) return `${ms}ms`
151
+ const s = ms / 1000
152
+ if (s < 60) return `${s.toFixed(1)}s`
153
+ const m = Math.floor(s / 60)
154
+ const sec = Math.round(s % 60)
155
+ return `${m}m${sec}s`
156
+ }
157
+
158
+ type ColorName = 'dim' | 'magenta' | 'cyan' | 'green' | 'yellow' | 'red' | 'gray'
159
+
160
+ function tint(opts: RenderOptions, color: ColorName, text: string): string {
161
+ if (!opts.color) return text
162
+ return styleText(color, text)
163
+ }