parser

package
v0.5.6 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 12, 2026 License: AGPL-3.0 Imports: 15 Imported by: 0

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

View Source
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

Agents lists every supported format. Validation outside the parser (the announce endpoint) derives from it, so this enum stays the one owner of "which agent formats exist" and a format the parser handles is never rejected at announce.

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

func HashString(content string) string

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 SentinelBytes

func SentinelBytes(sha string, n int, media, filePath, detail string) []byte

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 Agent

type Agent string

Agent identifies which on-disk format a session uses.

const (
	AgentClaude Agent = "claude"
	AgentCodex  Agent = "codex"
	AgentPi     Agent = "pi"
)

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

func ExtractBodies(agent Agent, region []byte, enc BodyEncoder) ([]byte, []Body, error)

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

func ClassifyResultBody(firstByte byte) (BodyKind, string)

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
	Fallbacks   []FallbackOp

	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 parse of a session produces: the rows to write and the session's timestamp span. It deliberately carries no message/token counters: the session rollups are derived downstream from the deduped set the rebuild actually writes, so a counter here would only risk drifting from it.

type FallbackOp added in v0.2.5

type FallbackOp struct {
	// MessageOrdinal ties an assistant-side op to the message op the same entry produced
	// (the same way Usage ties to its message). It is nil on a system-side op, which
	// produces no message row and must not disturb ordinals.
	MessageOrdinal *int
	FromModel      string
	ToModel        string
	// Trigger, RefusalCategory, and RefusalExplanation come only from the system entry.
	Trigger            string
	RefusalCategory    string
	RefusalExplanation string
	// Declined* are the token counts of the declined attempt (the type=="message"
	// iteration entries), meaningful only on an assistant-side op that saw a
	// fallback_message entry. Zero elsewhere.
	DeclinedInput      int
	DeclinedOutput     int
	DeclinedCacheWrite int
	DeclinedCacheRead  int
	// DeclinedObserved is true only when the declined spend was actually summed from
	// fallback_message iteration entries. An assistant entry that carries a fallback
	// content block but no usage.iterations is a real fallback whose declined counts
	// were never reported, so it leaves this false and the zero Declined* stay
	// "unmeasured" rather than reading as a measured zero-token attempt.
	DeclinedObserved bool
	OccurredAt       time.Time
	// DedupKey is the top-level requestId when present, else the assistant message id.
	// Every line of one logical fallback repeats it, so the store dedups and merges on it.
	DedupKey string
}

FallbackOp records that a Claude Fable turn was declined by the safety classifier and re-served by a lower model. It is emitted only from explicit Claude Code markers (a "fallback" content block, a usage.iterations "fallback_message" entry, or a "model_refusal_fallback" system entry): never from a bare model-string change, which is an intentional switch (a /model command, a resume under a different default, a subagent on a smaller model), not a fallback.

One logical fallback surfaces across several JSONL lines that the store merges by DedupKey: Claude splits one API message into several assistant entries sharing the requestId (each repeating the same iterations), and a separate system entry carries the refusal category. The assistant side brings MessageOrdinal and the declined attempt's token counts; the system side brings Trigger, RefusalCategory, and RefusalExplanation. Either can be the first to arrive, so the store's merge fills a field from whichever line carries it. A field the source did not observe is left zero (MessageOrdinal nil, token counts zero, strings empty) so the merge can tell "unset" from a real value.

type Idx

type Idx int

Idx selects an array element by its 0-based position.

type Key

type Key string

Key selects an object member by name.

type LocatedSpan

type LocatedSpan struct {
	PathIndex int
	Span      ValueSpan
}

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
	ThinkingBytes int
	Model         string
	Timestamp     time.Time
	HasThinking   bool
	HasToolUse    bool
}

Message is one turn. Content holds the conversational text (stored inline and searchable); ThinkingText holds the concatenated reasoning plaintext, which is empty when the agent redacts it (see ThinkingBytes).

ThinkingBytes is the reasoning-trace weight: the byte size of the turn's reasoning material, whether or not its plaintext survived. Current Claude Code and Codex ship the reasoning encrypted (Claude leaves a "signature", Codex an "encrypted_content" blob) and drop the plaintext, so ThinkingText is empty while the reasoning still happened; the encrypted payload's length tracks the hidden reasoning volume closely (measured r=0.97 for Claude signatures against the rare blocks that kept their plaintext, r=0.997 for Codex encrypted_content against the reasoning-token count it reports). pi keeps its thinking in the clear, so there the weight is just the plaintext length. It is the per-turn volume the observed-thinking signal ranks per model; HasThinking is true whenever a reasoning block was present, decoupled from whether its text was redacted.

type MessageOp

type MessageOp struct {
	Ordinal       int
	Role          Role
	Content       string
	ThinkingText  string
	ThinkingBytes int
	Model         string
	HasThinking   bool
	HasToolUse    bool
	Timestamp     time.Time
}

MessageOp is one message write in a Delta. Each ordinal is written exactly once, carrying the turn's complete text: the reducer folds a whole session in one pass, so a turn's fragments are joined before the op is emitted, never appended in place. ThinkingBytes is the turn's reasoning-trace weight (see Message.ThinkingBytes): plaintext length where the agent logs it, else the encrypted payload length, so a redacted turn still records how much it thought.

type Reducer added in v0.3.0

type Reducer struct {
	// contains filtered or unexported fields
}

Reducer folds one session's raw bytes into a Delta. A parse always covers the whole session from byte zero: construct a Reducer, Feed the stored regions in offset order (each must contain only complete lines; the ingest protocol guarantees every stored byte ends on a newline), and Finish to flush the open turn and take the Delta. Because the whole parse is one Reducer, an open turn folds freely across region boundaries and no state is ever serialized. Malformed individual lines are skipped; a Feed error means the region could not be processed.

func NewReducer added in v0.3.0

func NewReducer(agent Agent) (*Reducer, error)

NewReducer returns a Reducer for one session of the given agent.

func (*Reducer) Feed added in v0.3.0

func (x *Reducer) Feed(region []byte, baseOffset int64) error

Feed advances the parse over the next raw region, which begins at baseOffset.

func (*Reducer) Finish added in v0.3.0

func (x *Reducer) Finish() Delta

Finish emits any still-open turn (the final, in-progress turn of a live session has no closing line) and returns the session's Delta.

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
	Fallbacks   []FallbackOp
}

Session is the parsed projection of one session file.

func Parse

func Parse(agent Agent, raw []byte) (Session, error)

Parse parses a whole session in one shot, assembling a Session from the same reducer the server's rebuild uses. Keeping the batch parser as a thin wrapper over Reducer guarantees the two can never diverge.

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.

type ValueSpan

type ValueSpan struct {
	Start int64
	End   int64
}

ValueSpan is the byte range [Start,End) of one located JSON value within a single JSONL line, relative to the line's first byte (offset 0 = first byte of the line). It matches gjson's value Index and Index+len(Raw) exactly.

func LocateValue

func LocateValue(ctx context.Context, path []Step, next func() ([]byte, error)) (ValueSpan, bool, error)

LocateValue is the single-path convenience wrapper over LocateValues. ok is false when the path is absent from the line.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL