mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-25 20:15:37 +02:00
* 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.
127 lines
3.1 KiB
Go
127 lines
3.1 KiB
Go
package usage
|
|
|
|
import (
|
|
"bufio"
|
|
"encoding/json"
|
|
"os"
|
|
"path/filepath"
|
|
"time"
|
|
|
|
"github.com/multica-ai/multica/server/pkg/agent"
|
|
)
|
|
|
|
// scanPi reads Pi session JSONL logs produced by the multica daemon and
|
|
// extracts token usage from assistant `message` events.
|
|
//
|
|
// The agent backend writes every run's session file into
|
|
// ~/.multica/pi-sessions/ (see agent.PiSessionDir). Pi appends events to
|
|
// the file as it runs; each assistant `message` event carries cumulative
|
|
// usage for that turn in the shape:
|
|
//
|
|
// {"type":"message","timestamp":"...",
|
|
// "message":{"role":"assistant","model":"...",
|
|
// "usage":{"input":N,"output":N,"cacheRead":N,"cacheWrite":N,...}}}
|
|
func (s *Scanner) scanPi() []Record {
|
|
root, err := agent.PiSessionDir()
|
|
if err != nil || root == "" {
|
|
return nil
|
|
}
|
|
if info, err := os.Stat(root); err != nil || !info.IsDir() {
|
|
return nil
|
|
}
|
|
|
|
files, err := filepath.Glob(filepath.Join(root, "*.jsonl"))
|
|
if err != nil {
|
|
s.logger.Debug("pi glob error", "error", err)
|
|
return nil
|
|
}
|
|
|
|
var records []Record
|
|
for _, f := range files {
|
|
records = append(records, s.parsePiFile(f)...)
|
|
}
|
|
return mergeRecords(records)
|
|
}
|
|
|
|
type piSessionLine struct {
|
|
Type string `json:"type"`
|
|
Timestamp string `json:"timestamp"`
|
|
Message *struct {
|
|
Role string `json:"role"`
|
|
Model string `json:"model"`
|
|
Usage *struct {
|
|
Input int64 `json:"input"`
|
|
Output int64 `json:"output"`
|
|
CacheRead int64 `json:"cacheRead"`
|
|
CacheWrite int64 `json:"cacheWrite"`
|
|
} `json:"usage"`
|
|
} `json:"message"`
|
|
}
|
|
|
|
// parsePiFile walks a single session file and emits one Record per
|
|
// assistant message with non-zero usage. Each assistant message carries
|
|
// the cost for that specific turn (not cumulative), so they can be
|
|
// summed by mergeRecords downstream.
|
|
func (s *Scanner) parsePiFile(path string) []Record {
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
defer f.Close()
|
|
|
|
scanner := bufio.NewScanner(f)
|
|
scanner.Buffer(make([]byte, 0, 256*1024), 8*1024*1024)
|
|
|
|
var records []Record
|
|
for scanner.Scan() {
|
|
line := scanner.Bytes()
|
|
if !bytesContains(line, `"usage"`) {
|
|
continue
|
|
}
|
|
var entry piSessionLine
|
|
if err := json.Unmarshal(line, &entry); err != nil {
|
|
continue
|
|
}
|
|
if entry.Type != "message" || entry.Message == nil || entry.Message.Usage == nil {
|
|
continue
|
|
}
|
|
if entry.Message.Role != "assistant" {
|
|
continue
|
|
}
|
|
u := entry.Message.Usage
|
|
if u.Input == 0 && u.Output == 0 && u.CacheRead == 0 && u.CacheWrite == 0 {
|
|
continue
|
|
}
|
|
|
|
date := ""
|
|
if entry.Timestamp != "" {
|
|
if ts, err := time.Parse(time.RFC3339Nano, entry.Timestamp); err == nil {
|
|
date = ts.Local().Format("2006-01-02")
|
|
}
|
|
}
|
|
if date == "" {
|
|
info, err := os.Stat(path)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
date = info.ModTime().Local().Format("2006-01-02")
|
|
}
|
|
|
|
model := entry.Message.Model
|
|
if model == "" {
|
|
model = "unknown"
|
|
}
|
|
|
|
records = append(records, Record{
|
|
Date: date,
|
|
Provider: "pi",
|
|
Model: model,
|
|
InputTokens: u.Input,
|
|
OutputTokens: u.Output,
|
|
CacheReadTokens: u.CacheRead,
|
|
CacheWriteTokens: u.CacheWrite,
|
|
})
|
|
}
|
|
return records
|
|
}
|