isthmos

package module
v0.2.0 Latest Latest
Warning

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

Go to latest
Published: Jul 24, 2026 License: Apache-2.0 Imports: 18 Imported by: 0

README

isthmos

Build Status Latest Release OpenSSF Scorecard License

isthmos (Greek: ισθμός), the narrow passage your tool outputs squeeze through.

A local context-compression layer for agent tool outputs. The core is agent-agnostic: JSON field pruning driven by per-tool rules, importable as a Go package. Adapters connect it to whatever runs your LLM: a native Claude Code PostToolUse hook that rewrites tool_response via updatedToolOutput, and a generic filter mode that works with any agent or CLI that can pipe through a command. Nothing leaves your machine and nothing sits in the credential path.

Status

Early but working: rule-based JSON field pruning, text compression for plain-text payloads, plus byte-level measurement.

Why

Verbose tool outputs (fat MCP JSON, log dumps, API payloads) can be a large share of an agent's context on some workflows and a rounding error on others. Which one your machine has is an empirical question, so isthmos ships measurement first: shadow mode and a per-call byte log show what pruning would save on your traffic before anything is rewritten. A per-tool saving is a local percentage, not a whole-task cost reduction; isthmos claims neither and reports both the local number and its share of everything it measured.

Usage

Claude Code (native hook)
{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "mcp__.*",
        "hooks": [
          {"type": "command", "command": "$HOME/.local/bin/isthmos hook", "timeout": 5}
        ]
      }
    ]
  }
}
Any other agent (generic filter)
some-tool --json | isthmos filter -tool mcp__github__search_repos

Stdin in, pruned stdout out. Wire it into any wrapper, shell function, or orchestrator that can interpose a pipe. Non-JSON payloads pass through untouched unless a matching rule sets the text limits below.

Checking your setup
$ isthmos doctor
version: 0.1.0
rules:   /Users/you/.config/isthmos/rules.json: ok, 4 rules
store:   ok, 12 entries
measure: /Users/you/.local/state/isthmos/measure.jsonl: 84.2KB, last write 2026-07-21T09:14:02Z
hook:    wired in ~/.claude/settings.json
shadow:  off

Exits non-zero when something is actually broken (unreadable or invalid rules, unusable store); a missing rules file is just reported, since no rules means isthmos is a deliberate no-op.

As a Go library
import "github.com/sphragis-oss/isthmos"

rs := isthmos.LoadRules(path)
out, changed := isthmos.Apply(rs, toolName, rawOutput)

Configuration

Rules live in ~/.config/isthmos/rules.json (override with ISTHMOS_RULES). Tool names are glob-matched, listed keys are dropped recursively:

{
  "rules": [
    {"tool": "mcp__github__*", "drop_keys": ["node_id", "avatar_url"], "max_items": 30},
    {"tool": "mcp__*", "max_str": 8000}
  ]
}

Besides drop_keys, a rule can cap payload size generically: max_items truncates any array beyond N elements and max_str truncates any string beyond N bytes (at a rune boundary). Both replace the removed tail with an explicit [isthmos: ... truncated] marker so the model knows the payload is partial; truncation is never silent. When several rules match, the strictest positive limit wins.

Text payloads get their own limits: max_lines keeps head and tail lines with the same reversible marker, and dedup collapses runs of 3 or more identical lines into a labelled count. Error-looking lines (error, fatal, panic, traceback, ...) are never dropped by max_lines. Both apply to raw non-JSON payloads, to a JSON string carrying text, and to long strings embedded in JSON objects, which is where real hook payloads keep their text (stdout for Bash, file.content for Read).

Truncation is head-and-tail, not naive: keep_last reserves part of the max_items budget for the newest entries, and items that look like errors (a truthy error field, or status/level/conclusion values such as failed or fatal) are always kept regardless of position, because those are the items an agent is usually looking for. min_bytes gates a whole rule: payloads smaller than it pass through untouched, so tiny outputs are never rewritten.

See rules.example.json for a starter set covering Atlassian and GitHub MCP noise fields. No config means no rewriting: isthmos is fail-open and only ever emits a replacement when the result is strictly smaller.

Measurement

Every invocation appends one line to ~/.local/state/isthmos/measure.jsonl with before/after byte counts per tool, including calls the rules left untouched, so pruning rules are driven by real data, not guesses. isthmos stats turns that log into a savings table (illustrative output):

$ isthmos stats -since 168h
TOOL                                      CALLS  IN     OUT    SAVED  SAVED%  %ALL   ~TOKENS  REVEALS
mcp__atlassian__searchJiraIssuesUsingJql  42     1.9MB  0.6MB  1.3MB  68.4%   68.0%  340787   3
mcp__github__get_me                       7      12.3KB 4.1KB  8.2KB  66.7%   0.4%   2099     0
TOTAL                                     49     1.9MB  0.6MB  1.3MB  68.4%   68.4%  342886   3
scope: only tool calls that reached isthmos; whole-session context is a larger denominator

SAVED% is local to that tool; %ALL is the same saving as a share of every byte isthmos measured in the window, so a flashy local percentage cannot pose as an overall one. Neither is a session-level or dollar figure: tools your hook matcher never routes to isthmos are not in the log, and published agent traces show repeated context (system prompt, history) is typically the far larger consumer. -file points at a different log, -since bounds the window. The ~TOKENS column is a rough 4-bytes-per-token estimate, not a tokenizer.

REVEALS counts isthmos reveal recoveries attributed to each tool. A reveal means a rule cut something the agent then had to fetch back, paying an extra tool call, so a tool with a rising reveal count is over-pruned: loosen its rule instead of celebrating its SAVED%.

Shadow mode

Set ISTHMOS_SHADOW=1 to measure without rewriting: isthmos computes what the rules would save and logs it, but the hook emits nothing and filter passes stdin through untouched. Nothing is written to the reversibility store. Use it to trial rules on a new machine, then unset it once isthmos stats shows the savings are worth it:

{"type": "command", "command": "ISTHMOS_SHADOW=1 $HOME/.local/bin/isthmos hook", "timeout": 5}

Reversibility

Truncation is reversible. When items or bytes are cut, the original payload is encrypted (AES-256-GCM) into ~/.local/state/isthmos/store/ and the marker carries the recovery command:

[isthmos: 17 of 20 items truncated, full: isthmos reveal de6ac0410501901b]

An agent that needs the full payload can simply run that command; a human can too. Entries expire after 7 days. If the store cannot be written, isthmos does not truncate at all: a marker must never point at a payload that was not stored. Field-pruned payloads (no truncation) are not stored.

Design constraints

  • No proxy in the credential path
  • One static binary, fast cold start (runs on every tool call)
  • Fail-open: any error means untouched passthrough
  • Lossy steps must be reversible or clearly labelled

Contributing

Issues and pull requests are welcome. See CONTRIBUTING.md for setup, style, and the DCO sign-off requirement, and GOVERNANCE.md for how decisions get made. Security reports go through SECURITY.md, never a public issue.

Pruning rules are the highest-value contribution: bring one backed by real before/after byte counts.

License

Apache-2.0

Documentation

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func Apply

func Apply(rs Rules, tool string, output json.RawMessage) (json.RawMessage, bool)

Apply returns the possibly pruned output and whether it shrank

func ApplyWithStore

func ApplyWithStore(rs Rules, tool string, output json.RawMessage, st *Store) (json.RawMessage, bool)

ApplyWithStore also spools the original payload so truncation markers are reversible

func EstTokens

func EstTokens(b int64) int64

EstTokens is a rough 4-bytes-per-token estimate, not a tokenizer

func PruneJSON

func PruneJSON(raw json.RawMessage, drop map[string]bool, lim Limits) ([]byte, error)

PruneJSON drops keys and applies limits recursively, unwrapping a JSON-encoded string payload

Types

type Limits

type Limits struct {
	MaxItems int
	MaxStr   int
	MaxLines int
	KeepLast int
	Dedup    bool
}

type Measure

type Measure struct {
	TS       time.Time `json:"ts"`
	Tool     string    `json:"tool"`
	InBytes  int       `json:"in_bytes"`
	OutBytes int       `json:"out_bytes"`
	Reveal   bool      `json:"reveal,omitempty"`
}

type Rule

type Rule struct {
	Tool     string   `json:"tool"`
	DropKeys []string `json:"drop_keys"`
	MaxItems int      `json:"max_items,omitempty"`
	MaxStr   int      `json:"max_str,omitempty"`
	MaxLines int      `json:"max_lines,omitempty"`
	KeepLast int      `json:"keep_last,omitempty"`
	MinBytes int      `json:"min_bytes,omitempty"`
	Dedup    bool     `json:"dedup,omitempty"`
}

type Rules

type Rules struct {
	Rules []Rule `json:"rules"`
}

func LoadRules

func LoadRules(p string) Rules

LoadRules is fail-open: missing or bad config means no rules

func (Rules) DropFor

func (rs Rules) DropFor(tool string) map[string]bool

DropFor merges drop keys from every rule whose glob matches the tool name

func (Rules) LimitsFor

func (rs Rules) LimitsFor(tool string) Limits

LimitsFor takes the strictest positive limit across matching rules

type Store

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

func OpenStore

func OpenStore(dir string, ttl time.Duration) (*Store, error)

OpenStore prepares the reversibility store, creating dir and key on first use

func (*Store) Load

func (s *Store) Load(id string) ([]byte, error)

Load opens the sealed payload for id

func (*Store) Save

func (s *Store) Save(id string, payload []byte, tool string) error

Save seals the payload under id and opportunistically drops expired entries

func (*Store) Tool added in v0.2.0

func (s *Store) Tool(id string) string

Tool returns the tool name an entry was saved for, or "" when unknown

type ToolStat

type ToolStat struct {
	Tool     string
	Calls    int
	InBytes  int64
	OutBytes int64
	Reveals  int
}

func Aggregate

func Aggregate(r io.Reader, since time.Time) []ToolStat

Aggregate sums measurements per tool, skipping lines it cannot parse

func (ToolStat) Saved

func (s ToolStat) Saved() int64

Directories

Path Synopsis
cmd
isthmos command

Jump to

Keyboard shortcuts

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