Files
multica/server/internal/daemon/usage/scanner.go
Bohan Jiang 8c518c350a feat(agent): add Pi agent runtime support (#1064)
* feat(agent): add Pi agent runtime support

Add Pi as a new agent runtime provider, following the established adapter
pattern. Pi CLI outputs JSONL events which are parsed for messages, tool
calls, and usage tracking.

Backend:
- New piBackend implementing the Backend interface (pi.go)
- Pi CLI discovery via MULTICA_PI_PATH env var or PATH lookup
- JSONL event stream parsing (agent_start, message_update, thinking_update,
  tool_execution_start/end, agent_end)
- Usage scanner for ~/.pi/sessions/*.jsonl files
- Runtime config injection via AGENTS.md
- Skill injection to .pi/agent/skills/

Frontend:
- Pi provider logo (teal π icon)
- Pi label in transcript dialog

Docs:
- Updated all provider lists in README, CLI_INSTALL, and docs

* fix(agent): filter Pi usage scanner to agent_end events only

Address review feedback: restrict usage parsing to agent_end events
which contain cumulative totals, preventing potential inaccuracy if
Pi adds usage fields to other event types in the future.

* fix(agent): align Pi runtime with real CLI flags, event schema, and custom_args

- Flags: Pi's CLI uses `--mode json` (not `--output-format jsonl`), has no
  `--yolo` (explicit `--tools` allowlist instead), takes the prompt as a
  positional argument (not `-p <prompt>`), splits model as
  `--provider <name> --model <id>`, and treats `--session` as a file path
  that must exist before spawn.
- Event parsing: rewrite the stream event struct to match Pi's actual
  JSON event schema (`message_update.assistantMessageEvent.delta`,
  `turn_end.message.usage.{input,output,cacheRead,cacheWrite}`, etc.).
- Sessions: generate/persist session files under ~/.multica/pi-sessions/
  and use the file path as the opaque SessionID returned to the daemon.
- Usage scanner: read assistant `message` events from the same session
  files (Pi's session-file schema, distinct from the stdout stream).
- Custom args: consume `ExecOptions.CustomArgs` via `filterCustomArgs`
  with a Pi-specific blocked set (`-p`, `--print`, `--mode`, `--session`)
  so Pi matches the pattern shared by every other agent backend.
2026-04-16 15:42:40 +08:00

83 lines
2.2 KiB
Go

package usage
import (
"log/slog"
)
// Record represents aggregated token usage for one (date, provider, model) tuple.
type Record struct {
Date string `json:"date"` // "2006-01-02"
Provider string `json:"provider"` // "claude" or "codex"
Model string `json:"model"`
InputTokens int64 `json:"input_tokens"`
OutputTokens int64 `json:"output_tokens"`
CacheReadTokens int64 `json:"cache_read_tokens"`
CacheWriteTokens int64 `json:"cache_write_tokens"`
}
// Scanner scans local CLI log files for token usage data.
type Scanner struct {
logger *slog.Logger
}
// NewScanner creates a new usage scanner.
func NewScanner(logger *slog.Logger) *Scanner {
return &Scanner{logger: logger}
}
// Scan reads local log files for all supported agent runtimes (Claude Code,
// Codex, OpenCode, OpenClaw, Hermes) and returns aggregated usage records
// keyed by (date, provider, model). Supports Claude Code, Codex, OpenCode,
// OpenClaw, Hermes, and Pi.
func (s *Scanner) Scan() []Record {
var records []Record
claudeRecords := s.scanClaude()
records = append(records, claudeRecords...)
codexRecords := s.scanCodex()
records = append(records, codexRecords...)
openCodeRecords := s.scanOpenCode()
records = append(records, openCodeRecords...)
openClawRecords := s.scanOpenClaw()
records = append(records, openClawRecords...)
hermesRecords := s.scanHermes()
records = append(records, hermesRecords...)
piRecords := s.scanPi()
records = append(records, piRecords...)
return records
}
// aggregation key for merging records.
type aggKey struct {
Date string
Provider string
Model string
}
func mergeRecords(records []Record) []Record {
m := make(map[aggKey]*Record)
for _, r := range records {
k := aggKey{Date: r.Date, Provider: r.Provider, Model: r.Model}
if existing, ok := m[k]; ok {
existing.InputTokens += r.InputTokens
existing.OutputTokens += r.OutputTokens
existing.CacheReadTokens += r.CacheReadTokens
existing.CacheWriteTokens += r.CacheWriteTokens
} else {
copy := r
m[k] = &copy
}
}
result := make([]Record, 0, len(m))
for _, r := range m {
result = append(result, *r)
}
return result
}