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.
- package/README.md +14 -12
- package/auth.schema.json +41 -0
- package/cron.schema.json +8 -0
- package/package.json +1 -1
- package/secrets.schema.json +41 -0
- package/src/agent/auth.ts +45 -22
- package/src/agent/index.ts +189 -19
- package/src/agent/multimodal/index.ts +12 -0
- package/src/agent/multimodal/look-at.ts +185 -0
- package/src/agent/multimodal/looker.ts +145 -0
- package/src/agent/plugin-tools.ts +30 -1
- package/src/agent/session-origin.ts +194 -46
- package/src/agent/subagents.ts +57 -1
- package/src/agent/system-prompt.ts +1 -1
- package/src/agent/tool-result-budget.ts +121 -0
- package/src/bundled-plugins/backup/index.ts +23 -8
- package/src/bundled-plugins/backup/runner.ts +22 -0
- package/src/bundled-plugins/memory/README.md +7 -4
- package/src/bundled-plugins/memory/append-tool.ts +87 -61
- package/src/bundled-plugins/memory/dreaming.ts +23 -9
- package/src/bundled-plugins/memory/find-entry-tool.ts +62 -0
- package/src/bundled-plugins/memory/fragment-parser.ts +19 -44
- package/src/bundled-plugins/memory/index.ts +91 -8
- package/src/bundled-plugins/memory/load-memory.ts +74 -34
- package/src/bundled-plugins/memory/memory-logger.ts +72 -29
- package/src/bundled-plugins/memory/migration.ts +276 -0
- package/src/bundled-plugins/memory/stream-events.ts +55 -0
- package/src/bundled-plugins/memory/stream-io.ts +63 -0
- package/src/bundled-plugins/memory/watermark.ts +48 -8
- package/src/bundled-plugins/security/index.ts +103 -10
- package/src/bundled-plugins/security/permissions.ts +12 -0
- package/src/bundled-plugins/security/policies/git-exfil.ts +51 -18
- package/src/bundled-plugins/tool-result-cap/README.md +9 -4
- package/src/bundled-plugins/tool-result-cap/cap-jsonl.ts +115 -0
- package/src/bundled-plugins/tool-result-cap/cap-result.ts +25 -13
- package/src/bundled-plugins/tool-result-cap/index.ts +16 -2
- package/src/channels/adapters/discord-bot-classify.ts +2 -6
- package/src/channels/adapters/discord-bot.ts +4 -45
- package/src/channels/adapters/kakaotalk-classify.ts +3 -7
- package/src/channels/adapters/kakaotalk.ts +28 -47
- package/src/channels/adapters/slack-bot-classify.ts +2 -6
- package/src/channels/adapters/slack-bot.ts +4 -50
- package/src/channels/adapters/telegram-bot-classify.ts +8 -10
- package/src/channels/adapters/telegram-bot.ts +3 -16
- package/src/channels/index.ts +3 -2
- package/src/channels/manager.ts +15 -1
- package/src/channels/persistence.ts +44 -10
- package/src/channels/router.ts +228 -19
- package/src/channels/schema.ts +6 -156
- package/src/cli/channel.ts +200 -4
- package/src/cli/compose-usage.ts +182 -0
- package/src/cli/compose.ts +33 -0
- package/src/cli/hostd.ts +49 -1
- package/src/cli/index.ts +4 -0
- package/src/cli/init.ts +799 -319
- package/src/cli/model.ts +244 -0
- package/src/cli/provider.ts +404 -0
- package/src/cli/reload.ts +6 -1
- package/src/cli/role.ts +156 -0
- package/src/cli/run.ts +3 -1
- package/src/cli/tui.ts +8 -1
- package/src/cli/usage-args.ts +47 -0
- package/src/cli/usage.ts +97 -0
- package/src/compose/index.ts +1 -0
- package/src/compose/usage.ts +65 -0
- package/src/config/config.ts +385 -12
- package/src/config/index.ts +7 -0
- package/src/config/models-mutation.ts +209 -0
- package/src/config/providers-mutation.ts +250 -0
- package/src/config/providers.ts +141 -2
- package/src/config/reloadable.ts +15 -4
- package/src/container/index.ts +5 -0
- package/src/container/require-running.ts +33 -0
- package/src/container/start.ts +39 -58
- package/src/cron/consumer.ts +22 -2
- package/src/cron/index.ts +45 -4
- package/src/cron/schema.ts +104 -0
- package/src/doctor/checks.ts +50 -33
- package/src/git/system-commit.ts +103 -0
- package/src/hostd/daemon.ts +16 -0
- package/src/hostd/kakao-renewal-manager.ts +223 -0
- package/src/hostd/paths.ts +7 -0
- package/src/init/dockerfile.ts +32 -6
- package/src/init/index.ts +190 -61
- package/src/init/kakaotalk-auth.ts +18 -1
- package/src/init/models-dev.ts +26 -1
- package/src/init/run-owner-claim.ts +77 -0
- package/src/permissions/builtins.ts +70 -0
- package/src/permissions/grant.ts +99 -0
- package/src/permissions/index.ts +29 -0
- package/src/permissions/match-rule.ts +305 -0
- package/src/permissions/permissions.ts +196 -0
- package/src/permissions/resolve.ts +80 -0
- package/src/permissions/schema.ts +79 -0
- package/src/plugin/context.ts +8 -4
- package/src/plugin/define.ts +2 -0
- package/src/plugin/index.ts +2 -0
- package/src/plugin/manager.ts +41 -0
- package/src/plugin/registry.ts +9 -0
- package/src/plugin/types.ts +35 -1
- package/src/role-claim/client.ts +182 -0
- package/src/role-claim/code.ts +53 -0
- package/src/role-claim/controller.ts +194 -0
- package/src/role-claim/index.ts +19 -0
- package/src/role-claim/match-rule.ts +43 -0
- package/src/role-claim/pending.ts +100 -0
- package/src/run/channel-session-factory.ts +76 -5
- package/src/run/index.ts +55 -6
- package/src/secrets/encryption.ts +116 -0
- package/src/secrets/kakao-renewal.ts +248 -0
- package/src/secrets/kakao-store.ts +66 -7
- package/src/secrets/keys.ts +173 -0
- package/src/secrets/schema.ts +23 -0
- package/src/secrets/storage.ts +68 -0
- package/src/server/index.ts +122 -11
- package/src/shared/index.ts +4 -0
- package/src/shared/protocol.ts +27 -0
- package/src/skills/typeclaw-channel-kakaotalk/SKILL.md +3 -3
- package/src/skills/typeclaw-config/SKILL.md +38 -64
- package/src/skills/typeclaw-memory/SKILL.md +1 -1
- package/src/skills/typeclaw-permissions/SKILL.md +166 -0
- package/src/stream/types.ts +7 -1
- package/src/usage/aggregate.ts +117 -0
- package/src/usage/format.ts +30 -0
- package/src/usage/index.ts +68 -0
- package/src/usage/report.ts +354 -0
- package/src/usage/scan.ts +186 -0
- 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
|
+
}
|