SLOPSHOPPER

awcompact

Compacts a long tool result through the AitherOS decision door before the model reads it: per LINE SHAPE, learned from outcomes, never dropping a traceback or…

newguardprocess
★ 11v0.1.0MITupdated 2026-09-20Aitherium/awdk/adk/mods/compact
A shopper browsing a rack in a slop shop
README

Aither ADK — Build AI Agent Fleets

<!-- aither-header:start GENERATED from the ecosystem registry. Edits here are overwritten; change the registry instead. -->

Docs · Source · pip install awdk · The Aither World

The Aither World is an operating system for agents — a Linux you can hand to one, the runtimes it works in, and the tools it works with. awnix is the Linux underneath it; awdk is one of its 67 bricks — each installs on its own, runs offline, and needs no account.

Start here: Point it at a backend you already pay for and run one agent loop.

<!-- aither-header:end -->

<!-- mcp-name: io.github.Aitherium/awdk -->

PyPI License: BSL 1.1 Docs

3 lines of code. Any backend. Local or cloud. Zero lock-in.

Aither ADK is a Python SDK + CLI for building AI agents that run on your hardware — a single helpful agent or a coordinated fleet that delegates work to each other. Agents get tools, persistent knowledge-graph memory, safety filtering, and effort-based model routing out of the box. Swap the LLM backend at runtime — your GPU, Ollama, llama.cpp, or any cloud API — same code, same agents.

pip install awdk
adk quickstart                                    # auto-detect hardware, set up inference
adk init my-agent && cd my-agent && python agent.py

The package is awdk; the command is adk (awdk is the same command, and python -m adk always works). Windows, "adk is not recognized"? pip put the command in a Scripts folder that is not on PATH — its warning names the folder. Add it once, then open a new terminal:

$s = python -c "import sysconfig; print(sysconfig.get_path('scripts'))"
[Environment]::SetEnvironmentVariable('Path', "$([Environment]::GetEnvironmentVariable('Path','User'));$s", 'User')

Get running in 60 seconds — pick your path

You have…Run thisYou get
Nothing — not even Pythonone-line installer (below)isolated env + first-run wizard
No GPU, no API keyadk bonsai-localBonsai running free, offline, on CPU — pulls ~300MB image, serves on :8090
A GPU (6 GB+)adk quickstartauto-detected vLLM/Ollama, models pulled, ready to chat
Just an API keyadk quickstart --cloudcloud inference (Anthropic / OpenAI / DeepSeek)
A whole LAN of machinesadk deploy gridmulti-machine effort-routed inference

The no-Python one-liner — sets up an isolated environment (via uv) and launches the wizard:

# macOS / Linux
curl -fsSL https://aitherium.com/install.sh | sh
# Windows
powershell -ExecutionPolicy ByPass -c "irm https://aitherium.com/install.ps1 | iex"

Then, whichever path you took:

adk start          # chat with your agent (zero config)
adk doctor         # something wrong? this names it

Using an AI coding agent (Claude Code, Cursor, Copilot)? Paste the Agent Setup Prompt into your session — it walks the agent through install, auth, inference, and the path from zero to fleet. There's also llms.txt / llms-full.txt for tools that ingest those.


Contents


New here? The five concepts

Everything in the ADK hangs off five ideas:

  1. Agent — AitherAgent("aither"). One object: await agent.chat("...") is the whole API. It has a persona, tools, and memory.
  2. Backend — where inference runs. Local (vLLM / Ollama / llama.cpp / Bonsai) or cloud (Anthropic / OpenAI / DeepSeek / Aitherium gateway). Switchable at runtime, mid-session.
  3. Effort routing — every call carries a 1–10 effort level; cheap calls go to small fast models, hard calls go to the big reasoning model. Automatically. You never pick a model per call again.
  4. Memory — a local SQLite knowledge graph that auto-ingests entities and relations from every conversation. Hybrid keyword + semantic search. No external services.
  5. Fleet — multiple agents that can call each other via the built-in ask_agent tool. One YAML file, one adk-serve command, and you have an orchestrator delegating to specialists.

If you only remember one thing: agent.chat() is the agent. Everything else is configuration.

Documentation map

I want to…Read this
Drive Claude Code / Codex / OpenCode / Aider from one shellAWSH-OMNISHELL-PLAYBOOK.md — install → detect → daemon → UI, verified end to end
Build a real agent or publish a packdocs/AGENT_DEV_GUIDE.md — the golden path + gotcha checklist
Self-host the full managed-agent experienceQUICKSTART_SELF_HOSTED.md — adk onboard --quick
Operate a self-hosted node long-termdocs/SELF_HOSTING_RUNBOOK.md
Run inference across several machinesGRID_SETUP.md
Wire up a specific LLM providerdocs/providers/ — DeepSeek, Kimi, OpenAI-compatible, local AitherOS
Give my agent a persistent identity/personadocs/PERSONA.md · `adk soul importexport`
Understand the world-model layerdocs/WORLD_MODEL.md
Connect agents across machines (relay)docs/AITHERRELAY_GUIDE.md
Run a private, local-only companionPRIVATE_COMPANION.md
Reach my own agent from my phone (Aither Hearth)docs/agent-home.md — adk home serve, channels, approvals, receipts
See working codeexamples/ — five runnable scripts
See what changedCHANGELOG.md
Browse rendered docsaitherium.github.io/awdk

Interoperability

Aither agents speak three protocols for seamless integration with external systems:

1. ACP (Agent Client Protocol) — IDE Integration

Connect your agent to JetBrains, Zed, VS Code, or any ACP-compatible editor over JSON-RPC 2.0 stdio.

adk acp serve                          # Serve your agent to an editor
  • Harness ID: acp (registered in adk.harnesses.registry)
  • Transport: STRUCTURED_BIDI (JSON-RPC 2.0)
  • Usage: Agents appear as room participants in AitherShell, driven by editors that speak ACP v2

2. A2A (Agent-to-Agent) — Remote Agent Integration

Map remote A2A agents (Google A2A v0.3.0 compatible) as room participants with full task lifecycle visibility.

from adk.a2a_adapter import A2AAdapter

adapter = A2AAdapter(room_id="main", remote_agent_id="foo")
adapter.on_task_submitted("task_001", "what is AI?")
adapter.on_task_working("task_001", "thinking...")
adapter.on_task_completed("task_001", "AI is...")
  • Module: adk.a2a_adapter.A2AAdapter
  • Events: Task lifecycle maps to AitherEvents (orchestration + cognition pillars)
  • Flux codes: a2a.s (submit), a2a.u (update), a2a.d (done)
  • Actor kind: a2a — remote agents appear with their own identity in rooms

3. MCP-UI — Render Blocks as Resources

Serve agent-generated RenderBlocks (server-driven UI: tables, forms, charts, approval gates) via the MCP resource protocol using ui:// URIs.

from adk.mcp_ui_resources import RenderBlocksMCPServer, create_table_block, create_scores_block

server = RenderBlocksMCPServer()
blocks = [
    create_table_block(columns=["Issue", "Severity"], rows=[[...], [...]]),
    create_scores_block({"security": 0.92, "style": 0.78}),
]
uri = server.from_agent_response("reviewer", "task_123", blocks)
# uri -> "ui://agent/reviewer/task_123"
  • Module: adk.mcp_ui_resources.RenderBlocksMCPServer
  • Block types: 24 primitives (markdown, header, table, code, form, approve, slider, file_upload, etc.)
  • Schema validation: Block schemas are kept at parity with the AitherOS RenderBlocks protocol, so a block emitted here renders identically in any AitherOS surface
  • MIME type: application/vnd.aitheros.renderblocks+json
  • Integration: Mount into FastAPI, use in MCP clients that understand ui://

The aw packages — three questions adk can ask about a repository

adk is the agent runtime; three small, independent packages give it the facts it would otherwise have to guess at. Each answers a different question, each installs on its own, and none of the three requires the others:

PackageKnowsThe question it answers
awgraphwhat the code is, and what depends on whatWhere is this symptom coming from?
awgitwhat changed, and who is editing itIs this an in-flight edit someone else owns?
awrelaywho found what, and who still needs to hear itWho do I tell?
pip install awgraph awgit awrelay   # or any one of them, alone

Used together, an agent can find a symptom with awgraph, check whether it is an in-flight edit with awgit, and tell the agent already working that file with awrelay — three questions a solo grep-and-guess loop cannot ask at all. The failure they remove is not "the agent was wrong"; it is two agents editing the same file without knowing, and a finding that died in a transcript nobody read.

Each publishes an aither-manifest.json beside its page, and each page renders the others live from those manifests — a project whose manifest is missing shows as unknown rather than silently disappearing: awgraph · awgit · awrelay.


Subagents — drive Claude Code, Codex, and eight more

Just want it working? → AWSH-OMNISHELL-PLAYBOOK.md. Install → detect → start the daemon → use it, with verified output at each step. The step people miss is that the harness daemon has to be running: without it the desktop app reports "No harnesses reported by the daemon yet", which reads as a missing feature rather than a stopped process.

Your agent can delegate a task to another coding agent's real product — not a reimplementation of it against the raw API.

That distinction is the whole design. Rebuilding Claude Code's behaviour yourself means inheriting none of its skills, hooks or account handling, and then chasing a product that ships faster than you can track it. So the ADK resolves the real binary on PATH (honouring PATHEXT, so the Windows .cmd shim works), runs it headless with an explicit tool scope, feeds the prompt over stdin — never argv, which is visible in the process table — gives each run its own config dir so concurrent subagents can't corrupt one another's state, and tears down the process tree on timeout.

adk shell harnesses          # what can this machine drive, and how to get the rest
adk shell new --harness claude
adk shell send  <id> "refactor the retry logic in billing/"
adk shell attach <id>        # watch it work
adk shell kill  <id>         # teardown

adk shell harnesses on a typical box:

ID           INSTALLED  TRANSPORT         DESCRIPTION
claude       yes        structured-bidi   Anthropic Claude Code — bidirectional stream-json, full tool use
gemini       yes        oneshot-per-turn  Google Gemini CLI — one process per turn, stream-json output
terminal     yes        pty-stream        A real shell on this host behind a pseudo-terminal (pwsh/bash)
sandbox      NO         pty-stream        A real Linux TTY inside a dev-workspace container
                                          -> Install Docker Desktop
acp          yes        structured-bidi   JSON-RPC 2.0 stdio harness for JetBrains/Zed/VS Code editors
codex        NO         oneshot-per-turn  OpenAI Codex CLI — one process per turn (codex exec --json)
                                          -> npm i -g @openai/codex
aider        NO         oneshot-per-turn  Aider — pair-programming CLI (one process per turn)
                                          -> pip install aider-install && aider-install
opencode     NO         oneshot-per-turn  OpenCode — open-source coding agent (one process per turn)
                                          -> npm i -g opencode-ai

Ten harnesses are declared; the ones you haven't installed say so and tell you the command. It never silently pretends the world is Claude-only — a harness you don't have is a missing install, not a missing feature, and the difference is printed rather than guessed at.

Harnesses are data, not drivers

A per-agent runner does not scale — you end up with claude_runner.py, codex_runner.py, gemini_runner.py, each drifting. So a harness is a row:

HarnessSpec(
    id            = "codex",
    label         = "OpenAI Codex CLI",
    transport     = Transport.ONESHOT_PER_TURN,
    binary        = "codex",
    version_argv  = ["--version"],
    install_hint  = "npm i -g @openai/codex",
    json_lines    = True,
    build_argv    = lambda spec, launch: [spec.binary, "exec", "--json", launch.prompt],
)

Four transports cover every agent CLI shipping today: structured-bidi (a persistent bidirectional stream-json session), oneshot-per-turn (a fresh process per turn), pty-stream (a real TTY behind a pseudo-terminal), and http-stream (a remote agent over SSE). Adding an eleventh harness is a table entry, not a new module.

Scoped by construction

A subagent is launched with an explicit allow-list, and the runner re-validates it fail-closed rather than trusting the caller:

from adk.claude_runner import ClaudeRunner, RunScope

runner = ClaudeRunner()
scope  = RunScope(allowed_tools=["Read", "Grep", "Glob"])      # read-only
rec    = runner.submit(task="audit error handling in ./api", scope=scope)

rec = runner.get(rec.run_id)          # queued | running | completed | failed | cancelled
print(rec.result_text)                # one task out, one answer back
runner.kill(rec.run_id)               # teardown, whole process tree

The scope becomes --allowedTools on the real CLI, so a subagent asked to audit code cannot write to your disk — enforced by the product you delegated to, not by a prompt asking it nicely.


Quick Start

1. Set up inference (one command)

adk quickstart detects your hardware, pulls the right models, configures backends, and gets you chatting:

pip install awdk
adk quickstart                 # local GPU: detect → pull models → serve
adk quickstart --cloud         # no GPU: enter an API key (Anthropic / OpenAI / DeepSeek)
adk start                      # start chatting

Either way you get the full harness: tools, skills, memory, and multi-agent coordination.

Want the full self-hosted, managed-agent experience (local LLM → customize a pack → enroll your machine → manage it from the portal)? See QUICKSTART_SELF_HOSTED.md — adk onboard --quick does it in one command.

2. Your first agent

import asyncio
from adk import AitherAgent

async def main():
    agent = AitherAgent("aither")              # auto-detects vLLM/Ollama on localhost
    response = await agent.chat("Hello! What can you help me with?")
    print(response.content)

asyncio.run(main())

3. Grow into a fleet

The package ships one ready agent — aither, the orchestrator. Add specialists by installing a ready-made pack, or by defining your own. Any agent can then call any other through the built-in ask_agent tool.

# install a ready-made specialist (web research)
adk install pack:openclaw

# define a fleet — the shipped orchestrator + an installed pack + your own agent — and serve it
cat > fleet.yaml <<'YAML'
orchestrator: aither
agents:
  - identity: aither                  # ships with the package
  - identity: openclaw                # installed above
  - name: reviewer                    # your own — just give it a prompt
    system_prompt: "You review code for bugs and security issues."
YAML
adk-serve --fleet fleet.yaml --port 8080

4. Earn tokens by volunteering

Earn Aitherium tokens by contributing compute to the community embedding pool:

adk volunteer enroll                   # register as a volunteer (tenant from adk login)
adk volunteer serve                    # download the embedding model & start llama-server
adk volunteer start                    # loop: claim batches → embed → submit → earn tokens

Reputation, verified batches and earnings show in the Volunteer Compute panel of the tenant workspace (dgg.aitherium.com) and in adk volunteer status.

Why Aither?

Locked appliancesAither ADK
Their hardware, their cloudYour hardware, your rules
1 AI assistantBuild a fleet — start with aither, add ready-made packs or your own; they delegate to each other
Their model picksAny model — route by effort level automatically
Data on their serversData stays on your machine
Closed system, monthly feeOpen-core (BSL-1.1) — free, runs entirely on your box
Locked to one providerRuntime backend switching — swap LLM mid-session
Cloud-only reasoningHybrid reasoning — local orchestration + cloud deep thinking

Aither Hearth: your agent, on your phone

adk home runs one personal agent on your machine that answers only you, on the chat apps you already use. The same CLI is installed as aither-hearth.

adk home init --name pip                  # ~/.aither/agent-home: persona, model, memory
adk home model --byo anthropic            # or --local ollama | llamacpp | bonsai
adk home model --check
adk home signin                           # Sign in with Aitherium
export HEARTH_TELEGRAM_TOKEN=...          # a Telegram bot token from @BotFather
adk home serve --channels telegram --pair # prints a 6-digit code: DM it to the bot
  • Serve and channels. adk home serve answers you on the relay, Telegram, Discord, Slack, email, WhatsApp and SMS: every channel whose credentials are in the environment (adk home channels shows which), or exactly the ones in --channels. Pair another channel by sending pair <channel> from one that is already paired.
  • It messages you first. Reminders and follow-ups you ask for arrive on the channel you last used.
  • Approvals. Anything that sends, books or adds (an email, a calendar event, a to-do, a recurring follow-up) waits for your yes <code> on the channel the request arrived on.
  • Receipts. Every action is appended to a signed, hash-chained log: adk home receipts --verify exits 0 intact, 1 tampered, 2 cannot judge. adk home trust status shows what is enforced.
  • Connectors (optional). After adk home signin, a workspace admin connects a Google account at api.aitherium.com/admin?tab=connections (admin-only); the agent can then read your agenda and mail, and add to them only after an approval. Microsoft 365 is not available yet.
  • Local window. adk home say "...", adk home events and /hearth in adk-shell talk to the running serve over 127.0.0.1 instead of starting a second agent.

Everything above is free. The paid agent-home pack adds learning that carries across game sessions and more than one agent at a time. Full guide: docs/agent-home.md.


Bonsai: an agent on literally anything

No GPU. No API key. No account. Nothing leaves your machine.

Bonsai is Aitherium's family of ultra-compact models built to make agents sovereign by default — they run on hardware everyone already owns. The 1-bit Bonsai-27B runs on a plain CPU with 4 GB of RAM; Bonsai-4B runs in 2 GB (Android via Termux, Raspberry Pi Zero). Agents on Bonsai get the full harness — tool calling, memory, safety, fleets — not a demo mode.

adk bonsai-local                # one command: Docker pulls the image + serves Bonsai-27B on :8090
adk --backend bonsai-local      # point your agents at it

Why this matters, concretely:

  • Free forever, offline after setup — one network pull for the model/image, then a fully working agent with zero external dependencies. Air-gapped targets work too: fetch the artifacts on a connected machine and sideload them.
  • Tool calling works — Bonsai drives the same @tool functions, ask_agent delegation, and pack skills as the big models.
  • Private by construction — no key means no telemetry decision to trust; there is simply no wire out.
  • A floor, not a ceiling — start on Bonsai today, add a GPU tier or a cloud reasoning backend later; your agent code does not change.

When you outgrow it, effort routing lets you keep Bonsai for the cheap calls and send only the hard ones somewhere bigger — see hybrid profiles.


Reasoning capture & code intelligence

Three packs added in 3.2.0. Each exists because of something the platform's chat models structurally cannot do.

External thinking — get the chain of thought back

Providers stopped returning raw reasoning. The recovery, from Oh My Pi's externalThinking (MIT), needs no jailbreak: turn the model's native reasoning channel off, then give it a tool whose only parameter is a string described as a private scratchpad. It keeps reasoning — into the tool call, which the API returns in plaintext. What comes back is the model's own shorthand, not a written-for-an-audience summary.

from adk.packs.omp_thinking import reconcile, deep_think_directive

model = {"api": "anthropic-messages", "reasoning": True,
         "thinking_requires_effort": True, "thinking_suppress_when_off": True}

reconcile(agent._tools, model)          # arms `deep_think` only if the model can take it
print(deep_think_directive(8)["directive"])   # the effort number, aimed at the scratchpad

Two things this pack refuses to do, both deliberate:

  • It refuses unknown and incapable models. A model that cannot suppress its native channel gets both channels or a rejected request, so it is refused and counted, never probed hopefully.
  • It disarms on model swap. Whether the scratchpad is legal is a property of the model, not the session, so reconcile() must run on every swap. Arming it once at startup is correct right up until someone changes models.
Source 1 files
hooks/compact.ts 190 lines
1import type { On } from 'claude-code'
2
3/*
4 * awcompact: a long tool result reaches the model as the lines that decide what
5 * happens next.
6 *
7 * This is the half the PostToolUse shell hook cannot do. A PostToolUse hook may
8 * only ADD context, so the raw 1,000-line pytest run still reaches the model and
9 * nothing is saved. A `tool.call` function hook sits AROUND the call: it awaits
10 * `next(e)`, and what it returns IS the result the model reads. So the compacted
11 * form REPLACES the output here, and the saving is real.
12 *
13 * What decides: the AitherOS decision door, asked per distinct LINE SHAPE
14 * (`kind:pytest-pass|len:<80|rep:20+|pos:middle`), never per line and never
15 * about the line's text -- so a secret in an env dump cannot leave the process
16 * through the model rung, and the second run of the same kind of command is
17 * answered from evidence in microseconds with no model call at all. A shape the
18 * door has nothing to say about is KEPT: dropping a line on no evidence is the
19 * one failure this may not have, and the always-keep rules (tracebacks, summary
20 * lines, error lines, the first two and last five lines) never reach the door.
21 *
22 * The classification and the door call live in `python -m adk.compact`, one
23 * implementation for the shell hook, the awsh mod and this one. That is
24 * deliberate: three copies of a shape grammar keyed on one fork name would each
25 * teach the door a different descriptor for the same line.
26 *
27 * Install (both lines are the OWNER's -- an agent cannot edit settings here):
28 *   claude --plugin-dir awdk/adk/mods/compact          # this session only
29 *   CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1                # in the settings env block
30 *
31 * Config (`pluginConfigs.awcompact` in settings, each also a row in the config
32 * menu): minLines, tools, mode, verbose, timeoutMs.
33 */
34
35/** Below this many lines a result is left alone: the compactor costs more than it saves. */
36const DEFAULT_MIN_LINES = 60
37
38/** Tools whose results are compacted. Others pass through untouched. */
39const DEFAULT_TOOLS = ['Bash', 'BashOutput']
40
41/** How long the compactor may take before the original result is used instead. */
42const DEFAULT_TIMEOUT_MS = 20_000
43
44/** Commands whose fork the output learns on, longest name first so `npm test` is npm. */
45const FORKS = [
46  'pytest', 'ruff', 'mypy', 'terraform', 'ansible', 'podman', 'docker', 'cargo',
47  'yarn', 'npm', 'curl', 'make', 'pip', 'git', 'go',
48]
49
50type Options = {
51  minLines?: number
52  tools?: string
53  mode?: string
54  verbose?: boolean
55  timeoutMs?: number
56}
57
58export function register(on: On, options: Options = {}) {
59  const minLines = Number(options.minLines ?? DEFAULT_MIN_LINES)
60  const tools = String(options.tools ?? DEFAULT_TOOLS.join(','))
61    .split(',')
62    .map((t) => t.trim())
63    .filter(Boolean)
64  const mode = String(options.mode ?? 'auto')
65  const verbose = Boolean(options.verbose ?? false)
66  const timeoutMs = Number(options.timeoutMs ?? DEFAULT_TIMEOUT_MS)
67
68  on('tool.call', async ($, e, next) => {
69    const result = await next(e)
70    if (!tools.includes(e.tool)) return result
71
72    const text = textOf(result)
73    if (text === undefined || countLines(text) < minLines) return result
74
75    const fork = forkOf(commandOf(e))
76    let compacted: Compacted | undefined
77    try {
78      compacted = await runCompactor($, text, fork, mode, minLines, timeoutMs)
79    } catch (err) {
80      // The compactor is away, slow or broken. The ORIGINAL result stands --
81      // a compactor that eats a tool result when it fails is worse than none.
82      await $.ui.log(`awcompact: ${String(err).slice(0, 200)}`)
83      return result
84    }
85    if (compacted === undefined || compacted.kept_lines >= compacted.total_lines) return result
86
87    const ids = (compacted.decision_ids ?? []).slice(0, 3).join(', ')
88    const header =
89      `[compacted by the decision door: kept ${compacted.kept_lines} of ` +
90      `${compacted.total_lines} lines; fork decide.compact.${fork}; ` +
91      `mode ${compacted.mode}` +
92      (ids ? `; a dropped line mattered? adk compact teach ${ids.split(',')[0].trim()}` : '') +
93      ']'
94    if (verbose) {
95      await $.ui.log(
96        `awcompact ${fork}: ${compacted.kept_lines}/${compacted.total_lines} lines, ` +
97          `sources ${JSON.stringify(compacted.source_counts ?? {})}`,
98      )
99    }
100    return withText(result, `${header}\n${compacted.kept}`)
101  })
102}
103
104type Compacted = {
105  kept: string
106  kept_lines: number
107  total_lines: number
108  dropped: number
109  mode: string
110  source_counts?: Record<string, number>
111  decision_ids?: string[]
112}
113
114/** The tool result's text, whichever shape this build uses. undefined = leave it alone. */
115export function textOf(result: unknown): string | undefined {
116  if (typeof result === 'string') return result
117  if (result === null || typeof result !== 'object') return undefined
118  const r = result as Record<string, unknown>
119  if (typeof r.result === 'string') return r.result
120  if (typeof r.output === 'string') return r.output
121  if (typeof r.stdout === 'string' || typeof r.stderr === 'string') {
122    return [r.stdout, r.stderr].filter((s) => typeof s === 'string' && s.length > 0).join('\n')
123  }
124  return undefined
125}
126
127/** The same shape back with new text, so nothing else about the result changes. */
128export function withText(result: unknown, text: string): unknown {
129  if (typeof result === 'string') return text
130  const r = result as Record<string, unknown>
131  if (typeof r.result === 'string') return { ...r, result: text }
132  if (typeof r.output === 'string') return { ...r, output: text }
133  if (typeof r.stdout === 'string' || typeof r.stderr === 'string') {
134    return { ...r, stdout: text, stderr: '' }
135  }
136  return result
137}
138
139export function commandOf(e: unknown): string {
140  const input = (e as { input?: Record<string, unknown> })?.input
141  const cmd = input?.command ?? input?.cmd ?? input?.script
142  return typeof cmd === 'string' ? cmd : ''
143}
144
145/** Which fork this output learns on. pytest filler looks nothing like build filler. */
146export function forkOf(command: string): string {
147  const low = command.toLowerCase()
148  for (const name of FORKS) {
149    if (new RegExp(`(^|[\\s;&|/\\\\"'])${name}(\\s|$)`).test(low)) return name
150  }
151  return 'bash'
152}
153
154export function countLines(text: string): number {
155  let n = 1
156  for (let i = 0; i < text.length; i++) if (text.charCodeAt(i) === 10) n++
157  return n
158}
159
160/*
161 * `$.process.run`'s ARGUMENT shape is the one thing here that could not be
162 * verified on this box (2026-09-20): the plugin validator reports which engine
163 * methods a module calls but not what it passes them, and the test harness's
164 * `$` carries no `process` noun, so the call cannot be made from a test. Two
165 * shapes are tried, and if both throw the caller keeps the ORIGINAL result --
166 * the failure mode is "the mod does nothing", never a mangled tool result.
167 */
168async function runCompactor(
169  $: { process: { run: (...a: any[]) => Promise<any> } },
170  text: string,
171  fork: string,
172  mode: string,
173  minLines: number,
174  timeoutMs: number,
175): Promise<Compacted | undefined> {
176  const args = ['-m', 'adk.compact', '-', '--tool', fork, '--json', '--mode', mode,
177                '--min-lines', String(minLines)]
178  let got: any
179  try {
180    got = await $.process.run({ command: 'python', args, stdin: text, timeout: timeoutMs })
181  } catch {
182    got = await $.process.run('python', args, { stdin: text, timeout: timeoutMs })
183  }
184  const out = typeof got?.stdout === 'string' ? got.stdout : (typeof got === 'string' ? got : '')
185  if (!out.trim()) return undefined
186  const parsed = JSON.parse(out) as Compacted
187  if (typeof parsed?.kept !== 'string') return undefined
188  return parsed
189}
190