Documentation
¶
Overview ¶
Package parser turns the raw bytes of an agent session file into akari's normalized projection: ordered messages, tool calls, and token usage. It runs on the server, so all per-agent format knowledge lives here in one place and can be improved and re-run against stored raw bytes.
Index ¶
- Constants
- func CanonicalBodyReader(ctx context.Context, f io.ReaderAt, lineOffset int64, span ValueSpan, ...) io.Reader
- func HashString(content string) string
- func LocateToolBodies(ctx context.Context, agent Agent, f io.ReaderAt, lineOff, lineLen int64, ...) error
- func Reduce(agent Agent, st State, region []byte, baseOffset int64) (State, Delta, error)
- func SentinelBytes(sha string, n int, media, filePath, detail string) []byte
- func WalkArrayElements(ctx context.Context, arrPath []Step, subKeys []Step, ...) error
- type Agent
- type Attachment
- type AttachmentOp
- type Body
- type BodyEncoder
- type BodyKind
- type BodyLocation
- type Delta
- type Idx
- type Key
- type LocatedSpan
- type Message
- type MessageOp
- type Role
- type Session
- type State
- type Step
- type ToolCall
- type ToolResultOp
- type Usage
- type ValueSpan
Constants ¶
const ( ContentRaw = "application/octet-stream" // stored verbatim, key over the raw bytes ContentZstd = "application/zstd" // stored zstd-compressed, key over the compressed bytes )
Storage content types name how the bytes the CAS holds are encoded, which is a separate axis from a body's own MediaType (its semantic type, e.g. application/json). A small body is stored verbatim; a body large enough to be worth it is stored zstd-compressed. Compression is deliberately a client concern: the server stores and serves these bytes opaquely and never (de)compresses, and the browser decompresses transparently via Content-Encoding. These constants live here, in the dependency-free parser, so the server can name them without linking any compression code.
Variables ¶
This section is empty.
Functions ¶
func CanonicalBodyReader ¶
func CanonicalBodyReader(ctx context.Context, f io.ReaderAt, lineOffset int64, span ValueSpan, kind BodyKind) io.Reader
CanonicalBodyReader returns an io.Reader that streams the canonical body bytes for a value located at span [Start,End) within a line that begins at lineOffset in f. The reader pulls from f lazily and holds only a bounded window, never the whole body, so a tool body of hundreds of MiB streams through O(window) memory rather than being buffered. The bytes it yields are byte-identical to what bodyContent / setToolInput would store inline today, which is what lets the hash match the server's.
kind selects the canonicalization:
- BodyRaw: the raw span, copied verbatim.
- BodyJSONString: the section inside the quotes, JSON-string-decoded on the fly.
- BodyArrayText: the array's contributing blocks, decoded and newline-joined.
ctx threads into the BodyArrayText enumeration so a canceled hash or upload of a huge array result aborts during the lazy walk rather than scanning the whole array. The BodyRaw and BodyJSONString readers stream a single contiguous span and do not run a walk, so they take no ctx of their own.
func HashString ¶
HashString returns the lowercase hex sha256 of content. It hashes in place (the digest consumes the string in fixed blocks) so a large body is never copied into a byte slice just to be hashed. It hashes raw bytes; the CAS key is the hash of the STORED bytes, which differ from the raw body when the encoder compresses it, so this is the key only for an uncompressed (raw-stored) body. The server's inline fallback write path computes the identical hash over the identical raw bytes.
func LocateToolBodies ¶
func LocateToolBodies(ctx context.Context, agent Agent, f io.ReaderAt, lineOff, lineLen int64, emit func(BodyLocation) error) error
LocateToolBodies enumerates the tool input and result bodies in one transcript line, in source order, by streaming the line from the file rather than parsing it whole. It is the streaming twin of toolBodyFields: the same agent knowledge (which paths are bodies, which media each gets), but expressed as byte spans and a canonicalization kind so a hundreds-of-MiB body is never resident.
The line lives at [lineOff, lineOff+lineLen) in f. Enumeration reads only the small structural parts (block `type` discriminators), never a body. A line whose shape is unknown or carries no tool body yields nothing.
Results stream through emit, called once per located body in source order, rather than being collected into a slice. This lets the client lift one body at a time (upload it, rewrite its span) without the parser holding a slice whose size grows with the block count, so peak memory stays bounded by the structural scan, not by how many bodies a line carries. If emit returns an error the walk aborts and that error is returned. ctx threads through the structural scans so a canceled lift stops promptly even mid-line.
func Reduce ¶
Reduce advances the parse of one agent over a raw region that begins at baseOffset. The region must contain only complete lines (the ingest protocol guarantees every stored byte ends on a newline). It returns the new carry-over state and the projection delta. Malformed individual lines are skipped, exactly as the batch parser did; an error means the region could not be processed.
func SentinelBytes ¶
SentinelBytes renders the CAS reference that replaces a tool body, for callers outside the package (the client's streaming big-line path) that build a rewritten line from located spans rather than from a parsed line. It is the same encoding RewriteLine uses, so a body lifted by streaming produces a byte-identical sentinel to one lifted by the buffered path. filePath and detail are empty for anything that is not a tool input carrying them.
func WalkArrayElements ¶
func WalkArrayElements(ctx context.Context, arrPath []Step, subKeys []Step, next func() ([]byte, error), visit func(idx int, elemSpan ValueSpan, subSpans map[Step]ValueSpan) error) error
WalkArrayElements scans the JSONL line exactly once, streaming, and invokes visit for each direct element of the array located at arrPath, in source order. For every element it reports the element's own byte span plus, for an object element, the byte spans of any requested subKeys that are present as direct members. Elements that are not objects (a bare string, a number) simply carry an empty subSpans map.
This is the single-pass primitive behind the block walkers: a transcript line's content array can hold many blocks, and probing each block index with its own LocateValues pass restreams the whole line per element (O(line * elements)). Walking the array once is O(line) total while keeping memory at O(path depth): element bodies (which can be hundreds of MiB) are never buffered, only the tiny element span and the small subKey spans are retained, and each is handed to visit as soon as the element closes so nothing accumulates across elements.
next supplies the line incrementally exactly as LocateValues consumes it: each call returns the next chunk of bytes until it reports io.EOF, and the walker is correct for any chunking. visit is called with the element index (0-based), the element span, and a map from the matched subKey Step to its span. Returning a non-nil error from visit aborts the walk and is propagated. The reported spans are byte-identical to gjson (value .Index for Start, .Index+len(.Raw) for End).
Only direct members of an element object are matched for subKeys: a subKey is a single Step (for example Key("type")), not a nested path, because block discriminators and bodies live one level under the element.
ctx lets a caller abort a walk over a huge array promptly: it is checked once per chunk returned by next() and again every ctxCheckBytes within a chunk, so a canceled hash or upload of a large array result stops mid-enumeration rather than draining the whole region.
Types ¶
type Attachment ¶
type Attachment struct {
MessageOrdinal int
SHA256 string
Bytes int
MediaType string
Filename string
Content string
}
Attachment is one binary blob attached to a message: today a lifted image (a Codex image-generation result or a pasted image). The bytes live in the CAS keyed by SHA256; Bytes is the raw (decoded) size and MediaType its semantic type, so the UI can render it without fetching. Content carries the decoded bytes only on the inline path (a small image with no sentinel, used by the batch/test parser); when the client lifted the image it is empty and SHA256 names the already-uploaded blob.
type AttachmentOp ¶
type AttachmentOp struct {
MessageOrdinal int
SHA256 string
Content string
Bytes int
MediaType string
Filename string
}
AttachmentOp records one binary attachment (today a lifted image) against a message. Like a tool body it reaches the CAS by one of two paths: when the client lifted the image and left a sentinel, SHA256 is set and the server records the reference with no blob write; otherwise Content holds the decoded bytes inline for the server to store. Bytes and MediaType describe the decoded image either way, so the row's metadata is the same whichever path delivered the bytes.
type Body ¶
type Body struct {
SHA256 string
Bytes int
MediaType string
Stored []byte
ContentType string
Kind string
}
Body is one tool body the client lifts out of the transcript. Stored holds the exact bytes the CAS keeps, which are the raw canonical bytes for a small body and the zstd-compressed form for a large one; ContentType says which. SHA256 is the key, the sha256 of Stored. Bytes is the RAW (uncompressed) canonical length, the size the transcript sentinel and the tool_calls row record (what a reader thinks of as the body's size), independent of how the bytes are stored. Kind is "input" or "result", for diagnostics and tests.
func ExtractBodies ¶
ExtractBodies lifts every tool input and result body out of a transcript region of complete lines, returning the rewritten region (each body replaced by a CAS sentinel) and the bodies that were lifted, deduped by sha256 within the call so a body that recurs is uploaded once. The region must be line aligned (the ingest protocol guarantees it); each line is rewritten independently, so the line count, the newline positions, and any non-body bytes are preserved exactly, which keeps the rewritten stream resumable and turn aligned.
A line that is not valid JSON, or carries no tool body, passes through unchanged. Re-running ExtractBodies over already-rewritten output is a no-op (the sentinels are skipped), which is what makes a re-sync of an unchanged file upload zero bodies and zero transcript bytes.
func RewriteLine ¶
func RewriteLine(agent Agent, line []byte, enc BodyEncoder) ([]byte, []Body)
RewriteLine replaces each tool body in one transcript line with a sentinel and returns the rewritten line plus the bodies it lifted. The line keeps its trailing newline (or lack of one) untouched: rewriting happens strictly inside the JSON value spans, so the line's length changes only by the body/sentinel size delta and its boundary stays a boundary. The client uses it to transform the transcript one line at a time so a giant tool body is never buffered as part of a whole region. enc encodes each lifted body into the bytes the CAS stores and names the key; the sentinel carries that key (so the transcript references the stored bytes) while still recording the raw body length.
type BodyEncoder ¶
type BodyEncoder interface {
// EncodeBody returns the CAS key (sha256 of the stored bytes), the stored bytes,
// and their storage content type (ContentRaw or ContentZstd) for a body's raw
// canonical bytes.
EncodeBody(raw []byte) (sha string, stored []byte, contentType string)
}
BodyEncoder turns a tool body's canonical raw bytes into the bytes the CAS actually stores, deciding per body whether compression is worth it. The CAS key is the sha256 of the STORED bytes, so the same raw body must always encode to the same stored bytes and key; the client supplies a deterministic implementation. The parser depends only on this interface, never on a compression library, which is what keeps the server (which links the parser but never compresses) free of one. The streaming big-line path lives in the client and drives the same encoder directly, so a body encodes identically whether buffered or streamed.
type BodyKind ¶
type BodyKind int
BodyKind selects how the raw located value is canonicalized into the bytes the CAS stores. The three kinds mirror the branches of bodyContent: a value copied verbatim, a JSON string whose decoded contents are the body, and an array of blocks flattened to its text.
const ( // BodyRaw copies the value's raw bytes verbatim. This is the canonical form // for genuine objects (a result whose body is a JSON object) and for the // claude/pi tool input, where the stored body is exactly input.Raw. BodyRaw BodyKind = iota // BodyJSONString treats the value as a JSON string and emits its decoded // contents, matching gjson .String(). This is the canonical form for a string // result body and for codex arguments (a JSON-encoded string). BodyJSONString // BodyArrayText treats the value as an array of typed blocks and emits the // blockText flattening: the decoded text of each contributing element joined // by a single newline. BodyArrayText // BodyBase64 treats the value as a JSON string carrying a base64-encoded binary // blob (optionally wrapped in a `data:<media>;base64,` URI) and emits the decoded // binary bytes. This is the canonical form for the image payloads Codex inlines // (image_generation results, data-URI image_url blocks, pasted images): the CAS // stores the real PNG/JPEG bytes, not the base64 text, so a reader serves the blob // directly under its image media type. The decode ignores \r and \n exactly as // encoding/base64 does, so the streamed result is byte-identical to the buffered // base64.StdEncoding.DecodeString the extractor uses. BodyBase64 )
func ClassifyResultBody ¶
ClassifyResultBody peeks the first byte of a result value's raw span to choose the canonicalization kind and media type, matching bodyContent's switch without reading the whole value. firstByte is line[span.Start], the first structural byte of the value (results are located at the value's own start, so there is no leading whitespace to skip).
The mapping mirrors bodyContent exactly:
- '"' -> a JSON string: decoded contents, text/plain.
- '[' -> an array of blocks: blockText flattening, text/plain.
- '{' -> a JSON object: raw JSON, application/json.
- anything else (a bare scalar) -> raw, text/plain.
The absent-value case (empty body, empty media) is the caller's concern: a classifier needs a byte to look at, so callers handle "no value located" before reaching here.
type BodyLocation ¶
type BodyLocation struct {
Span ValueSpan
Kind BodyKind
Media string
FilePath string
Detail string
}
BodyLocation is one tool body found in a transcript line by streaming, ready to be lifted to the CAS without ever buffering the body. Span is the raw byte range of the value within the line (relative to the line's first byte), the bytes the sentinel replaces. Kind and Media say how to canonicalize the raw value into the bytes the CAS stores (CanonicalBodyReader), so the streamed body is byte identical to what the server records inline today. FilePath is the top-level file_path string of a JSON tool input (empty otherwise), and Detail is the input's short human-scannable summary (a command, pattern, URL, or description; empty otherwise), both extracted here so the sentinel the caller builds carries them, exactly as the buffered RewriteLine path does via sentinelFilePath and sentinelDetail.
type Delta ¶
type Delta struct {
Messages []MessageOp
ToolCalls []ToolCall
ToolResults []ToolResultOp
Usage []Usage
Attachments []AttachmentOp
Started time.Time
Ended time.Time
// Cwd and GitBranch are the last values seen in the region. The store ignores
// them (announce owns those columns); the test-facing Parse wrapper uses them.
Cwd string
GitBranch string
}
Delta is everything one Reduce call produces for one raw region: the rows to write and the region's timestamp span. It carries operations, not a whole session, so applying it is append-only work proportional to the region, never to the session. It deliberately carries no message/token counters: the session rollups are derived downstream from the rows that actually persist (the store dedups messages and usage on insert), so a counter here would only risk drifting from that surviving set.
type LocatedSpan ¶
LocatedSpan pairs a located value's span with the index of the path that produced it, so callers can correlate results back to their request even when some paths are absent and skipped.
func LocateValues ¶
func LocateValues(ctx context.Context, paths [][]Step, next func() ([]byte, error)) ([]LocatedSpan, error)
LocateValues scans the JSONL line exactly once, streaming, and returns the byte span of every requested path that is present, in source order (the order the values appear in the line, which for distinct leaf paths is also request order for well-formed transcripts). Absent paths are skipped.
The motivation is lifting very large tool-call bodies (a single JSON value, possibly hundreds of MiB) out of a transcript line without ever buffering the whole line or the whole value. The returned spans are byte-identical to gjson's value .Index (Start) and .Index+len(.Raw) (End), so the exact bytes they delimit can be sha256'd to match what the server stored.
next supplies the line incrementally: each call returns the next chunk of bytes (any size greater than zero) until it reports io.EOF. The scanner is correct for any chunking, including a single byte per call, and it retains only O(path depth) state plus the constant overhead of one chunk at a time. It never accumulates the bytes of a located value.
ctx lets a caller cancel a scan of a huge line promptly: it is checked once per chunk returned by next() and again every ctxCheckBytes within a chunk, so a canceled hash or upload aborts instead of streaming the whole value.
type Message ¶
type Message struct {
Ordinal int
Role Role
Content string
ThinkingText string
Model string
Timestamp time.Time
HasThinking bool
HasToolUse bool
}
Message is one turn. Content holds the conversational text (stored inline and searchable); ThinkingText holds concatenated reasoning blocks.
type MessageOp ¶
type MessageOp struct {
Ordinal int
Role Role
Content string
ThinkingText string
Model string
HasThinking bool
HasToolUse bool
Timestamp time.Time
}
MessageOp is one message write in a Delta. Each ordinal is written exactly once: a turn is folded whole within the region that contains it (the ingest protocol keeps a turn inside one chunk), so Content and ThinkingText are the complete text, not a fragment to append.
type Role ¶
type Role string
Role is a normalized message role.
const ( RoleUser Role = "user" RoleAssistant Role = "assistant" RoleSystem Role = "system" RoleTool Role = "tool" // RoleContext marks an injected-context turn: agent framing (project // instructions, the environment block) that an agent prepends to a session // rather than a human prompt. It is a distinct role so every role='user' // reader (the session title, user_message_count, the prompt-hygiene // aggregate) excludes it, and the transcript renders it in its own Context // section instead of as the opening turn. Only Codex records such framing as a // transcript message today (Claude and pi keep their framing in the system // prompt, which akari never ingests); the role is agent-agnostic so any // reducer can classify into it. RoleContext Role = "context" )
type Session ¶
type Session struct {
Cwd string
GitBranch string
StartedAt time.Time
EndedAt time.Time
Messages []Message
ToolCalls []ToolCall
UsageEvent []Usage
Attachments []Attachment
}
Session is the parsed projection of one session file.
type State ¶
type State struct {
// NextOrdinal is the ordinal the next message will take.
NextOrdinal int `json:"next_ordinal"`
// Model is the sticky current model (Codex carries it across lines, and a
// later region's usage line may need the model named in an earlier region).
Model string `json:"model"`
}
State is the carry-over a parser needs to resume from a byte cursor. It is serialized to the session_raw row between chunks and is bounded in size: it holds only counters, never any per-message accumulation or an open turn. A Codex turn folds a run of reasoning/function_call items and the final text into one assistant message, but that fold never crosses a region: the ingest protocol cuts chunks on turn boundaries, so a whole turn always lands in one region. That is what lets the open turn live in the reducer for the span of a single Reduce call and never be serialized here.
func DecodeState ¶
DecodeState parses serialized parser state, defaulting unset fields to the initial state. An empty or "{}" blob yields the initial state.
type Step ¶
type Step interface {
// contains filtered or unexported methods
}
Step is one segment of a path into a JSON document: either an object Key or an array Idx. The marker method keeps the two step kinds in a single closed set so callers cannot smuggle in an arbitrary type.
type ToolCall ¶
type ToolCall struct {
MessageOrdinal int
CallIndex int
ToolName string
Category string
FilePath string
Detail string
InputJSON string
InputSHA256 string
InputBytes int
InputMediaType string
ResultBody string
ResultSHA256 string
ResultBytes int
ResultMediaType string
ResultStatus string // "ok" | "error" | "" (pending)
CallUID string
}
ToolCall is one tool invocation attached to a message. InputJSON and ResultBody carry the bulky bodies the CAS stores; ResultBytes and ResultMediaType describe ResultBody exactly, so the recorded size and media type always match the stored content. InputJSON is the raw tool-input JSON; ResultBody is the result body (a tool result that is an array of text blocks is flattened to its text).
CallUID is the agent's own call id; the incremental pipeline records it on the row so a tool result arriving in a later line (Claude delivers tool_result in the following user entry, which may land in a later region) is back-patched by an UPDATE keyed on it rather than by a parser-held id->row map. It is carried on inserts only; a Session assembled for tests ignores it. InputSHA256, InputBytes, and InputMediaType carry a CAS reference when the client lifted the input body to the CAS and left a sentinel in the transcript: the server records the reference instead of re-storing the body. InputJSON is empty in that case. When the body travels inline, InputJSON holds it and the SHA/bytes/media fields are unset (the server hashes and sizes the inline body).
Detail is a bounded, human-scannable summary of the input the UI shows when a call has no file_path: a shell command, a search pattern, a fetched URL, or an agent's description, derived from the input's top-level JSON keys. On the inline path it is derived here from the raw input; on the sentinel path the body is no longer readable, so it rides the sentinel and comes back through the casRef, exactly the way FilePath does.
type ToolResultOp ¶
type ToolResultOp struct {
CallUID string
Body string
BodySHA256 string
Bytes int
MediaType string
Status string
}
ToolResultOp back-patches a tool call's result, matched by the agent's call id.
A result body normally travels inline (Body holds it) and the server writes it to the CAS. When the client has already lifted the body to the CAS and left a sentinel in the transcript, BodySHA256 is set instead: the server records the reference and writes no blob, but Bytes and MediaType still describe the body exactly so the row's metadata is unchanged.
type Usage ¶
type Usage struct {
MessageOrdinal *int
Model string
Input int
Output int
CacheWrite int
CacheRead int
Reasoning int
OccurredAt time.Time
DedupKey string
SourceOffset int64
SourceIndex int
}
Usage is one token-accounting record. MessageOrdinal is nil for session-level totals not tied to a single message. SourceOffset and SourceIndex identify the originating line (and its position within it) so incremental inserts are idempotent even for agents whose usage carries no native dedup key.