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.
- package/README.md +6 -6
- package/package.json +5 -3
- package/scripts/require-parallel.ts +41 -0
- package/src/agent/index.ts +55 -6
- package/src/agent/live-sessions.ts +34 -0
- package/src/agent/plugin-tools.ts +2 -0
- package/src/agent/session-meta.ts +21 -2
- package/src/agent/subagent-completion-reminder.ts +89 -0
- package/src/agent/subagents.ts +3 -2
- package/src/agent/system-prompt.ts +10 -8
- package/src/bundled-plugins/explorer/explorer.ts +2 -2
- package/src/bundled-plugins/guard/index.ts +14 -1
- package/src/bundled-plugins/guard/policies/managed-config.ts +43 -13
- package/src/bundled-plugins/guard/policies/memory-retrieval-cache-write.ts +37 -0
- package/src/bundled-plugins/guard/policies/memory-topics-delete.ts +67 -0
- package/src/bundled-plugins/guard/policies/memory-topics-write.ts +33 -0
- package/src/bundled-plugins/guard/policies/non-workspace-write.ts +8 -2
- package/src/bundled-plugins/guard/policy.ts +7 -0
- package/src/bundled-plugins/memory/README.md +76 -62
- package/src/bundled-plugins/memory/append-tool.ts +3 -2
- package/src/bundled-plugins/memory/citation-superset.ts +49 -11
- package/src/bundled-plugins/memory/citations.ts +19 -8
- package/src/bundled-plugins/memory/delete-tool.ts +57 -0
- package/src/bundled-plugins/memory/dreaming-state.ts +1 -1
- package/src/bundled-plugins/memory/dreaming.ts +364 -146
- package/src/bundled-plugins/memory/frontmatter.ts +165 -0
- package/src/bundled-plugins/memory/index.ts +236 -16
- package/src/bundled-plugins/memory/injection-plan.ts +15 -0
- package/src/bundled-plugins/memory/load-memory.ts +102 -103
- package/src/bundled-plugins/memory/load-shards.ts +156 -0
- package/src/bundled-plugins/memory/memory-logger.ts +16 -15
- package/src/bundled-plugins/memory/memory-retrieval.ts +105 -0
- package/src/bundled-plugins/memory/migration.ts +282 -1
- package/src/bundled-plugins/memory/paths.ts +42 -0
- package/src/bundled-plugins/memory/search-tool.ts +232 -0
- package/src/bundled-plugins/memory/secret-detector.ts +2 -2
- package/src/bundled-plugins/memory/shard-snapshot.ts +51 -0
- package/src/bundled-plugins/memory/slug.ts +59 -0
- package/src/bundled-plugins/memory/stream-io.ts +110 -1
- package/src/bundled-plugins/memory/strength.ts +3 -3
- package/src/bundled-plugins/memory/topics.ts +70 -16
- package/src/bundled-plugins/security/index.ts +24 -0
- package/src/bundled-plugins/security/permissions.ts +4 -0
- package/src/bundled-plugins/security/policies/cron-promotion.ts +349 -0
- package/src/bundled-plugins/security/policies/git-exfil.ts +2 -0
- package/src/bundled-plugins/security/policies/prompt-injection.ts +3 -0
- package/src/bundled-plugins/security/policies/role-promotion.ts +419 -0
- package/src/bundled-plugins/security/policies/system-prompt-leak.ts +1 -0
- package/src/channels/adapters/kakaotalk-attachment.ts +7 -17
- package/src/channels/adapters/kakaotalk.ts +64 -37
- package/src/channels/adapters/slack-bot-classify.ts +2 -27
- package/src/channels/index.ts +5 -0
- package/src/channels/router.ts +201 -17
- package/src/channels/subagent-completion-bridge.ts +84 -0
- package/src/cli/builtins.ts +1 -0
- package/src/cli/index.ts +1 -0
- package/src/cli/init.ts +122 -14
- package/src/cli/inspect.ts +151 -0
- package/src/cron/consumer.ts +1 -1
- package/src/init/dockerfile.ts +268 -4
- package/src/init/hatching.ts +5 -6
- package/src/init/kakaotalk-auth.ts +6 -47
- package/src/init/validate-api-key.ts +121 -0
- package/src/inspect/index.ts +213 -0
- package/src/inspect/label.ts +50 -0
- package/src/inspect/live.ts +221 -0
- package/src/inspect/render.ts +163 -0
- package/src/inspect/replay.ts +265 -0
- package/src/inspect/session-list.ts +160 -0
- package/src/inspect/types.ts +110 -0
- package/src/plugin/hooks.ts +23 -1
- package/src/plugin/index.ts +2 -0
- package/src/plugin/manager.ts +1 -1
- package/src/plugin/registry.ts +1 -1
- package/src/plugin/types.ts +10 -0
- package/src/run/channel-session-factory.ts +7 -1
- package/src/run/index.ts +87 -21
- package/src/secrets/kakao-renewal.ts +3 -47
- package/src/server/index.ts +241 -60
- package/src/shared/index.ts +3 -0
- package/src/shared/protocol.ts +49 -0
- package/src/skills/typeclaw-channel-kakaotalk/SKILL.md +9 -9
- package/src/skills/typeclaw-claude-code/SKILL.md +57 -39
- package/src/skills/typeclaw-claude-code/references/stop-hook.md +2 -0
- package/src/skills/typeclaw-claude-code/references/tmux-driving.md +102 -16
- package/src/skills/typeclaw-config/SKILL.md +1 -1
- package/src/skills/typeclaw-cron/SKILL.md +1 -1
- package/src/skills/typeclaw-memory/SKILL.md +16 -163
- package/src/skills/typeclaw-permissions/SKILL.md +2 -2
- package/src/skills/typeclaw-plugins/SKILL.md +25 -14
- package/src/test-helpers/wait-for.ts +7 -1
- 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
|
+
}
|