@saifymatteo/pi-tool-usage-log 0.0.0-stage → 0.1.1
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/LICENSE +661 -0
- package/NOTICE.md +5 -0
- package/README.md +31 -2
- package/package.json +34 -4
- package/src/index.ts +233 -0
- package/src/lib/dialog.ts +31 -0
- package/src/lib/usage-log.ts +62 -0
- package/src/skill-usage-log.ts +316 -0
package/README.md
CHANGED
|
@@ -1,3 +1,32 @@
|
|
|
1
|
-
#
|
|
1
|
+
# pi-tool-usage-log
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Passively record direct and nested tool executions and show aggregate usage, errors, duration, selected model and thinking level.
|
|
4
|
+
|
|
5
|
+
## Running locally
|
|
6
|
+
|
|
7
|
+
From the monorepo root:
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
pi -e ./packages/pi-tool-usage-log
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
**Warning:** this extension uses your existing Pi resources and settings unless you load it in an isolated home (see the root README). Do not load two copies of the same extension (for example an older standalone file alongside this package): commands and hooks can duplicate. The package never installs or relocates personal settings.
|
|
14
|
+
|
|
15
|
+
## Commands and shortcuts
|
|
16
|
+
|
|
17
|
+
- `/tool-usage` — opens the all-project report (no arguments).
|
|
18
|
+
- Up/Down, PageUp/PageDown, Home/End scroll; Escape/Ctrl+C/Enter close.
|
|
19
|
+
|
|
20
|
+
## Settings and storage
|
|
21
|
+
|
|
22
|
+
Appends JSONL records to `<agent-dir>/tool-usage.log`; `<agent-dir>` is `PI_CODING_AGENT_DIR` or `~/.pi/agent`. No configuration file. The copied `skill-usage-log.ts` is a dialog sidecar only: only `src/index.ts` is a Pi manifest entry, so loading this package does not independently register skill logging.
|
|
23
|
+
|
|
24
|
+
## Privacy and operations
|
|
25
|
+
|
|
26
|
+
Log stores timestamps, session IDs, working directories, full tool/call/parent-call names, selected model/thinking level, status and durations, never tool arguments/results. No network requests. There is no rotation/deletion UI. Interrupted pending calls are flushed on orderly shutdown/session change; a hard crash can lose them. MCP tools aggregate by server in reports but keep full names on disk. Success means the tool returned, not that a background job eventually succeeded.
|
|
27
|
+
|
|
28
|
+
## Compatibility
|
|
29
|
+
|
|
30
|
+
Node.js >=24 for development checks; run extensions through Pi's TypeScript loader. Host imports are wildcard peers, not bundled runtime copies. Reports require interactive terminal mode; execution tracking needs Pi tool_execution_start/end hooks, including nested-event metadata for nested counts. Older hosts may omit these. Duration includes hooks/nested work and is not a pure tool benchmark.
|
|
31
|
+
|
|
32
|
+
See [NOTICE.md](NOTICE.md) for provenance. Tests use the monorepo's TypeScript dev dependency and VM loader; they do not contact services or write to user-home resources.
|
package/package.json
CHANGED
|
@@ -1,6 +1,36 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@saifymatteo/pi-tool-usage-log",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"license": "AGPL-3.0-only",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"pi-package"
|
|
8
|
+
],
|
|
9
|
+
"files": [
|
|
10
|
+
"src",
|
|
11
|
+
"README.md",
|
|
12
|
+
"NOTICE.md",
|
|
13
|
+
"LICENSE"
|
|
14
|
+
],
|
|
15
|
+
"pi": {
|
|
16
|
+
"extensions": [
|
|
17
|
+
"./src/index.ts"
|
|
18
|
+
]
|
|
19
|
+
},
|
|
20
|
+
"peerDependencies": {
|
|
21
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
22
|
+
"@earendil-works/pi-tui": "*"
|
|
23
|
+
},
|
|
24
|
+
"engines": {
|
|
25
|
+
"node": ">=24"
|
|
26
|
+
},
|
|
27
|
+
"description": "Passive log of tool executions (names and status, never contents).",
|
|
28
|
+
"publishConfig": {
|
|
29
|
+
"access": "public"
|
|
30
|
+
},
|
|
31
|
+
"repository": {
|
|
32
|
+
"type": "git",
|
|
33
|
+
"url": "git+https://github.com/saifymatteo/pi-extensions.git",
|
|
34
|
+
"directory": "packages/pi-tool-usage-log"
|
|
35
|
+
}
|
|
36
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Passive, forward-only tool usage log. Includes direct and nested executions,
|
|
3
|
+
* but never arguments/results. Success means the tool returned, not that a
|
|
4
|
+
* background command or delegated agent eventually succeeded.
|
|
5
|
+
*/
|
|
6
|
+
import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent'
|
|
7
|
+
import { appendFileSync, mkdirSync } from 'node:fs'
|
|
8
|
+
import { readFile } from 'node:fs/promises'
|
|
9
|
+
import { homedir } from 'node:os'
|
|
10
|
+
import { dirname, join } from 'node:path'
|
|
11
|
+
import { SkillUsageDialog } from './skill-usage-log.ts'
|
|
12
|
+
import { DIALOG_OVERLAY } from './lib/dialog.ts'
|
|
13
|
+
import { agentMetadata, localTimestamp, logField, markdownCell, usageTimeFormat } from './lib/usage-log.ts'
|
|
14
|
+
|
|
15
|
+
export interface ToolUsageRecord {
|
|
16
|
+
version: 1
|
|
17
|
+
timestamp: string
|
|
18
|
+
session: string
|
|
19
|
+
cwd: string
|
|
20
|
+
tool: string
|
|
21
|
+
callId: string
|
|
22
|
+
parentCallId?: string
|
|
23
|
+
model: string
|
|
24
|
+
thinking: string
|
|
25
|
+
status: 'success' | 'error' | 'interrupted'
|
|
26
|
+
durationMs: number
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface ToolUsageRow {
|
|
30
|
+
tool: string
|
|
31
|
+
calls: number
|
|
32
|
+
sessions: number
|
|
33
|
+
errors: number
|
|
34
|
+
nested: number
|
|
35
|
+
durationMs: number
|
|
36
|
+
/** null = installed but never called. */
|
|
37
|
+
lastUsed: string | null
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface ToolUsageBreakdown extends ToolUsageRow {
|
|
41
|
+
model: string
|
|
42
|
+
thinking: string
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** MCP tools (`mcp__<server>__<tool>`) report as one row per server, `mcp__<server>__*`; the log keeps full names. */
|
|
46
|
+
const toolGroup = (tool: string) => tool.match(/^(mcp__.+?__)./)?.[1].concat('*') ?? tool
|
|
47
|
+
|
|
48
|
+
/** Malformed/partial rows are ignored, not allowed to spoil the whole report.
|
|
49
|
+
* When supplied, `installed` filters removed tools and adds unseen tools as zero rows. */
|
|
50
|
+
export function summarizeToolUsage(log: string, installed?: string[]): {
|
|
51
|
+
tools: ToolUsageRow[]; breakdown: ToolUsageBreakdown[]; skipped: number
|
|
52
|
+
} {
|
|
53
|
+
const tools = new Map<string, { row: ToolUsageRow; sessions: Set<string> }>()
|
|
54
|
+
const groups = new Map<string, { row: ToolUsageBreakdown; sessions: Set<string> }>()
|
|
55
|
+
let skipped = 0
|
|
56
|
+
for (const line of log.split(/\r?\n/)) {
|
|
57
|
+
if (!line.trim()) continue
|
|
58
|
+
let record: ToolUsageRecord
|
|
59
|
+
try {
|
|
60
|
+
const value = JSON.parse(line)
|
|
61
|
+
if (!value || value.version !== 1 ||
|
|
62
|
+
!['timestamp', 'session', 'cwd', 'tool', 'callId', 'model', 'thinking'].every(key => typeof value[key] === 'string') ||
|
|
63
|
+
!value.session || !value.tool || !value.callId ||
|
|
64
|
+
!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}[+-]\d{2}:\d{2}$/.test(value.timestamp) ||
|
|
65
|
+
!Number.isFinite(Date.parse(value.timestamp)) ||
|
|
66
|
+
(value.parentCallId !== undefined && typeof value.parentCallId !== 'string') ||
|
|
67
|
+
!['success', 'error', 'interrupted'].includes(value.status) ||
|
|
68
|
+
typeof value.durationMs !== 'number' || !Number.isFinite(value.durationMs) || value.durationMs < 0) {
|
|
69
|
+
skipped++
|
|
70
|
+
continue
|
|
71
|
+
}
|
|
72
|
+
record = value
|
|
73
|
+
} catch {
|
|
74
|
+
skipped++
|
|
75
|
+
continue
|
|
76
|
+
}
|
|
77
|
+
const lastUsed = new Date(record.timestamp).toISOString()
|
|
78
|
+
const name = toolGroup(record.tool)
|
|
79
|
+
let entry = tools.get(name)
|
|
80
|
+
if (!entry) {
|
|
81
|
+
entry = { row: { tool: name, calls: 0, sessions: 0, errors: 0, nested: 0, durationMs: 0, lastUsed }, sessions: new Set() }
|
|
82
|
+
tools.set(name, entry)
|
|
83
|
+
}
|
|
84
|
+
const key = JSON.stringify([name, record.model, record.thinking])
|
|
85
|
+
let group = groups.get(key)
|
|
86
|
+
if (!group) {
|
|
87
|
+
group = { row: { ...entry.row, calls: 0, sessions: 0, errors: 0, nested: 0, durationMs: 0, lastUsed, model: record.model, thinking: record.thinking }, sessions: new Set() }
|
|
88
|
+
groups.set(key, group)
|
|
89
|
+
}
|
|
90
|
+
for (const aggregate of [entry, group]) {
|
|
91
|
+
aggregate.row.calls++
|
|
92
|
+
aggregate.sessions.add(record.session)
|
|
93
|
+
aggregate.row.sessions = aggregate.sessions.size
|
|
94
|
+
if (record.status !== 'success') aggregate.row.errors++
|
|
95
|
+
if (record.parentCallId) aggregate.row.nested++
|
|
96
|
+
aggregate.row.durationMs += record.durationMs
|
|
97
|
+
if (lastUsed > aggregate.row.lastUsed!) aggregate.row.lastUsed = lastUsed
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (installed !== undefined) {
|
|
101
|
+
const available = new Set(installed.map(toolGroup))
|
|
102
|
+
for (const tool of tools.keys()) if (!available.has(tool)) tools.delete(tool)
|
|
103
|
+
for (const [key, group] of groups) if (!available.has(group.row.tool)) groups.delete(key)
|
|
104
|
+
for (const tool of available) {
|
|
105
|
+
if (!tools.has(tool)) tools.set(tool, { row: { tool, calls: 0, sessions: 0, errors: 0, nested: 0, durationMs: 0, lastUsed: null }, sessions: new Set() })
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
const sort = (a: ToolUsageRow, b: ToolUsageRow) => b.calls - a.calls || a.tool.localeCompare(b.tool)
|
|
109
|
+
return {
|
|
110
|
+
tools: [...tools.values()].map(({ row }) => row).sort(sort),
|
|
111
|
+
breakdown: [...groups.values()].map(({ row }) => row).sort((a, b) => sort(a, b) || a.model.localeCompare(b.model) || a.thinking.localeCompare(b.thinking)),
|
|
112
|
+
skipped,
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export default function register(pi: ExtensionAPI): void {
|
|
117
|
+
const agentDir = (process.env.PI_CODING_AGENT_DIR || join(homedir(), '.pi', 'agent')).replace(/^~(?=$|[\\/])/, homedir())
|
|
118
|
+
const logPath = join(agentDir, 'tool-usage.log')
|
|
119
|
+
const pending = new Map<string, { record: Omit<ToolUsageRecord, 'status' | 'durationMs'>; start: number }>()
|
|
120
|
+
let session: string | undefined
|
|
121
|
+
let logError: string | undefined
|
|
122
|
+
let warned = false
|
|
123
|
+
|
|
124
|
+
function forSession(ctx: ExtensionContext): void {
|
|
125
|
+
const sid = ctx.sessionManager.getSessionId()
|
|
126
|
+
if (session === sid) return
|
|
127
|
+
flushPending()
|
|
128
|
+
warn(ctx)
|
|
129
|
+
session = sid
|
|
130
|
+
logError = undefined
|
|
131
|
+
warned = false
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function append(record: ToolUsageRecord): void {
|
|
135
|
+
try {
|
|
136
|
+
mkdirSync(dirname(logPath), { recursive: true })
|
|
137
|
+
appendFileSync(logPath, JSON.stringify(record) + '\n')
|
|
138
|
+
} catch (error) {
|
|
139
|
+
logError = error instanceof Error ? error.message : String(error)
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function flushPending(): void {
|
|
144
|
+
for (const { record, start } of pending.values()) {
|
|
145
|
+
append({ ...record, status: 'interrupted', durationMs: Math.max(0, Math.round(performance.now() - start)) })
|
|
146
|
+
}
|
|
147
|
+
pending.clear()
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function warn(ctx: ExtensionContext): void {
|
|
151
|
+
if (logError && !warned && ctx.hasUI) {
|
|
152
|
+
warned = true
|
|
153
|
+
try { ctx.ui.notify(`Tool usage could not be logged to ${logPath}: ${logError}`, 'warning') }
|
|
154
|
+
catch { /* A closed UI must not turn a logging failure into a tool failure. */ }
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
pi.on('session_start', (_event, ctx) => { forSession(ctx) })
|
|
159
|
+
pi.on('tool_execution_start', (event, ctx) => {
|
|
160
|
+
// Observation must never block a real tool, even if metadata is unavailable.
|
|
161
|
+
try {
|
|
162
|
+
forSession(ctx)
|
|
163
|
+
pending.set(event.toolCallId, {
|
|
164
|
+
start: performance.now(),
|
|
165
|
+
record: {
|
|
166
|
+
version: 1, timestamp: localTimestamp(), session: logField(session!),
|
|
167
|
+
cwd: logField(ctx.cwd), tool: logField(event.toolName), callId: logField(event.toolCallId),
|
|
168
|
+
...(event.parentToolCallId ? { parentCallId: logField(event.parentToolCallId) } : {}),
|
|
169
|
+
...agentMetadata(pi, ctx),
|
|
170
|
+
},
|
|
171
|
+
})
|
|
172
|
+
} catch (error) {
|
|
173
|
+
logError = error instanceof Error ? error.message : String(error)
|
|
174
|
+
}
|
|
175
|
+
})
|
|
176
|
+
pi.on('tool_execution_end', (event, ctx) => {
|
|
177
|
+
const call = pending.get(event.toolCallId)
|
|
178
|
+
if (!call) return // Started before reload, or already recorded.
|
|
179
|
+
pending.delete(event.toolCallId)
|
|
180
|
+
append({ ...call.record, status: event.isError ? 'error' : 'success', durationMs: Math.max(0, Math.round(performance.now() - call.start)) })
|
|
181
|
+
warn(ctx)
|
|
182
|
+
})
|
|
183
|
+
pi.on('turn_end', (_event, ctx) => { warn(ctx) })
|
|
184
|
+
pi.on('session_shutdown', (_event, ctx) => {
|
|
185
|
+
flushPending()
|
|
186
|
+
warn(ctx)
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
pi.registerCommand('tool-usage', {
|
|
190
|
+
description: 'List historical tool calls, sessions, errors, model and thinking level',
|
|
191
|
+
handler: async (args, ctx) => {
|
|
192
|
+
if (ctx.mode !== 'tui') {
|
|
193
|
+
ctx.ui.notify('Tool usage requires interactive terminal mode; no report was added to the transcript.', 'info')
|
|
194
|
+
return
|
|
195
|
+
}
|
|
196
|
+
if (args.trim()) {
|
|
197
|
+
ctx.ui.notify('Usage: /tool-usage (all logged sessions and projects)', 'info')
|
|
198
|
+
return
|
|
199
|
+
}
|
|
200
|
+
try {
|
|
201
|
+
let log = ''
|
|
202
|
+
try { log = await readFile(logPath, 'utf8') }
|
|
203
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error }
|
|
204
|
+
const { tools, breakdown, skipped } = summarizeToolUsage(log, pi.getAllTools().map((tool) => tool.name))
|
|
205
|
+
const time = usageTimeFormat()
|
|
206
|
+
const lines = ['Tool usage — all logged sessions and projects', '',
|
|
207
|
+
'Dates follow your locale. Different historical daylight-saving offsets are shown beside the date.',
|
|
208
|
+
'Tracking starts when installed; no historical import. Every registered tool is listed; 0 / never = not called since then.',
|
|
209
|
+
'MCP tools are grouped per server as mcp__<server>__*.',
|
|
210
|
+
'Direct and nested calls are counted separately. Errors include interrupted calls; unfinished calls appear on orderly shutdown.',
|
|
211
|
+
'Success means the tool returned, not that a background job succeeded. Duration includes hooks and nested work.', '',
|
|
212
|
+
`| Tool | Calls | Sessions | Errors | Nested | Avg ms | Last used (${markdownCell(time.label)}) |`,
|
|
213
|
+
'| --- | ---: | ---: | ---: | ---: | ---: | --- |']
|
|
214
|
+
for (const row of tools) {
|
|
215
|
+
lines.push(`| ${markdownCell(row.tool)} | ${row.calls} | ${row.sessions} | ${row.errors} | ${row.nested} | ${row.calls ? Math.round(row.durationMs / row.calls) : '—'} | ${row.lastUsed ? markdownCell(time.format(new Date(row.lastUsed))) : 'never'} |`)
|
|
216
|
+
}
|
|
217
|
+
if (!tools.some((row) => row.calls)) lines.push('', 'No tool usage recorded yet.')
|
|
218
|
+
if (breakdown.length) {
|
|
219
|
+
lines.push('', 'Agent model / thinking at call start (not a tool’s internal service model).', '',
|
|
220
|
+
'| Tool | Model | Thinking | Calls | Sessions | Errors |',
|
|
221
|
+
'| --- | --- | --- | ---: | ---: | ---: |')
|
|
222
|
+
for (const row of breakdown) lines.push(`| ${markdownCell(row.tool)} | ${markdownCell(row.model)} | ${markdownCell(row.thinking)} | ${row.calls} | ${row.sessions} | ${row.errors} |`)
|
|
223
|
+
}
|
|
224
|
+
if (skipped) lines.push('', `Skipped ${skipped} malformed log row(s).`)
|
|
225
|
+
lines.push('', `Log: ${markdownCell(logPath.replaceAll('\\', '/'))}`)
|
|
226
|
+
await ctx.ui.custom<void>((tui, theme, _keybindings, done) =>
|
|
227
|
+
new SkillUsageDialog(tui, theme, lines.join('\n'), done, 'Tool usage'), DIALOG_OVERLAY)
|
|
228
|
+
} catch (error) {
|
|
229
|
+
ctx.ui.notify(`Could not show tool usage: ${error instanceof Error ? error.message : String(error)}`, 'error')
|
|
230
|
+
}
|
|
231
|
+
},
|
|
232
|
+
})
|
|
233
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// One size for every overlay dialog in this package. Change DIALOG_HEIGHT here and every dialog follows:
|
|
2
|
+
// the overlay is capped at that share of the terminal and each dialog pads its list to fill it.
|
|
3
|
+
|
|
4
|
+
/** Share of the terminal height every dialog fills. */
|
|
5
|
+
export const DIALOG_HEIGHT = 0.8
|
|
6
|
+
|
|
7
|
+
/** `ctx.ui.custom(factory, DIALOG_OVERLAY)`: centered, 92% wide, DIALOG_HEIGHT tall. */
|
|
8
|
+
export const DIALOG_OVERLAY = {
|
|
9
|
+
overlay: true,
|
|
10
|
+
overlayOptions: { anchor: 'center', width: '92%', maxHeight: `${DIALOG_HEIGHT * 100}%`, margin: 1 },
|
|
11
|
+
} as const
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Scrolling rows that fit once `chrome` fixed lines (borders, search, header, footer) are drawn.
|
|
15
|
+
* Same floor as the overlay's own percentage, so dialog and overlay agree; read it in render() so a
|
|
16
|
+
* resize takes effect on the next frame.
|
|
17
|
+
*/
|
|
18
|
+
export const dialogRows = (tui: { terminal?: { rows: number } }, chrome: number, min = 1): number =>
|
|
19
|
+
Math.max(min, Math.floor((tui.terminal?.rows ?? 24) * DIALOG_HEIGHT) - chrome)
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Mouse wheel → ↑/↓ key presses, so a dialog reuses its own keyboard handling (clamping, render).
|
|
23
|
+
* Use as `handleMouse(e) { return wheelAsArrows(e, (k) => this.handleInput(k)) }`. Fullscreen mode only:
|
|
24
|
+
* regular mode leaves the mouse to the terminal. Handling the wheel also stops it scrolling the transcript behind.
|
|
25
|
+
*/
|
|
26
|
+
export function wheelAsArrows(event: { type: string; wheelDelta?: number }, handleInput: (data: string) => void): { handled: true } | undefined {
|
|
27
|
+
if (event.type !== 'wheel' || !event.wheelDelta) return undefined
|
|
28
|
+
const key = event.wheelDelta < 0 ? '\x1b[A' : '\x1b[B'
|
|
29
|
+
for (let i = 0; i < Math.min(Math.abs(event.wheelDelta), 20); i++) handleInput(key)
|
|
30
|
+
return { handled: true }
|
|
31
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/** Shared metadata and local-time formatting for the two passive usage logs. */
|
|
2
|
+
export function userTimeZone(): string {
|
|
3
|
+
return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
/** An ISO timestamp with an explicit offset; DST and fractional-hour zones work. */
|
|
7
|
+
export function localTimestamp(date = new Date(), timeZone = userTimeZone()): string {
|
|
8
|
+
const parts = Object.fromEntries(new Intl.DateTimeFormat('en-GB', {
|
|
9
|
+
timeZone, year: 'numeric', month: '2-digit', day: '2-digit',
|
|
10
|
+
hour: '2-digit', minute: '2-digit', second: '2-digit', hourCycle: 'h23',
|
|
11
|
+
timeZoneName: 'longOffset',
|
|
12
|
+
}).formatToParts(date).map(({ type, value }) => [type, value]))
|
|
13
|
+
const offset = parts.timeZoneName.replace('GMT', '') || '+00:00'
|
|
14
|
+
return `${parts.year}-${parts.month}-${parts.day}T${parts.hour}:${parts.minute}:${parts.second}.${String(date.getUTCMilliseconds()).padStart(3, '0')}${offset}`
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Human-readable report dates; keep machine-readable log timestamps separate. */
|
|
18
|
+
export function usageTimeFormat(timeZone = userTimeZone(), locale?: string | string[], now = new Date()) {
|
|
19
|
+
const formatter = new Intl.DateTimeFormat(locale, { timeZone, dateStyle: 'medium', timeStyle: 'short' })
|
|
20
|
+
const offset = localTimestamp(now, timeZone).slice(-6)
|
|
21
|
+
const location = timeZone.split('/').pop()!.replaceAll('_', ' ')
|
|
22
|
+
return {
|
|
23
|
+
label: `${location} · UTC${offset}`,
|
|
24
|
+
format(date: Date): string {
|
|
25
|
+
const text = formatter.format(date)
|
|
26
|
+
const dateOffset = localTimestamp(date, timeZone).slice(-6)
|
|
27
|
+
// A historical DST offset can differ from the current offset in the header.
|
|
28
|
+
return dateOffset === offset ? text : `${text} (UTC${dateOffset})`
|
|
29
|
+
},
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface AgentMetadata {
|
|
34
|
+
model: string
|
|
35
|
+
thinking: string
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Read the selected agent settings, not a classifier/TTS model used by a tool. */
|
|
39
|
+
export function agentMetadata(
|
|
40
|
+
pi: { getThinkingLevel(): string },
|
|
41
|
+
ctx: { model?: { provider: string; id: string } },
|
|
42
|
+
): AgentMetadata {
|
|
43
|
+
let model = 'unknown'
|
|
44
|
+
let thinking = 'unknown'
|
|
45
|
+
// Logging must not block tool_call if settings are unavailable during a switch.
|
|
46
|
+
try {
|
|
47
|
+
const selected = ctx.model
|
|
48
|
+
if (selected) model = `${selected.provider}/${selected.id}`
|
|
49
|
+
} catch { /* Keep unknown metadata; the tool must still execute. */ }
|
|
50
|
+
try { thinking = pi.getThinkingLevel() || 'unknown' }
|
|
51
|
+
catch { /* Older runtimes may not expose the thinking getter. */ }
|
|
52
|
+
return { model, thinking }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Strip terminal controls as well as TSV separators from untrusted metadata. */
|
|
56
|
+
export function logField(value: string): string {
|
|
57
|
+
return value.replace(/[\x00-\x1f\x7f-\x9f]/g, ' ')
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function markdownCell(value: string): string {
|
|
61
|
+
return logField(value).replace(/[\\`*_[\]<>|]/g, '\\$&')
|
|
62
|
+
}
|