@tduyng/pi-timbre
Constrain prose and code before it is written: inject the rules, report the mechanical tells on the write, and check TypeScript against dmmulroy/anti-slop once per turn.
Package details
Install @tduyng/pi-timbre from npm and Pi will load the resources declared by the package manifest.
$ pi install npm:@tduyng/pi-timbre- Package
@tduyng/pi-timbre- Version
0.0.4- Published
- Nov 5, 2026
- Downloads
- 388/mo · 388/wk
- Author
- tienduy-nguyen
- License
- Apache-2.0
- Types
- extension
- Size
- 150.8 KB
- Dependencies
- 2 dependencies · 2 peers
Pi manifest JSON
{
"extensions": [
"./src/index.ts"
]
}Security note
Pi packages can execute code and influence agent behavior. Review the source before installing third-party packages.
README
@tduyng/pi-timbre
Keeps Markdown and TypeScript inside a project's rules before the writing happens, instead of cleaning it up afterwards.
Rules go into the system prompt before the model answers. Markdown is scanned when a file is written. TypeScript is checked against dmmulroy/anti-slop once per turn. Nothing is rewritten after generation.
Before the first token
Cleaning up a sloppy reply costs another request, and that request re-reads the whole conversation. On a 200k-token session the correction is more expensive than the paragraph it fixes.
Most de-slopping tools work after generation: detect a tell, then ask a model to rewrite. Pi lets an extension return the system prompt for the turn through before_agent_start, which runs before the first token exists. That hook is why this is a pi extension rather than a filter.
Two rules keep the injected block small:
- Constraints go in the prompt, procedures go behind a tool. "Never use an em dash" is short. A procedure for judging whether a paragraph is any good is long, and irrelevant on a turn that is not judging paragraphs.
- Only rules that nothing else can enforce go in the prompt. Em dashes, curly quotes,
unknownreturns and 18 TypeScript rules have a deterministic check behind them, so restating them in the prompt buys the same enforcement twice.
The injected block costs 208 tokens a request and the review guide 70. Pi marks the block cacheable, so a warm turn pays about a tenth. The block is computed once per project and frozen for the session: memory changes on every reply, and one changed word in the system prompt invalidates the whole cached prefix.
Set PI_CACHE_RETENTION=long. Pi's default cache TTL is 5 minutes, and any pause longer than that forces a full prefix rewrite.
Hooks
| Hook | What happens |
|---|---|
before_agent_start |
Injects the Never and Code sections of the catalog |
tool_call |
Records the code files the turn touched, and notices a prose file once per session |
tool_result |
Appends the tell report to a write or edit of a .md, .mdx, .txt or source file |
message_end |
Measures the reply's tell density, silently |
turn_end |
One oxlint spawn over everything the turn wrote, reported as a session entry |
A write is never refused. Refusing it discards the tool call and the model regenerates the whole file, which costs more than the tell did. The report names the line, and a turn that ends on a write still gets its report because turn_end delivers it as an entry.
Markdown
| Moment | Catches |
|---|---|
| Before generation | Throat-clearing, hooks, upbeat closers, self-answered beats, summary paragraphs, title case, "leverage" |
| On the write | Em and en dashes, clause-joining semicolons, curly quotes, tier-1 vocabulary, stock phrases |
| On the result | Tool leakage (utm_source=chatgpt.com), zero-width characters, unfilled placeholders |
| Statistics | Monotone cadence, four short sentences in a row, paragraphs that open the same way |
| On demand | /timbre fix <paths> scans prose already on disk and makes the agent rewrite it |
The report is advice, so there is a gate too. bun scripts/prose-gate.mts in this repository scans every tracked markdown file and fails on a tier-1 tell, and it runs inside just check. To use it in another repository, point its import at the installed package and call it from CI.
Path sets the register without a model call. A changelog can be terse, a blog post cannot, and the same flat cadence is correct in one and wrong in the other. Code spans, fenced blocks, URLs and link targets are masked, so a document that quotes a tell is not committing it. The escape hatch is <!-- lint-disable -- <reason> --> in the first 400 bytes, and the reason has to say why.
TypeScript
Regexes cannot see Promise<unknown>, so that half is dmmulroy/anti-slop: 18 Oxlint rules, one run at the end of the turn over every source file the turn wrote.
| Rule | What it stops |
|---|---|
no-unknown-returns, no-unknown-parameters, no-unknown-type-aliases |
Hiding a value behind unknown |
no-known-value-widening, no-widen-then-assert, no-chained-type-assertions |
Widening evidence and asserting it back |
no-unsafe-dictionary-type, no-object-parameters, no-runtime-typeof |
Skipping the parse at the boundary |
no-reflect-get, no-reflect-apply, no-conditional-empty-object-spread |
Untyped access, an unmodelled omission |
require-safety-comment-for-type-assertion |
An assertion without a SAFETY: line |
The prompt and the linter do different jobs. The prompt tells the model what a good type looks like while it composes, and the linter supplies the evidence. A rule the linter can prove needs no prompt tokens, so the catalog's Code section is four lines.
The check runs once per turn because an oxlint spawn is about 130ms of fixed cost and a file adds 2 to 3ms. Forty files measured 230ms against 135ms for one. Two earlier arrangements failed: a follow-up note cost a whole model request, and a note attached to the next write result was dropped when the turn's last act was the write.
Setting up anti-slop
Upstream publishes no npm package and asks to be vendored, so timbre ships the vendoring:
# from the installed package or any checkout of this repo
./scripts/anti-slop-setup.sh
The script fetches the pinned upstream revision and writes ~/.agents/tools/oxlint/anti-slop/ and anti-slop.config.ts with all 18 rules selected. It records provenance in anti-slop.vendor.json, installs @oxlint/plugins at the version your Oxlint reports, and then proves the rules run by checking a file that must fail. Re-running changes nothing unless you pass --force.
Timbre reads that path by default. With the rules elsewhere:
export TIMBRE_OXLINT_CONFIG=./tools/oxlint/oxlint.config.ts
export TIMBRE_OXLINT_BIN=./node_modules/.bin/oxlint # optional, found automatically
The same config is the hard gate. just check here runs oxlint --config scripts/oxlint/anti-slop.config.ts extensions/, and a project's pre-commit hook or CI can run the same command. The report is a tendency. The gate is what makes it never.
If upstream's own installer is a better fit, npx skills add dmmulroy/anti-slop --skill install-anti-slop vendors into the repository and merges the plugin into the project's Oxlint config. Point TIMBRE_OXLINT_CONFIG at that config, since timbre runs oxlint --config and reads the anti-slop(...) diagnostics back.
Commands
/timbre status|stats
/timbre on|off
/timbre fix <paths>
/timbre reset
ctrl+alt+t
/timbre reports whether the design is holding: average cache read and write per turn, and a warning when writes dominate reads. It prints a measured effect after the project has samples from both prevention arms, so run /timbre off for a stretch first. Until then the effect reads null.
A toggle persists to the project state file, so it survives the session and reaches a subagent, which runs as its own process.
Measurements
Prevention takes em dashes and semicolon chains from 14 and 15 per 1,000 characters down to 1, 2 and 3. The 117-token Never section carries that whole effect.
The judgment sections have no demonstrated effect on the tells the scanner counts, and two runs disagree with the first reading. The scanner is blind to attribution and sourcing, the work those sections do, so it cannot measure them either way. They sit behind the tool for that reason.
No model is used to detect anything. A local model asked which of two passages reads more machine-generated picked the same one in both orderings, and one asked to label a file read human and machine text at chance over 80 passages.
Limits
- Nothing is rewritten after generation.
- Patterns are English. A French or Vietnamese repository gets the prompt block and little else.
- Everything fails open.
/timbrenames a missing catalog, a missing Oxlint binary or a missing rule config instead of reporting prevention over an empty block.
Install
pi install npm:@tduyng/pi-timbre
Local checkout: pi install /path/to/dotfiles/pi/agent/extensions/pi-timbre
Then vendor the code rules once:
/path/to/extensions/pi-timbre/scripts/anti-slop-setup.sh
Design notes live in src/README.md.