typeclaw 0.1.5 → 0.2.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 (128) hide show
  1. package/README.md +14 -12
  2. package/auth.schema.json +41 -0
  3. package/cron.schema.json +8 -0
  4. package/package.json +1 -1
  5. package/secrets.schema.json +41 -0
  6. package/src/agent/auth.ts +45 -22
  7. package/src/agent/index.ts +189 -19
  8. package/src/agent/multimodal/index.ts +12 -0
  9. package/src/agent/multimodal/look-at.ts +185 -0
  10. package/src/agent/multimodal/looker.ts +145 -0
  11. package/src/agent/plugin-tools.ts +30 -1
  12. package/src/agent/session-origin.ts +194 -46
  13. package/src/agent/subagents.ts +57 -1
  14. package/src/agent/system-prompt.ts +1 -1
  15. package/src/agent/tool-result-budget.ts +121 -0
  16. package/src/bundled-plugins/backup/index.ts +23 -8
  17. package/src/bundled-plugins/backup/runner.ts +22 -0
  18. package/src/bundled-plugins/memory/README.md +7 -4
  19. package/src/bundled-plugins/memory/append-tool.ts +87 -61
  20. package/src/bundled-plugins/memory/dreaming.ts +23 -9
  21. package/src/bundled-plugins/memory/find-entry-tool.ts +62 -0
  22. package/src/bundled-plugins/memory/fragment-parser.ts +19 -44
  23. package/src/bundled-plugins/memory/index.ts +91 -8
  24. package/src/bundled-plugins/memory/load-memory.ts +74 -34
  25. package/src/bundled-plugins/memory/memory-logger.ts +72 -29
  26. package/src/bundled-plugins/memory/migration.ts +276 -0
  27. package/src/bundled-plugins/memory/stream-events.ts +55 -0
  28. package/src/bundled-plugins/memory/stream-io.ts +63 -0
  29. package/src/bundled-plugins/memory/watermark.ts +48 -8
  30. package/src/bundled-plugins/security/index.ts +103 -10
  31. package/src/bundled-plugins/security/permissions.ts +12 -0
  32. package/src/bundled-plugins/security/policies/git-exfil.ts +51 -18
  33. package/src/bundled-plugins/tool-result-cap/README.md +9 -4
  34. package/src/bundled-plugins/tool-result-cap/cap-jsonl.ts +115 -0
  35. package/src/bundled-plugins/tool-result-cap/cap-result.ts +25 -13
  36. package/src/bundled-plugins/tool-result-cap/index.ts +16 -2
  37. package/src/channels/adapters/discord-bot-classify.ts +2 -6
  38. package/src/channels/adapters/discord-bot.ts +4 -45
  39. package/src/channels/adapters/kakaotalk-classify.ts +3 -7
  40. package/src/channels/adapters/kakaotalk.ts +28 -47
  41. package/src/channels/adapters/slack-bot-classify.ts +2 -6
  42. package/src/channels/adapters/slack-bot.ts +4 -50
  43. package/src/channels/adapters/telegram-bot-classify.ts +8 -10
  44. package/src/channels/adapters/telegram-bot.ts +3 -16
  45. package/src/channels/index.ts +3 -2
  46. package/src/channels/manager.ts +15 -1
  47. package/src/channels/persistence.ts +44 -10
  48. package/src/channels/router.ts +228 -19
  49. package/src/channels/schema.ts +6 -156
  50. package/src/cli/channel.ts +200 -4
  51. package/src/cli/compose-usage.ts +182 -0
  52. package/src/cli/compose.ts +33 -0
  53. package/src/cli/hostd.ts +49 -1
  54. package/src/cli/index.ts +4 -0
  55. package/src/cli/init.ts +799 -319
  56. package/src/cli/model.ts +244 -0
  57. package/src/cli/provider.ts +404 -0
  58. package/src/cli/reload.ts +6 -1
  59. package/src/cli/role.ts +156 -0
  60. package/src/cli/run.ts +3 -1
  61. package/src/cli/tui.ts +8 -1
  62. package/src/cli/usage-args.ts +47 -0
  63. package/src/cli/usage.ts +97 -0
  64. package/src/compose/index.ts +1 -0
  65. package/src/compose/usage.ts +65 -0
  66. package/src/config/config.ts +385 -12
  67. package/src/config/index.ts +7 -0
  68. package/src/config/models-mutation.ts +209 -0
  69. package/src/config/providers-mutation.ts +250 -0
  70. package/src/config/providers.ts +141 -2
  71. package/src/config/reloadable.ts +15 -4
  72. package/src/container/index.ts +5 -0
  73. package/src/container/require-running.ts +33 -0
  74. package/src/container/start.ts +39 -58
  75. package/src/cron/consumer.ts +22 -2
  76. package/src/cron/index.ts +45 -4
  77. package/src/cron/schema.ts +104 -0
  78. package/src/doctor/checks.ts +50 -33
  79. package/src/git/system-commit.ts +103 -0
  80. package/src/hostd/daemon.ts +16 -0
  81. package/src/hostd/kakao-renewal-manager.ts +223 -0
  82. package/src/hostd/paths.ts +7 -0
  83. package/src/init/dockerfile.ts +32 -6
  84. package/src/init/index.ts +190 -61
  85. package/src/init/kakaotalk-auth.ts +18 -1
  86. package/src/init/models-dev.ts +26 -1
  87. package/src/init/run-owner-claim.ts +77 -0
  88. package/src/permissions/builtins.ts +70 -0
  89. package/src/permissions/grant.ts +99 -0
  90. package/src/permissions/index.ts +29 -0
  91. package/src/permissions/match-rule.ts +305 -0
  92. package/src/permissions/permissions.ts +196 -0
  93. package/src/permissions/resolve.ts +80 -0
  94. package/src/permissions/schema.ts +79 -0
  95. package/src/plugin/context.ts +8 -4
  96. package/src/plugin/define.ts +2 -0
  97. package/src/plugin/index.ts +2 -0
  98. package/src/plugin/manager.ts +41 -0
  99. package/src/plugin/registry.ts +9 -0
  100. package/src/plugin/types.ts +35 -1
  101. package/src/role-claim/client.ts +182 -0
  102. package/src/role-claim/code.ts +53 -0
  103. package/src/role-claim/controller.ts +194 -0
  104. package/src/role-claim/index.ts +19 -0
  105. package/src/role-claim/match-rule.ts +43 -0
  106. package/src/role-claim/pending.ts +100 -0
  107. package/src/run/channel-session-factory.ts +76 -5
  108. package/src/run/index.ts +55 -6
  109. package/src/secrets/encryption.ts +116 -0
  110. package/src/secrets/kakao-renewal.ts +248 -0
  111. package/src/secrets/kakao-store.ts +66 -7
  112. package/src/secrets/keys.ts +173 -0
  113. package/src/secrets/schema.ts +23 -0
  114. package/src/secrets/storage.ts +68 -0
  115. package/src/server/index.ts +122 -11
  116. package/src/shared/index.ts +4 -0
  117. package/src/shared/protocol.ts +27 -0
  118. package/src/skills/typeclaw-channel-kakaotalk/SKILL.md +3 -3
  119. package/src/skills/typeclaw-config/SKILL.md +38 -64
  120. package/src/skills/typeclaw-memory/SKILL.md +1 -1
  121. package/src/skills/typeclaw-permissions/SKILL.md +166 -0
  122. package/src/stream/types.ts +7 -1
  123. package/src/usage/aggregate.ts +117 -0
  124. package/src/usage/format.ts +30 -0
  125. package/src/usage/index.ts +68 -0
  126. package/src/usage/report.ts +354 -0
  127. package/src/usage/scan.ts +186 -0
  128. package/typeclaw.schema.json +57 -45
@@ -0,0 +1,68 @@
1
+ import { join } from 'node:path'
2
+
3
+ import type { Aggregation } from './aggregate'
4
+ import { aggregate } from './aggregate'
5
+ import { scanAssistantRows } from './scan'
6
+
7
+ export type { Aggregation, DailyUsage, ModelUsage, SessionUsage, UsageTotals } from './aggregate'
8
+ export type { AssistantRow } from './scan'
9
+
10
+ export type UsageReport = {
11
+ generatedAt: number
12
+ agentDir: string
13
+ range: { since: number | null; until: number | null }
14
+ // The process timezone used by the date helpers (startOfToday,
15
+ // startOfDaysAgo) and by per-day grouping. Container processes default to
16
+ // UTC; host CLI uses the user's local TZ. Surfaced explicitly so consumers
17
+ // (humans, --json, downstream tooling) can interpret "today" unambiguously.
18
+ timezone: string
19
+ aggregation: Aggregation
20
+ warnings: string[]
21
+ }
22
+
23
+ export type RunUsageOptions = {
24
+ agentDir: string
25
+ since?: number
26
+ until?: number
27
+ }
28
+
29
+ export async function runUsage(opts: RunUsageOptions): Promise<UsageReport> {
30
+ const warnings: string[] = []
31
+ const sessionsDir = join(opts.agentDir, 'sessions')
32
+ const rows = scanAssistantRows({
33
+ sessionsDir,
34
+ ...(opts.since !== undefined ? { since: opts.since } : {}),
35
+ ...(opts.until !== undefined ? { until: opts.until } : {}),
36
+ onWarn: (m) => warnings.push(m),
37
+ })
38
+ const aggregation = await aggregate(rows)
39
+ return {
40
+ generatedAt: Date.now(),
41
+ agentDir: opts.agentDir,
42
+ range: { since: opts.since ?? null, until: opts.until ?? null },
43
+ timezone: processTimezone(),
44
+ aggregation,
45
+ warnings,
46
+ }
47
+ }
48
+
49
+ function processTimezone(): string {
50
+ try {
51
+ return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'
52
+ } catch {
53
+ return process.env.TZ ?? 'UTC'
54
+ }
55
+ }
56
+
57
+ export function startOfToday(now: Date = new Date()): number {
58
+ const d = new Date(now)
59
+ d.setHours(0, 0, 0, 0)
60
+ return d.getTime()
61
+ }
62
+
63
+ export function startOfDaysAgo(days: number, now: Date = new Date()): number {
64
+ const d = new Date(now)
65
+ d.setHours(0, 0, 0, 0)
66
+ d.setDate(d.getDate() - days)
67
+ return d.getTime()
68
+ }
@@ -0,0 +1,354 @@
1
+ import { styleText } from 'node:util'
2
+
3
+ import type { ModelUsage, UsageTotals } from './aggregate'
4
+ import { formatCacheHitRate, formatCost, formatTokens, isoDay } from './format'
5
+ import type { UsageReport } from './index'
6
+
7
+ export type FormatOptions = {
8
+ useColor?: boolean
9
+ view?: 'summary' | 'daily' | 'session' | 'models'
10
+ limit?: number
11
+ // Terminal width hint used to size the elastic Item column. Omit to render
12
+ // without truncation (tests, piped output where columns is undefined).
13
+ terminalWidth?: number
14
+ }
15
+
16
+ export function formatReport(report: UsageReport, opts: FormatOptions = {}): string {
17
+ const view = opts.view ?? 'summary'
18
+ const useColor = opts.useColor ?? false
19
+ const ctx: RenderCtx = { useColor, terminalWidth: opts.terminalWidth ?? Number.POSITIVE_INFINITY }
20
+ switch (view) {
21
+ case 'summary':
22
+ return renderSummary(report, ctx)
23
+ case 'daily':
24
+ return renderDaily(report, ctx, opts.limit)
25
+ case 'session':
26
+ return renderSessions(report, ctx, opts.limit ?? 20)
27
+ case 'models':
28
+ return renderModels(report, ctx, opts.limit)
29
+ }
30
+ }
31
+
32
+ export function formatJson(report: UsageReport): string {
33
+ return JSON.stringify(report, null, 2)
34
+ }
35
+
36
+ type RenderCtx = { useColor: boolean; terminalWidth: number }
37
+
38
+ function renderSummary(report: UsageReport, ctx: RenderCtx): string {
39
+ const { aggregation } = report
40
+ const sections: string[] = []
41
+ sections.push(header(`USAGE`, ctx) + ' ' + dim(`— ${report.agentDir}`, ctx))
42
+ sections.push(dim(`Timezone: ${report.timezone}`, ctx))
43
+
44
+ if (aggregation.bySession.length === 0) {
45
+ sections.push(dim('No assistant turns recorded yet.', ctx))
46
+ return sections.join('\n')
47
+ }
48
+
49
+ const total = aggregation.total
50
+ sections.push(
51
+ `${dim('Sessions:', ctx)} ${color('cyan', String(aggregation.bySession.length), ctx)}` +
52
+ ` ${dim('Messages:', ctx)} ${color('cyan', String(total.messageCount), ctx)}` +
53
+ ` ${dim('In:', ctx)} ${formatTokens(total.input)}` +
54
+ ` ${dim('Out:', ctx)} ${formatTokens(total.output)}` +
55
+ ` ${dim('Sent:', ctx)} ${formatTokens(total.input + total.cacheRead)}` +
56
+ ` ${dim('Cost:', ctx)} ${formatCost(total.cost)}`,
57
+ )
58
+
59
+ if (aggregation.byDay.length > 0) {
60
+ sections.push('')
61
+ sections.push(header('By day (most recent first)', ctx))
62
+ const recent = aggregation.byDay.slice(-7).reverse()
63
+ const dayRows = recent.map((d) => ({ label: d.date, totals: d as UsageTotals }))
64
+ sections.push(renderTotalsTable(dayRows, ctx, { total: totalOfRows(dayRows) }))
65
+ }
66
+
67
+ if (aggregation.byModel.length > 0) {
68
+ sections.push('')
69
+ sections.push(header('By model', ctx))
70
+ const modelRows = aggregation.byModel.map((m) => ({
71
+ label: colorModelLabel(m, ctx),
72
+ modelId: m.model,
73
+ totals: m as UsageTotals,
74
+ }))
75
+ sections.push(renderTotalsTable(modelRows, ctx, { total: totalOfRows(modelRows) }))
76
+ }
77
+
78
+ if (report.warnings.length > 0) {
79
+ sections.push('')
80
+ sections.push(color('yellow', `${report.warnings.length} warning(s):`, ctx))
81
+ for (const w of report.warnings) sections.push(` - ${w}`)
82
+ }
83
+
84
+ return sections.join('\n')
85
+ }
86
+
87
+ function renderDaily(report: UsageReport, ctx: RenderCtx, limit: number | undefined): string {
88
+ const days = limit !== undefined ? report.aggregation.byDay.slice(-limit) : report.aggregation.byDay
89
+ if (days.length === 0) return dim('No usage in range.', ctx)
90
+ const rows = days.map((d) => ({ label: dim(d.date, ctx), totals: d as UsageTotals }))
91
+ return [
92
+ sectionTitle(`USAGE BY DAY`, report.agentDir, ctx),
93
+ renderTotalsTable(rows, ctx, { total: totalOfRows(rows) }),
94
+ ].join('\n')
95
+ }
96
+
97
+ function renderSessions(report: UsageReport, ctx: RenderCtx, limit: number): string {
98
+ const sessions = report.aggregation.bySession.slice(0, limit)
99
+ if (sessions.length === 0) return dim('No sessions in range.', ctx)
100
+ const rows = sessions.map((s) => {
101
+ const firstModel = s.models[0]
102
+ const extra = s.models.length > 1 ? `${s.models.length} models` : modelIdFromKey(firstModel)
103
+ return {
104
+ label: `${color('magenta', s.sessionId.slice(0, 12), ctx)} ${dim(isoDay(s.firstAt), ctx)}`,
105
+ totals: s as UsageTotals,
106
+ extra,
107
+ extraTruncatable: s.models.length === 1,
108
+ }
109
+ })
110
+ return [
111
+ sectionTitle(`USAGE BY SESSION`, report.agentDir, ctx, `(top ${limit} by cost)`),
112
+ renderTotalsTable(rows, ctx, { extraHeader: 'Model', total: totalOfRows(rows) }),
113
+ ].join('\n')
114
+ }
115
+
116
+ function renderModels(report: UsageReport, ctx: RenderCtx, limit: number | undefined): string {
117
+ const models = limit !== undefined ? report.aggregation.byModel.slice(0, limit) : report.aggregation.byModel
118
+ if (models.length === 0) return dim('No models in range.', ctx)
119
+ const rows = models.map((m) => ({
120
+ label: colorModelLabel(m, ctx),
121
+ modelId: m.model,
122
+ totals: m as UsageTotals,
123
+ }))
124
+ return [
125
+ sectionTitle(`USAGE BY MODEL`, report.agentDir, ctx),
126
+ renderTotalsTable(rows, ctx, { total: totalOfRows(rows) }),
127
+ ].join('\n')
128
+ }
129
+
130
+ function sectionTitle(title: string, agentDir: string, ctx: RenderCtx, suffix?: string): string {
131
+ const t = header(title, ctx)
132
+ const path = dim(`— ${agentDir}`, ctx)
133
+ return suffix !== undefined ? `${t} ${dim(suffix, ctx)} ${path}` : `${t} ${path}`
134
+ }
135
+
136
+ function colorModelLabel(m: ModelUsage, ctx: RenderCtx): string {
137
+ return `${dim(`${m.provider}/`, ctx)}${m.model}`
138
+ }
139
+
140
+ function modelIdFromKey(key: string | undefined): string {
141
+ if (key === undefined) return ''
142
+ const slash = key.indexOf('/')
143
+ return slash === -1 ? key : key.slice(slash + 1)
144
+ }
145
+
146
+ type Row = {
147
+ label: string
148
+ totals: UsageTotals
149
+ extra?: string
150
+ // When truncation kicks in on a model-bearing row, drop the `provider/`
151
+ // prefix from `label` rather than ellipsizing into the prefix. Set by the
152
+ // model and session renderers; date/day rows leave it undefined.
153
+ modelId?: string
154
+ extraTruncatable?: boolean
155
+ }
156
+
157
+ // 2 spaces between columns, matches the existing alignTable join.
158
+ const COL_GAP = 2
159
+ // Floor below which truncation would erase too much context to be useful.
160
+ // "kimi-k2-instr…" at 14 chars still tells you which model family it is.
161
+ const MIN_ITEM_WIDTH = 14
162
+ const ELLIPSIS = '…'
163
+
164
+ // Sum of rendered rows. Tokscale-style "Total" footer: always represents
165
+ // what the user sees on screen, not lifetime totals across rows that were
166
+ // sliced/filtered out.
167
+ function totalOfRows(rows: Row[]): UsageTotals {
168
+ const acc: UsageTotals = {
169
+ messageCount: 0,
170
+ input: 0,
171
+ output: 0,
172
+ cacheRead: 0,
173
+ cacheWrite: 0,
174
+ totalTokens: 0,
175
+ cost: 0,
176
+ }
177
+ for (const r of rows) {
178
+ acc.messageCount += r.totals.messageCount
179
+ acc.input += r.totals.input
180
+ acc.output += r.totals.output
181
+ acc.cacheRead += r.totals.cacheRead
182
+ acc.cacheWrite += r.totals.cacheWrite
183
+ acc.totalTokens += r.totals.totalTokens
184
+ acc.cost += r.totals.cost
185
+ }
186
+ return acc
187
+ }
188
+
189
+ function renderTotalsTable(
190
+ rows: Row[],
191
+ ctx: RenderCtx,
192
+ opts: { extraHeader?: string; total?: UsageTotals } = {},
193
+ ): string {
194
+ const hasExtra = opts.extraHeader !== undefined
195
+ const headers = ['Item', 'Msgs', 'In', 'Out', 'Sent', 'Cache %', 'Cost', ...(hasExtra ? [opts.extraHeader!] : [])]
196
+
197
+ const dataCells: string[][] = rows.map((r) => [
198
+ r.label,
199
+ String(r.totals.messageCount),
200
+ formatTokens(r.totals.input),
201
+ formatTokens(r.totals.output),
202
+ formatTokens(r.totals.input + r.totals.cacheRead),
203
+ formatCacheHitRate(r.totals.input, r.totals.cacheRead),
204
+ formatCost(r.totals.cost),
205
+ ...(hasExtra ? [r.extra ?? ''] : []),
206
+ ])
207
+
208
+ const totalCells: string[] | undefined =
209
+ opts.total !== undefined
210
+ ? [
211
+ 'Total',
212
+ String(opts.total.messageCount),
213
+ formatTokens(opts.total.input),
214
+ formatTokens(opts.total.output),
215
+ formatTokens(opts.total.input + opts.total.cacheRead),
216
+ formatCacheHitRate(opts.total.input, opts.total.cacheRead),
217
+ formatCost(opts.total.cost),
218
+ ...(hasExtra ? [''] : []),
219
+ ]
220
+ : undefined
221
+
222
+ const itemColIdx = 0
223
+ const extraColIdx = hasExtra ? headers.length - 1 : -1
224
+
225
+ const widthRows = totalCells !== undefined ? [headers, ...dataCells, totalCells] : [headers, ...dataCells]
226
+ const naturalWidths = computeNaturalWidths(widthRows)
227
+ const fixedWidth =
228
+ naturalWidths.reduce((a, b) => a + b, 0) -
229
+ naturalWidths[itemColIdx]! -
230
+ (extraColIdx === -1 ? 0 : naturalWidths[extraColIdx]!) +
231
+ COL_GAP * (naturalWidths.length - 1)
232
+ const elasticBudget = Math.max(0, ctx.terminalWidth - fixedWidth)
233
+
234
+ let itemBudget: number
235
+ let extraBudget: number
236
+ if (extraColIdx === -1) {
237
+ itemBudget = Math.max(MIN_ITEM_WIDTH, elasticBudget)
238
+ } else {
239
+ // Split the elastic budget between Item and Extra columns by their natural
240
+ // widths, then clamp each to the MIN_ITEM_WIDTH floor.
241
+ const itemNatural = naturalWidths[itemColIdx]!
242
+ const extraNatural = naturalWidths[extraColIdx]!
243
+ const total = itemNatural + extraNatural
244
+ if (total === 0) {
245
+ itemBudget = MIN_ITEM_WIDTH
246
+ extraBudget = MIN_ITEM_WIDTH
247
+ } else {
248
+ itemBudget = Math.max(MIN_ITEM_WIDTH, Math.floor((elasticBudget * itemNatural) / total))
249
+ extraBudget = Math.max(MIN_ITEM_WIDTH, elasticBudget - itemBudget)
250
+ }
251
+ }
252
+
253
+ const truncatedBody = rows.map((r, rowIdx) => {
254
+ const cells = [...dataCells[rowIdx]!]
255
+ cells[itemColIdx] = fitItemCell(cells[itemColIdx]!, r, itemBudget, ctx)
256
+ if (extraColIdx !== -1) {
257
+ const allow = r.extraTruncatable !== false
258
+ cells[extraColIdx] = allow ? truncateTail(cells[extraColIdx]!, extraBudget, ctx) : cells[extraColIdx]!
259
+ }
260
+ return cells
261
+ })
262
+
263
+ const table = totalCells !== undefined ? [headers, ...truncatedBody, totalCells] : [headers, ...truncatedBody]
264
+ return alignTable(table, ctx, totalCells !== undefined ? { totalRowIdx: table.length - 1 } : {})
265
+ }
266
+
267
+ function fitItemCell(label: string, row: Row, budget: number, ctx: RenderCtx): string {
268
+ const visible = stripAnsi(label).length
269
+ if (visible <= budget) return label
270
+ // Model row: try dropping `provider/` first; if the bare model id still
271
+ // doesn't fit, ellipsize it. Falls through to a plain tail truncation for
272
+ // anything else. The bare model id has no embedded ANSI so truncateTail
273
+ // is safe on it.
274
+ if (row.modelId !== undefined && row.modelId.length > 0) {
275
+ if (row.modelId.length <= budget) return row.modelId
276
+ return truncateTail(row.modelId, budget, ctx)
277
+ }
278
+ return truncateTail(label, budget, ctx)
279
+ }
280
+
281
+ function truncateTail(text: string, budget: number, ctx: RenderCtx): string {
282
+ const plain = stripAnsi(text)
283
+ if (plain.length <= budget) return text
284
+ if (budget <= 1) return colorEllipsis(ctx)
285
+ // The colored labels we render do not interleave ANSI sequences in the
286
+ // middle of visible characters — coloring is always wrap-the-whole-cell
287
+ // or wrap-a-prefix. For the truncation path we slice the plain text and
288
+ // re-color uniformly, which keeps the math honest.
289
+ return `${plain.slice(0, budget - 1)}${colorEllipsis(ctx)}`
290
+ }
291
+
292
+ function colorEllipsis(ctx: RenderCtx): string {
293
+ return dim(ELLIPSIS, ctx)
294
+ }
295
+
296
+ function computeNaturalWidths(table: string[][]): number[] {
297
+ const cols = table[0]?.length ?? 0
298
+ const widths: number[] = []
299
+ for (let c = 0; c < cols; c++) {
300
+ let w = 0
301
+ for (const row of table) {
302
+ const cell = row[c] ?? ''
303
+ const visible = stripAnsi(cell).length
304
+ if (visible > w) w = visible
305
+ }
306
+ widths.push(w)
307
+ }
308
+ return widths
309
+ }
310
+
311
+ function alignTable(table: string[][], ctx: RenderCtx, opts: { totalRowIdx?: number } = {}): string {
312
+ if (table.length === 0) return ''
313
+ const widths = computeNaturalWidths(table)
314
+ const lines: string[] = []
315
+ table.forEach((row, idx) => {
316
+ const cells = row.map((cell, c) => {
317
+ const pad = widths[c]! - stripAnsi(cell).length
318
+ return c === 0 ? cell + ' '.repeat(pad) : ' '.repeat(pad) + cell
319
+ })
320
+ const line = cells.join(' ')
321
+ if (idx === 0) {
322
+ lines.push(color('cyan', line, ctx))
323
+ } else if (opts.totalRowIdx !== undefined && idx === opts.totalRowIdx) {
324
+ lines.push(color('yellow', bold(line, ctx), ctx))
325
+ } else {
326
+ lines.push(line)
327
+ }
328
+ })
329
+ return lines.join('\n')
330
+ }
331
+
332
+ function header(text: string, ctx: RenderCtx): string {
333
+ return bold(text, ctx)
334
+ }
335
+
336
+ function bold(text: string, ctx: RenderCtx): string {
337
+ return color('bold', text, ctx)
338
+ }
339
+
340
+ function dim(text: string, ctx: RenderCtx): string {
341
+ return color('dim', text, ctx)
342
+ }
343
+
344
+ function color(modifier: Parameters<typeof styleText>[0], text: string, ctx: RenderCtx): string {
345
+ if (!ctx.useColor) return text
346
+ return styleText(modifier, text)
347
+ }
348
+
349
+ // ANSI escape sequences would inflate column widths and break alignment under
350
+ // --no-color piping; strip before measuring.
351
+ function stripAnsi(s: string): string {
352
+ // eslint-disable-next-line no-control-regex
353
+ return s.replace(/\u001b\[[0-9;]*m/g, '')
354
+ }
@@ -0,0 +1,186 @@
1
+ import { readdir } from 'node:fs/promises'
2
+ import { join } from 'node:path'
3
+
4
+ // Narrow projection: session files can grow into tens of MB on long-lived
5
+ // agents, so we deliberately drop content/tool blocks before aggregation.
6
+ export type AssistantRow = {
7
+ sessionFile: string
8
+ sessionBasename: string
9
+ timestamp: number
10
+ provider: string
11
+ model: string
12
+ input: number
13
+ output: number
14
+ cacheRead: number
15
+ cacheWrite: number
16
+ totalTokens: number
17
+ cost: number
18
+ }
19
+
20
+ export type ScanOptions = {
21
+ sessionsDir: string
22
+ since?: number
23
+ until?: number
24
+ onWarn?: (msg: string) => void
25
+ }
26
+
27
+ // Missing sessions/ resolves to empty (fresh agent, no turns yet). Per-line
28
+ // JSON parse failures are routed to onWarn and skipped rather than thrown — a
29
+ // crashed mid-line write should not bomb the whole report.
30
+ export async function* scanAssistantRows(opts: ScanOptions): AsyncGenerator<AssistantRow> {
31
+ const files = await listJsonlFiles(opts.sessionsDir, opts.onWarn)
32
+ for (const file of files) {
33
+ yield* readSessionFile(file, opts)
34
+ }
35
+ }
36
+
37
+ async function listJsonlFiles(dir: string, onWarn: ScanOptions['onWarn']): Promise<string[]> {
38
+ let entries
39
+ try {
40
+ entries = await readdir(dir, { withFileTypes: true, encoding: 'utf8' })
41
+ } catch (err) {
42
+ if (isNoEntError(err)) return []
43
+ throw err
44
+ }
45
+ const files: string[] = []
46
+ for (const entry of entries) {
47
+ const name = entry.name
48
+ if (!name.endsWith('.jsonl')) continue
49
+ if (!entry.isFile() && !entry.isSymbolicLink()) {
50
+ onWarn?.(`skipping non-file in sessions/: ${name}`)
51
+ continue
52
+ }
53
+ files.push(join(dir, name))
54
+ }
55
+ return files
56
+ }
57
+
58
+ async function* readSessionFile(file: string, opts: ScanOptions): AsyncGenerator<AssistantRow> {
59
+ const basename = file.split('/').pop() ?? file
60
+ let stream: ReadableStream<Uint8Array>
61
+ try {
62
+ stream = Bun.file(file).stream()
63
+ } catch (err) {
64
+ opts.onWarn?.(`could not open ${basename}: ${describeFileError(err)}`)
65
+ return
66
+ }
67
+ const decoder = new TextDecoder()
68
+ let buf = ''
69
+ try {
70
+ for await (const chunk of stream) {
71
+ buf += decoder.decode(chunk, { stream: true })
72
+ let nl = buf.indexOf('\n')
73
+ while (nl !== -1) {
74
+ const line = buf.slice(0, nl)
75
+ buf = buf.slice(nl + 1)
76
+ const row = parseLine(line, file, basename, opts)
77
+ if (row !== null) yield row
78
+ nl = buf.indexOf('\n')
79
+ }
80
+ }
81
+ } catch (err) {
82
+ opts.onWarn?.(`error reading ${basename}: ${describeFileError(err)}`)
83
+ return
84
+ }
85
+ // Tail line with no terminating \n: only emit if it parses cleanly so a
86
+ // half-written record from a live writer is silently skipped (parseLine
87
+ // returns null and does NOT warn for the tail).
88
+ if (buf.length > 0) {
89
+ const row = parseLine(buf, file, basename, opts, { isTail: true })
90
+ if (row !== null) yield row
91
+ }
92
+ }
93
+
94
+ function parseLine(
95
+ line: string,
96
+ file: string,
97
+ basename: string,
98
+ opts: ScanOptions,
99
+ ctx: { isTail?: boolean } = {},
100
+ ): AssistantRow | null {
101
+ const trimmed = line.trim()
102
+ if (trimmed === '') return null
103
+
104
+ let entry: unknown
105
+ try {
106
+ entry = JSON.parse(trimmed)
107
+ } catch {
108
+ // Silently skip the trailing tail: a live writer may be mid-append.
109
+ if (ctx.isTail !== true) opts.onWarn?.(`skipping malformed JSONL line in ${basename}`)
110
+ return null
111
+ }
112
+
113
+ if (!isMessageEntry(entry)) return null
114
+ const message = entry.message
115
+ if (!isAssistantMessage(message)) return null
116
+
117
+ // Aborted/error messages are intentionally counted: pi-ai's `usage` carries
118
+ // partial token counts that the provider still billed for.
119
+
120
+ const ts = typeof message.timestamp === 'number' ? message.timestamp : 0
121
+ if (opts.since !== undefined && ts < opts.since) return null
122
+ if (opts.until !== undefined && ts >= opts.until) return null
123
+
124
+ const u = message.usage
125
+ return {
126
+ sessionFile: file,
127
+ sessionBasename: basename,
128
+ timestamp: ts,
129
+ provider: typeof message.provider === 'string' ? message.provider : 'unknown',
130
+ model: typeof message.model === 'string' ? message.model : 'unknown',
131
+ input: numberOrZero(u.input),
132
+ output: numberOrZero(u.output),
133
+ cacheRead: numberOrZero(u.cacheRead),
134
+ cacheWrite: numberOrZero(u.cacheWrite),
135
+ totalTokens: numberOrZero(u.totalTokens),
136
+ cost: numberOrZero(u.cost?.total),
137
+ }
138
+ }
139
+
140
+ type MessageEntry = { type: 'message'; message: { role: string; [k: string]: unknown } }
141
+ type AssistantMessageShape = {
142
+ role: 'assistant'
143
+ timestamp?: unknown
144
+ provider?: unknown
145
+ model?: unknown
146
+ usage: {
147
+ input?: unknown
148
+ output?: unknown
149
+ cacheRead?: unknown
150
+ cacheWrite?: unknown
151
+ totalTokens?: unknown
152
+ cost?: { total?: unknown }
153
+ }
154
+ }
155
+
156
+ function isMessageEntry(value: unknown): value is MessageEntry {
157
+ if (typeof value !== 'object' || value === null) return false
158
+ const v = value as Record<string, unknown>
159
+ if (v.type !== 'message') return false
160
+ if (typeof v.message !== 'object' || v.message === null) return false
161
+ const m = v.message as Record<string, unknown>
162
+ return typeof m.role === 'string'
163
+ }
164
+
165
+ function isAssistantMessage(message: unknown): message is AssistantMessageShape {
166
+ if (typeof message !== 'object' || message === null) return false
167
+ const m = message as Record<string, unknown>
168
+ if (m.role !== 'assistant') return false
169
+ if (typeof m.usage !== 'object' || m.usage === null) return false
170
+ return true
171
+ }
172
+
173
+ function numberOrZero(value: unknown): number {
174
+ if (typeof value !== 'number') return 0
175
+ if (!Number.isFinite(value)) return 0
176
+ return value
177
+ }
178
+
179
+ function isNoEntError(err: unknown): boolean {
180
+ return typeof err === 'object' && err !== null && (err as { code?: unknown }).code === 'ENOENT'
181
+ }
182
+
183
+ function describeFileError(err: unknown): string {
184
+ if (err instanceof Error) return err.message
185
+ return String(err)
186
+ }