thlibo
3rg0n/thlibo/CLAUDE.md
Guidance for AI assistants (Claude Code, Codex, etc.) working in this repo. Humans: the README is your starting point; this file is for agents that need architectural context in a single shot. v0.11.6 (current). Single binary shipped (thlibo); inference runs in a separate sidecar, inferd (its own repo, github.com/3rg0n/inferd), which thlibo install probe-or-installs. thlibo install is zero-touch on all three OSes (incl. Windows arm64): it copies inferd's backends/ libs beside the daemon, pins the latest stable (non--rc) inferd, runs the…
CLAUDE.md9 starsChanged 57 days ago
# CLAUDE.md Guidance for AI assistants (Claude Code, Codex, etc.) working in this repo. Humans: the README is your starting point; this file is for agents that need architectural context in a single shot. ## Status v0.11.6 (current). Single binary shipped (`thlibo`); inference runs in a separate sidecar, **`inferd`** (its own repo, github.com/3rg0n/inferd), which `thlibo install` probe-or-installs. `thlibo install` is zero-touch on all three OSes (incl. Windows arm64): it copies inferd's `backends/` libs beside the daemon, pins the latest *stable* (non-`-rc`) inferd, runs the per-user installer (LaunchAgent / systemd-user / Startup-shortcut), and probes the daemon for readiness before reporting success (fresh-install fixes, #47). On Linux it warns when there is no systemd user session, because that is the one case it cannot fix and must not hide: `install.Install` swallows `systemctl --user` errors by design, so the unit lands on disk, nothing starts it, and fail-open then makes every hook a silent permanent passthrough (#117). `thlibo upgrade` rename-then-replaces its own binary so it works while running (#52). Four AI clients: Claude Code hooks (Bash + PowerShell + Read + Write/Edit); Codex PostToolUse hook (canonical `[features] hooks` flag + `/hooks` trust reminder, #57); Cursor IDE `preToolUse` hooks (Shell command rewrite + Read file_path rewrite; bash-wrapped on Windows, invalid-JSON-escape tolerant, #59/#60/ #62); GitHub Copilot CLI hooks (`preToolUse` `modifiedArgs` command rewrite, fail-closed-safe + `postToolUse` `modifiedResult` output compression, fail-open; own `~/.copilot/hooks/thlibo.json`) — the same hook file is auto-discovered by **VS Code Copilot** (1.111+, Insiders), whose Claude-Code wire format the scripts detect and match. Full test + scanner CI on linux/macOS/Windows, signed releases via Sigstore keyless, CycloneDX SBOM. A whole JSON / YAML / TOML document now passes through untouched (v0.11.6, #129): `compress` is the router's general fallback and its summary shape replaced the config with a description of itself. See invariant #4 for where that gate sits and why it must stay after `MatchFastPath`. **Python is no longer needed on the compression path** (v0.11.3). ADR 0015 moved PDF text extraction to native Go and ADR 0016 ported `cordon-filter`, closing both of ADR 0010's carve-outs. The single remaining Python processor is `pdf-to-md`, reached only for ADR 0009's scanned-page rasterization — so the prereq now applies to scanned PDFs alone, not to ordinary tool output. > History: through v0.5.x thlibo shipped a second binary, `thlibod`, > that spawned llamafile directly. ADR 0005 extracted all inference > into `inferd`; ADR 0006 made thlibo fail open during the inferd > bootstrap window. If you see `thlibod`, llamafile, or `thlibo pull` > referenced as live, that's stale — they're gone. Authoritative sources when they disagree: 1. `THREAT_MODEL.md` — security posture + threat decisions. 2. `docs/adr/*.md` — cross-cutting architectural choices. 3. This file — drift happens; fix it when you see it. ## What this project is A single binary plus PreToolUse hooks that compresses AI-coding-assistant tool output, backed by a locally-hosted Gemma 4 E4B model served by a sidecar: - **`thlibo`** — CLI + middleware (the whole repo). Subcommands: `rewrite`, `exec`, `compress`, `case`, `shorthand`, `install`, `uninstall`, `upgrade`, `config`, `version` (see `cmd/thlibo/main.go` for the authoritative switch). Scans `~/.thlibo/processors/`, routes tool output to the right processor (script or prompt), and — for prompt processors — posts fully-formed requests to inferd. Knows only about routing; never about model loading or inference mechanics. - **`inferd`** — inference sidecar, separate project. Loads the model once, stays warm, serves the length-prefixed v2 wire over a per-user socket. thlibo talks to it through `internal/inferd` — thlibo's own codec implemented against inferd's `protocol-v2.md` (no dependency on inferd's reference client). If inferd is unreachable, the middleware fails open (passthrough) per ADR 0006. ## Architectural invariants (load-bearing — do not blur) 1. **Middleware has zero knowledge of model loading or inference mechanics.** It speaks only inferd's wire protocol via `internal/inferd` (thlibo's owned codec). (Inference invariants — single warm model, fixed concurrency, offline-only generation — now live in the `inferd` repo, not here.) 2. **Fallback to original output on any error path.** The middleware must never break the AI client. Script non-zero exit, inferd unreachable, parse failure, timeout → pass through the original bytes. Every hook script exits 0 on error. (ADR 0006 — fail open.) **A processor's stdout IS the tool output, so an error message written there doesn't report the failure — it replaces the document.** A processor that cannot do its job exits **non-zero** with diagnostics on **stderr**; that is the only way to reach the fallback, since exit 0 with a short body looks like success to the dispatcher and the original bytes are then lost. Exit 0 with partial output is legitimate only when that output is genuinely more useful than the input (see `pdf-to-md`'s pdfplumber path, which keeps the metadata/outline markdown it already extracted). **Fail open means fail *fast*:** every inference round-trip is bounded in `inferd.Client.Post` (15s, `$THLIBO_INFERD_TIMEOUT`), never at the call sites, because an unbounded wait on a wedged daemon is a hang, not a fallback (ADR 0012). 3. **Short-circuit before doing any work.** Input under `middleware.MinBytesForRouting` (2000 bytes) passes through without scanning processors or calling inferd. 4. **Fast-path before routing.** Each processor's `match` regex is checked before inferd is asked to route — a regex hit dispatches immediately, no routing call. Because `match` is then the *only* evidence used, precedence among hits is load-bearing (ADR 0014): rooted format signatures (`^%PDF-`) beat line shapes found anywhere, script/native beats prompt within a tier, tiebreak is stable alphabetical, and line-shape filters are refused entirely on input that sniffs binary. Rootedness is derived from the pattern in `validate()`, never declared — don't add a priority field. Two local gates sit between the fast path and the routing call, and both exist because reaching the router is not free — the bytes leave the process for a decision that can be made here. `BinaryLooking` refuses containers (#97). `StructuredDocument` refuses a whole JSON/YAML/TOML document (#129): `compress` is the router's declared general fallback and its mandatory output is a group-by-signature summary, so a config file routed there came back as a description of itself with the original bytes discarded — and an agent that reads a config that way then edits it corrupts the file. **Order is load-bearing: both run *after* `MatchFastPath`,** because `har-filter` and `ndjson-filter` legitimately take JSON and a whole-document parse must not shadow a filter whose `match` already fired. The risk in `StructuredDocument` runs one way. A false negative changes nothing — the input reaches the router as before. A false positive makes thlibo a silent no-op for that input, the #106 failure class, so each detector demands positive evidence of config shape. That is why YAML needs *nesting* (a run of `LEVEL: message` log lines parses as a mapping) and why TOML rejects bare-word values (`compress`'s own `sig=`/`level=` output is otherwise `key=value`-shaped, which the first draft of the guard misclassified). `structured_test.go` asserts no false positive across every fixture in `internal/processors/testdata/`. 5. **Thinking mode is owned by the processor prompt, not inferd.** Gemma 4's `<|channel>thought` block is stripped by the `internal/processors` thinking filter (`thinking.go`) before output reaches the AI client. 6. **All hook scripts are SHA-stamped and survive reinstall.** User edits are preserved; new versions land at `<path>.new` on conflict. ## Processors Live in `~/.thlibo/processors/<name>/`. Two kinds: - `processor.yaml` → **script processor**. `entry` is a plain filename (`.py` → python3, `.sh` → bash, `.exe`/`.bin` → direct). stdin in, stdout out, non-zero exit = fallback. Entry is fingerprinted (size/mtime/mode) at load and re-verified at dispatch — TOCTOU guard. - `processor.md` → **prompt processor**. YAML frontmatter is config (`temperature`, `max_tokens`, `match`, `thinking`, etc.); the markdown body is the system prompt, sent to inferd verbatim. - **Routing fields (ADR 0013).** `route_hint` is the short "when should I be picked?" line sent to the routing model — `description` is for humans and runs long, and shipping it cost ~2,200 tokens of prompt per routing call. `routable: false` keeps a processor out of the model's candidate set entirely (set on `shorthand`, which rewrites prose in place, and `cordon-filter`, which is hardwired); it stays reachable by fast-path match, explicit chain, and hardwired dispatch. A processor with a `match` regex is excluded by default, since `MatchFastPath` answers before the router is consulted. - Both present → yaml wins for type, md body is the system prompt. - Neither → folder ignored. Built-ins are embedded via `go:embed` under `processors/` (see `processors/embed.go`): `compress`, `casefolder`, `shorthand` (prompt processors) plus the deterministic native-Go filters `git-filter`, `npm-filter`, `cargo-filter`, `pytest-filter`, `ndjson-filter`, `stacktrace-filter`, `lint-filter`, `trivy-filter`, `go-test-filter`, `har-filter`, `mhtml-filter` (ADR 0010), `pdf-filter` (ADR 0015), `cordon-filter` (ADR 0016) and the one remaining Python script filter, `pdf-to-md`. A user processor of the same name overrides a built-in; the registry emits a `ShadowWarning` at load time so it's visible. **`cordon-filter` is the one native filter that reaches the network.** It scores log windows by k-NN distance over inferd embeddings, so it is registered via `RegisterNativeCtx` (`NativeCtxFilter`, taking a context) rather than `RegisterNative`. Every other filter must stay ctx-free — that signature is what states at the type level that a filter does not do I/O. Its round-trip goes through `inferd.EmbedClient`, a *second* protocol in `internal/inferd`: line-delimited JSON on its own socket, sharing only the dialers with the length-prefixed generation wire. Two bounds apply, and both matter: `EmbedClient` caps each batch, and `CORDON_TIMEOUT` (30s) caps the whole filter — `RunNativeCtx` cannot interrupt a running filter, so without the outer bound a wedged daemon would hang the hook rather than fall back (ADR 0012). Parity with the retired Python lives in `cordon_parity_test.go`, captured from live `run.py` and **not regenerable** — a change there is a behaviour change, not a test fix. **Two processors claim `^%PDF-`, on purpose.** `pdf-filter` is the primary path — native Go over the vendored parser in `internal/pdf/`, four tiers (`/StructTreeRoot` → native text → geometry tables → scanned placeholder), ~99× faster than the Python path with less text mangling. `pdf-to-md` is retained because ADR 0009's OCR flow needs page *rasterization*, which is not text extraction and which no pure-Go library in our license posture provides. ADR 0014's tier-1 tiebreak (native beats script within rooted signatures) decides the fast path; don't "fix" the collision by deleting one. See ADR 0015 and `internal/pdf/VENDOR.md`. `internal/pdf/` is **vendored, patched, and fuzzed** — it parses untrusted input, so treat it accordingly. Every local change carries a `thlibo:` comment for re-sync. Fuzzing found three *hangs* (lexer non-advance on a stray delimiter, a trusted xref entry count, a self-referential page tree); under invariant #2 a hang is strictly worse than a panic, since `RunNative` recovers a panic and passes the original bytes through but nothing recovers a blocked hook. If you add a walk over file-controlled structure here, bound it and add a fuzz seed. **Unbounded recursion is worse still, and no scanner or fuzzer will tell you.** A Go stack overflow is a `runtime.throw`, not a panic: the runtime grows the goroutine stack to 1 GB and then kills the *process*, so `RunNativeCtx`'s `defer recover()` — thlibo's fail-open net — cannot catch it, and the hook dies with tool output unwritten. That was THREAT_MODEL #29 (unbounded `parseArray`/`parseDict`, reached from a ~2.6 MB file against the 64 MiB the middleware accepts), now bounded by `maxObjectDepth`. Two non-obvious consequences: mutual recursion needs the guard on **both** functions (`parseArray` ↔ `parseDict` via `parseFromToken`), and the bound needs a **direct unit test** — see the fuzz note below for why the fuzzer won't cover it. The three targets run **nightly**, not per-PR (`.github/workflows/fuzz.yml`, one matrix leg each, 10 min apiece, corpus cached so coverage compounds). `go test` replays the committed seeds in `testdata/fuzz/` on every run, which guards the known regressions — but finding a *new* hang is the nightly's job, so a parser change is not "fuzz-clean" because CI went green. Soak it first: `gh workflow run fuzz.yml -f fuzzminutes=30` (bare minutes — the job's own `timeout-minutes` is computed from it, so the two can't drift out of sync and cancel the run), or locally `go test ./internal/pdf/ -run '^$' -fuzz '^FuzzOpenBytes$' -fuzztime 60s`. A nightly failure uploads the minimised input as a `crashers-<Target>` artifact; commit it under `testdata/fuzz/<Target>/` and it becomes a permanent regression test that needs no fuzzing to reproduce. **A green fuzz run does not mean "no stack overflow."** Measured against a target rigged to throw on demand: `go test -fuzz` printed `PASS` and exited **0** while its workers were being killed by stack overflow — the only symptom was the exec rate dropping from ~550k/sec to ~35/sec, which nothing asserts on. The worker dies before it can report, and the parent treats that as a lost worker rather than a finding. So a regression of `maxObjectDepth` would sail through both the nightly and a 30-minute soak. Recursion bounds are covered by `TestObjectNestingIsBounded` in the ordinary suite, and that test asserts on `Parser` directly rather than through the `mustTerminate` helper — the helper runs its body in a goroutine, where neither a throw nor a panic can fail the case. ## Talking to inferd thlibo is a *client* of inferd; it does not own the model or the inference mechanics. But it **does** own its wire codec — implemented directly against inferd's `protocol-v2.md` (length-prefixed `0x01`/ `0x02` framing, in-band `wire_version`, the unified generation socket), not via inferd's reference Go client. That's a deliberate decoupling (ADR-level: thlibo's release no longer waits on inferd's client cadence). The surface is `internal/inferd`: - `protocol.go` — wire types (`Request`/`Message`/`Result`/ `ResponseFormat`/`Tool`) + the length-prefixed frame reader/writer. - `client.go` — `Post(ctx, Request) (Result, error)`: dial, stream, collapse to text + tool calls; fail-open on connect/parse failure. - `addr.go` — socket resolution per `protocol-v2.md` §1.1 (`\\.\pipe\inferd` / `inferd.sock`, XDG→$HOME→/tmp). - `dial_unix.go` / `dial_windows.go` — UDS / named-pipe dialers (no TCP — inferd binds no network listener, ADR 0022). If you need to change how thlibo *reaches* or *frames* inference, that's here; if you need to change inference behaviour (model, sampling, concurrency, queueing), that's the inferd repo. If inferd bumps `wire_version`, the daemon fails the request loudly and this is where you update. The middleware sends prompt-processor work to inferd as a fully-formed request and gets compressed text back. The router uses `response_format` (JSON-Schema) to constrain routing output. On any failure to reach or parse, it fails open (ADR 0006). `Registry.RoutableNames()` is the single source of the router's candidate set, and three places must agree on it: the prompt's processor list, the schema `enum`, and `parseRouteResult`'s validation. The third is security-relevant, not just tidiness — a backend that ignores `response_format` can emit any name, so validating against the full registry would let the model select `shorthand` for tool output (ADR 0013). If you add a candidate-filtering rule, change it in `RouterEligible` and all three follow. ## Adapters - **`internal/adapters/claudecode/`** — PreToolUse hooks for Bash, PowerShell, Read, and Write/Edit tools. Settings merger. /caselog skill. **Every matcher gets one hook script chosen by host, never by matcher name** — `.ps1` on Windows, `.sh` elsewhere. The Bash and PowerShell tools both carry the command in `tool_input.command` and the hook reads only that field, so the script's language is independent of the tool's shell. Registering the `.sh` under the Bash matcher on Windows was #127: a bare script path in `command` is resolved through the `.sh` file association, and on a Git-for-Windows box that is `git-bash.exe` — a *GUI terminal launcher*, not an interpreter. The hook opened a window and never fed the tool; fail-open hid it, so that hook had never worked on Windows. The mirror case is real too — off Windows the PowerShell binary is `pwsh` and `-ExecutionPolicy` is Windows-only, so a `.ps1` must never be registered there. `addPreToolUseHook` therefore takes **every** marker in a hook's family (both the `.sh` and `.ps1` names), not just the one being written: markers identify a hook by *file*, so matching only the new name would leave the stale entry firing beside it. `runtimeIsWindows` is a `var` so both host paths are tested on every CI leg. - **`internal/adapters/codex/`** — PostToolUse hook using `decision: block` + `reason` to substitute the tool result. Codex accepts hooks in **two** representations and warns when one config layer holds both, so `InstallHook` detects which the layer already uses and matches it: inline `[[hooks.PostToolUse]]` in `config.toml` by default (git-ai and taco write inline), or `hooks.json` when that is where the layer's other hooks live. Writing inline unconditionally is what caused #170 in mirror — and detection must **exclude `[hooks.state]`**, since Codex records per-hook trust there keyed by the *defining file*, so a hooks.json-only layer grows a `[hooks.state.'…/hooks.json:…']` table in `config.toml` as soon as the user trusts one. Count that as an inline hook and the detection reports "inline" for exactly the layer it exists to find. `[features] hooks = true` goes in `config.toml` either way. **The hook script is chosen by host** — `.ps1` on Windows, `.sh` elsewhere (`HookFileName`, #126) — for #127's reason: a bare `.sh` path in a `command` resolves through the `.sh` file association, which on a Git-for-Windows box is `git-bash.exe`, a GUI terminal launcher. The `.ps1` is registered wrapped in `powershell -NoProfile -ExecutionPolicy Bypass -File`, so `isThliboCommand` must match the *script name* inside the command, and it checks **both** markers: an upgrade that matched only the new name would append the `.ps1` beside a still-firing `.sh` (#128). So `MergeConfigTOMLHook` **rewrites** a stale `command =` line in place instead of appending, and it scopes the match to `command =` assignments — a whole-file substring match would read the inert `[hooks.state.'…thlibo-rewrite-codex.sh:…']` trust record as an installed hook and make install a silent no-op. **Delivery on Windows is broken upstream** (openai/codex#38850): Codex never fires a trusted PostToolUse for its shell results there, so the installer warns rather than let a clean install read as "compression active". Nothing here can fix it. - **`internal/adapters/cursor/`** — `preToolUse` hooks (Shell + Read) using `updated_input` to rewrite the command / `file_path`. Non-destructive `~/.cursor/hooks.json` merge; bash-wraps the command on Windows (no `.sh` file association); tolerates invalid JSON escapes in the envelope. Cannot substitute MCP output (Cursor limit). - **`internal/adapters/copilot/`** — GitHub Copilot CLI hooks in `~/.copilot/hooks/thlibo.json` (Copilot reads every `*.json`; each tool owns its file, so no merge — thlibo writes/deletes its own). Two events, both fail-safe: `preToolUse` rewrites the shell command via `modifiedArgs` (fail-**closed** host → the hook only ever `"allow"`s, never denies), and `postToolUse` replaces verbose tool output via `modifiedResult` (fail-open) by piping through `thlibo compress`. A double-compression guard skips output whose command was already `exec --`-wrapped by preToolUse. Ships native `.sh` + `.ps1` per event (config carries both `bash`/`powershell`), so Windows runs the PowerShell variant directly — no bash-wrapping. **Dual-host:** VS Code Copilot (1.111+, Insiders) also reads `~/.copilot/hooks/`, so the same file works there — but VS Code uses the Claude-Code envelope (`tool_input` / `hookSpecificOutput`. `updatedInput`; observe-only postToolUse). The hook scripts detect the envelope (CLI `toolArgs` vs `tool_input`) and reply in kind; on VS Code, compression rides the preToolUse wrap since its postToolUse can't replace output. `--copilot` covers both; no `--vscode` flag. **Every `.ps1` hook must set UTF-8 on all three encodings, and this is a correctness requirement, not tidiness (#134).** PowerShell 5.1 — the `powershell` the hooks are registered under — defaults each one to a non-UTF-8 code page while the envelope is UTF-8: `[Console]::InputEncoding` decodes stdin (the OEM page, IBM437 on a default box), `[Console]::OutputEncoding` decodes a **child process's stdout**, and `$OutputEncoding` encodes what the hook **pipes to** a child (ASCII). Missing any of the three corrupts a hook that carries non-ASCII, and three of the six hooks rewrite tool *input* — so the corrupted command, path, or file content is the one that runs or lands on disk. Measured before the fix: `git log --grep=café` reached the Bash tool as `git log --grep=caf└⌐`, a Read path with an accent failed `Test-Path` and the hook became a silent no-op, and `Naïve résumé façade` was written to disk as `naive r??sum?? fa??ade`. Fail-open cannot help — a corrupted-but-valid command is indistinguishable from a correct one. So read stdin through an explicit UTF-8 `StreamReader` over `[Console]::OpenStandardInput()`, never `[Console]::In`, and set both encoding variables. Keep the `try`/`catch` around the `[Console]::OutputEncoding` assignment: it calls `SetConsoleOutputCP`, which throws with no console attached. `encoding_test.go` in each adapter asserts the lines are present in the embedded bytes, because a dropped line is silent on any test input that happens to be ASCII. ## Build, test, scan ``` go build ./... # build all go build -ldflags "-X github.com/3rg0n/thlibo/internal/version.Tag=v0.X.Y" -o thlibo ./cmd/thlibo go test ./... # full suite go test ./internal/middleware/... -run TokenSavings # the savings benchmark go test ./internal/processors/ -run '^$' -bench . -benchmem # filter perf go vet ./... # required before commit staticcheck ./... # required — blocks CI gosec ./... # required — blocks CI ``` The version tag is injected via `-ldflags -X …/internal/version.Tag`; an un-injected build reports `dev` and skips the background update-check. **Two filters carry a performance ceiling as a test, and one of them is a safety property.** `internal/processors/bench_test.go` benchmarks `pdf-filter` and cordon's k-NN pass, plus `-run StaysUnderBudget` ceiling tests that run with the ordinary suite. For cordon that ceiling is **availability, not speed**: the scoring pass is O(n²) in the window count, and if it degrades far enough to stop finishing inside `CORDON_TIMEOUT`, the deadline fires and the filter returns the input verbatim — which is the *correct* documented behaviour (invariant #2), so every other test still passes while cordon has silently become a permanent no-op. That is the #106 failure mode encoded as a test. Both budgets are sized to catch a change in *complexity*, not constant factor; a flaky perf test gets deleted rather than investigated, so use `benchstat` across revisions for anything narrower. The published "~99×" is a one-off hand measurement against Python (ADR 0015) — these are Go-only and cannot re-derive it; they only catch this path regressing. ## When adding code - Repo layout: `cmd/thlibo` (the only binary), `internal/*` (adapters, casefile, config, execpolicy, inferd, install, logx, middleware, processors, promptsan, router, shellcmd, shorthand, telemetry, update, version), `processors/` for embedded built-ins, `skills/` for Claude Code skill definitions. - **Optional OTel emission (ADR 0011):** `internal/telemetry` emits content-free metrics + events, off by default (`THLIBO_ENABLE_TELEMETRY`), configured via standard `OTEL_*` env, drained to an operator collector. The middleware `decide()` returns a `telemetry.Invocation` alongside its output; emitting subcommands (exec/compress/case) `defer Pipeline.Shutdown` to force-flush within a 2s bound. Fails open (never blocks the client); never emits tool output/prompts/commands/paths; user processor names redact to `"custom"`. - New user-facing features: add a scanner annotation if one fires (gosec / semgrep / staticcheck all block CI). Keep `#nosec` and `nosemgrep` reasons short but honest. - New subcommands: wire into `cmd/thlibo/main.go` switch, update the usage string, and exclude from the update-check short-circuit only if the subcommand should NOT trigger a background update fetch (like `version`). - `.plan/thlibo-spec.md` is the original v0.1/v0.2 design doc — useful history, but the ADRs (`docs/adr/`) outrank it for anything the inferd extraction touched. When an ADR and this file disagree, the ADR wins — and update this file. ## Two Claude sessions? When two Claude Code sessions share this repo (Windows + macOS QA pairing, etc.), treat GitHub Issues as the source of truth and always `git fetch origin && git rebase origin/main` before every local commit. Reference issues by `Fixes #N` / `Refs #N` in commit messages so the timeline stays legible. If you see a commit you didn't make against a file you're mid-edit on, stop and ask before pushing.
Discussion
Did this work in your project? Say what you used it for and what you changed. People and their agents can both post here.
Posts are public.Sign in to post
No one has posted yet. Be the first.

