@tduyng/pi-timbre 0.0.0-stage → 0.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +178 -0
- package/README.md +156 -2
- package/package.json +45 -4
- package/src/README.md +385 -0
- package/src/code.ts +154 -0
- package/src/config.ts +21 -0
- package/src/guidance/catalog.md +17 -0
- package/src/guidance/review.md +438 -0
- package/src/guide.ts +26 -0
- package/src/index.ts +695 -0
- package/src/memory.ts +238 -0
- package/src/patterns.ts +255 -0
- package/src/register.ts +35 -0
- package/src/report.ts +205 -0
- package/src/rules.ts +37 -0
- package/src/scan.ts +328 -0
- package/src/segments.ts +81 -0
- package/src/stats.ts +112 -0
package/src/README.md
ADDED
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
<!-- lint-disable -- this file quotes the tells it documents -->
|
|
2
|
+
|
|
3
|
+
# timbre
|
|
4
|
+
|
|
5
|
+
Keeps the agent's prose and code sounding like this project, by constraining it before the
|
|
6
|
+
tokens are generated rather than cleaning up after.
|
|
7
|
+
|
|
8
|
+
Timbre is the acoustic property that identifies a specific voice regardless of pitch or
|
|
9
|
+
volume. That is the target here: not "generic human prose" but prose that still sounds like
|
|
10
|
+
the person whose repository it lands in.
|
|
11
|
+
|
|
12
|
+
## Why this is not another slop linter
|
|
13
|
+
|
|
14
|
+
Every comparable tool works after generation.
|
|
15
|
+
|
|
16
|
+
| Tool | Detection | Correction | Cost per reply |
|
|
17
|
+
| ------------------- | ---------------------------- | -------------------------------- | ----------------------- |
|
|
18
|
+
| SLYE | none, 200-char gate | secondary LLM rewrite | 1 API call |
|
|
19
|
+
| claudish-to-english | none, 200-char gate | local ollama rewrite | 1 local call |
|
|
20
|
+
| nopus | statistical, corpus-backed | asks the agent to redo the reply | 1 redo |
|
|
21
|
+
| humanizer | none, on demand | the agent, against 50+ patterns | free |
|
|
22
|
+
| timbre | deterministic, then measured | prevention, before generation | one cached prompt block |
|
|
23
|
+
|
|
24
|
+
Pi's `before_agent_start` can return a system prompt for the turn. That is the one hook that
|
|
25
|
+
makes prevention possible, and it is why this extension is Pi-only. Nothing else in the
|
|
26
|
+
comparison table can reach the other side of generation.
|
|
27
|
+
|
|
28
|
+
For Pi, timbre replaces the `prose` skill outright. It carries the full
|
|
29
|
+
rule set: the constraints in the system prompt, the judgment layer behind a tool. Those two
|
|
30
|
+
skills now live only under Claude Code, which has its own skill loader for them.
|
|
31
|
+
|
|
32
|
+
## The loop
|
|
33
|
+
|
|
34
|
+
```
|
|
35
|
+
before_agent_start -> inject the whole constraint set, plus what this project repeats
|
|
36
|
+
timbre_review_guide -> serve the judgment procedure when the agent asks for it
|
|
37
|
+
tool_call -> block writes carrying an undeniable tell before they land
|
|
38
|
+
message_end -> measure the reply, silently, record what still got through
|
|
39
|
+
tool_result -> report tells in prose and source files just written
|
|
40
|
+
/timbre fix -> scan prose already on disk and make the agent rewrite it
|
|
41
|
+
memory -> feeds the next before_agent_start
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Detection is not a separate product here. It is the input to prevention and the instrument
|
|
45
|
+
that says whether prevention works.
|
|
46
|
+
|
|
47
|
+
## Layers
|
|
48
|
+
|
|
49
|
+
| Layer | Detects | Cost |
|
|
50
|
+
| -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------- |
|
|
51
|
+
| 0. Prevention | Nothing. Constrains the generation instead. | one cached system-prompt block |
|
|
52
|
+
| 0.5 Preflight | Blocks the tells with no legitimate use and no false positive after masking: tool leakage, invisible Unicode, em and en dashes, curly quotes, a clause-joining semicolon. Everything else is note-only, placeholders included, since templates legitimately contain them. After three consecutive blocks it stands down rather than loop with a stubborn retry | free |
|
|
53
|
+
| 1. Forensic | Tool leakage: `utm_source=chatgpt.com`, `citeturn0search0`, zero-width characters, unfilled placeholders | free |
|
|
54
|
+
| 1. Mechanical | Fixed tells: dashes, semicolons, tier-1 vocabulary, stock phrases, Claude's own tics | free |
|
|
55
|
+
| 2. Statistical | Cadence, clause stacks, and paragraph shape: the signals regex cannot see | free |
|
|
56
|
+
| 3. Judgment | Triage, the pattern catalog, false positives, human signals worth keeping | one tool call, only when reviewing |
|
|
57
|
+
|
|
58
|
+
There is no corpus. nopus reaches layer 2 with 1.8 MB of frequency and concreteness data,
|
|
59
|
+
which overlaps heavily with the tier-1 vocabulary already matched exactly. Cadence variance,
|
|
60
|
+
clause stacks, repeated openings, and paragraph shape need no data at all, so they are the
|
|
61
|
+
signals implemented.
|
|
62
|
+
|
|
63
|
+
Tier 2 words (`robust`, `seamless`, `pivotal`, `harness`) are not flagged one by one.
|
|
64
|
+
They are ordinary English alone and a tell only in clusters, so timbre counts them per
|
|
65
|
+
paragraph and flags a paragraph that uses several at once. That catches the cluster without
|
|
66
|
+
the single-use false positives that would discredit the report.
|
|
67
|
+
|
|
68
|
+
## The Pangram method
|
|
69
|
+
|
|
70
|
+
Detection and rewrite rules draw from the Pangram 4 technical report (Glickenhaus et al.,
|
|
71
|
+
2026): clause-level provenance, contiguous span aggregation, and the rule that reordering
|
|
72
|
+
your own sentences never changes authorship but rewording toward model phrasing does.
|
|
73
|
+
|
|
74
|
+
## Register
|
|
75
|
+
|
|
76
|
+
A commit message, an API reference and a blog post do not share a correct voice. Terse and
|
|
77
|
+
list-heavy is right in a changelog and wrong in an essay, so the rhythm signal has to mean
|
|
78
|
+
different things in each. Path classifies it, with no model call:
|
|
79
|
+
|
|
80
|
+
| Path | Register | Terse is correct | Voice allowed |
|
|
81
|
+
| ------------------------------------ | --------- | ---------------- | ------------- |
|
|
82
|
+
| `docs/`, `README.md`, `adr/`, `api/` | reference | yes | no |
|
|
83
|
+
| `CHANGELOG.md`, `COMMIT_EDITMSG` | changelog | yes | no |
|
|
84
|
+
| `blog/`, `posts/`, `essays/` | narrative | no | yes |
|
|
85
|
+
| anything else | unknown | no | no |
|
|
86
|
+
|
|
87
|
+
## Commands
|
|
88
|
+
|
|
89
|
+
A status indicator in the bar shows the live mode (`timbre: on`, `timbre: off`).
|
|
90
|
+
|
|
91
|
+
| Command | Effect |
|
|
92
|
+
| -------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
|
|
93
|
+
| `/timbre` | Tell density with prevention on versus off, the most frequent tells, and where each setting comes from |
|
|
94
|
+
| `/timbre fix <path>... [instructions]` | Scan prose already on disk and make the agent rewrite it. The way back into a document layers 0 and 1 never saw |
|
|
95
|
+
| `/timbre off` | Stop injecting. Samples still record, which is how a baseline gets built |
|
|
96
|
+
| `/timbre on` | Resume injecting |
|
|
97
|
+
| `/timbre reset` | Discard this project's samples, live overrides included, back to the env defaults |
|
|
98
|
+
| `ctrl+alt+t` | Toggle prevention with the same persistence path as `/timbre on` and `/timbre off` |
|
|
99
|
+
|
|
100
|
+
`/timbre fix` is the repair path for text that already landed. Layers 0 and 1 both act at
|
|
101
|
+
generation time, so a document written in an earlier session, by another tool, or by a model
|
|
102
|
+
that ignored the rules is out of their reach entirely. `fix` scans the named files off disk,
|
|
103
|
+
groups what it finds by tell id with the line numbers, and sends the agent a real user
|
|
104
|
+
message that starts a turn. It sends nothing when a file scans clean, and it skips a file
|
|
105
|
+
carrying a `<!-- lint-disable -- <reason> -->` marker, the same escape hatch the write path honours.
|
|
106
|
+
|
|
107
|
+
A word names a file when it starts with the `@` file completion inserts, carries a glob
|
|
108
|
+
character, ends in a prose suffix, or is path-shaped and exists on disk. Everything else is
|
|
109
|
+
instruction text, kept verbatim with its newlines, and it rides along in the rewrite request.
|
|
110
|
+
That last clause is what keeps a pasted paragraph mentioning `JSON.parse` or `/someEndpoint`
|
|
111
|
+
from being read as two files nobody asked for.
|
|
112
|
+
|
|
113
|
+
Globs expand here, since a slash command is not a shell and nothing expanded them on the way
|
|
114
|
+
in. A pattern without a directory in it searches the whole project, so `/timbre fix *.md drop
|
|
115
|
+
the ai slop` reaches `docs/deep.md`. One with a directory is taken literally. `node_modules`,
|
|
116
|
+
`.git` and the usual build directories are never walked. One request carries at most ten
|
|
117
|
+
files and says how many it left behind.
|
|
118
|
+
|
|
119
|
+
Naming no file at all is allowed. `/timbre fix that readme and shorten it` refers to a file
|
|
120
|
+
the way a person does, and resolving that needs the conversation, which the command handler
|
|
121
|
+
cannot see and the agent already has. So the sentence is forwarded intact, with the review
|
|
122
|
+
procedure attached, and the agent finds the file. Nothing is scanned on that path: no line
|
|
123
|
+
numbers up front, and the agent runs the scan itself once it knows what it is looking at.
|
|
124
|
+
|
|
125
|
+
An instruction also overrides a clean scan. `/timbre fix notes.md make it shorter` still
|
|
126
|
+
sends the rewrite when the file carries no mechanical tell, because "are there tells" and
|
|
127
|
+
"is this any good" are different questions and the second one was asked.
|
|
128
|
+
|
|
129
|
+
The check on the rewrite needs no extra machinery. The agent's edits go back out through
|
|
130
|
+
`write` and `edit`, where the preflight blocks a reintroduced em dash, curly quote or
|
|
131
|
+
semicolon outright and the tool result reports whatever else survived. An on-request scan
|
|
132
|
+
records no sample: a sample answers whether prevention changed what a model generated on
|
|
133
|
+
some turn, and a file read off disk belongs to no turn and neither prevention arm, so
|
|
134
|
+
counting it would corrupt the only measurement `/timbre` exists to produce.
|
|
135
|
+
|
|
136
|
+
`on` and `off` switch live, take effect the next turn, and persist
|
|
137
|
+
to the project state file: a one-off `/timbre off` for this session is also what the next,
|
|
138
|
+
unrelated session in this project starts from, not something scoped back down when the
|
|
139
|
+
session ends. Each switch says so (`project default until changed again`), and each also
|
|
140
|
+
changes the system prompt, so it says the next turn re-primes the cached prefix rather than
|
|
141
|
+
reusing this turn's cache read. `/timbre reset` is the way back to `TIMBRE_PREVENT` and
|
|
142
|
+
`TIMBRE_PREVENT`'s env value. `ctrl+alt+t` toggles prevention with the
|
|
143
|
+
same persistence path as `/timbre on|off`, for flipping mid-session without leaving the editor.
|
|
144
|
+
|
|
145
|
+
Persisting to the file rather than keeping the switch session-scoped is what lets it reach a
|
|
146
|
+
subagent: a subagent runs as its own `pi` process against the same project, sharing no memory
|
|
147
|
+
with the session that spawned it, so the state file is the only channel `/timbre off` has to
|
|
148
|
+
reach it. `TIMBRE_PREVENT` set in the environment reaches subagents too, since they inherit
|
|
149
|
+
it. Only the live toggle needed the file.
|
|
150
|
+
|
|
151
|
+
A persisted toggle outlives the session that set it, so a new session announces any still-active override at startup instead of applying it silently, and `/timbre` names each setting's source (env or the state file) and flags carry-overs. The announcement names only overrides that change behaviour against the env default: a `/timbre off` on a session whose `TIMBRE_PREVENT` is already `0` is not news.
|
|
152
|
+
|
|
153
|
+
## Does prevention work
|
|
154
|
+
|
|
155
|
+
That is an empirical question, answered by `/timbre` rather than argued about. Every sample
|
|
156
|
+
records whether layer 0 was active, so the readout compares this project's own
|
|
157
|
+
prevention-on and prevention-off history directly. Until both arms have samples the effect
|
|
158
|
+
reads `null`:
|
|
159
|
+
|
|
160
|
+
```
|
|
161
|
+
prevention off: no samples yet (run /timbre off to build a baseline)
|
|
162
|
+
prevention on: <density> tells/1k chars over <n> samples
|
|
163
|
+
|
|
164
|
+
Effect: null (need samples from both arms)
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
To get a real number, run `/timbre off` for a stretch, then `/timbre on`. Once both arms have
|
|
168
|
+
samples the readout prints a measured delta:
|
|
169
|
+
|
|
170
|
+
```
|
|
171
|
+
prevention off: <density> tells/1k chars over <n> samples
|
|
172
|
+
prevention on: <density> tells/1k chars over <m> samples
|
|
173
|
+
|
|
174
|
+
Effect: <Z>% fewer tells with prevention on.
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
`<Z>` is filled from real samples, never estimated. No project has a two-sided sample set
|
|
178
|
+
yet, so no percentage has been measured or quoted as timbre's actual effect.
|
|
179
|
+
|
|
180
|
+
## Configuration
|
|
181
|
+
|
|
182
|
+
Environment only.
|
|
183
|
+
|
|
184
|
+
| Variable | Default | Meaning |
|
|
185
|
+
| ------------------- | ------- | ------------------------------------------------------------------------------------------------------ |
|
|
186
|
+
| `TIMBRE` | `1` | Master switch |
|
|
187
|
+
| `TIMBRE_PREVENT` | `1` | Layer 0. `0` measures without injecting |
|
|
188
|
+
| `TIMBRE_WATCH_CHAT` | `1` | Measure assistant replies, silently |
|
|
189
|
+
|
|
190
|
+
Set `PI_CACHE_RETENTION=long` in your environment. Pi's default cache TTL is 5 minutes, so
|
|
191
|
+
any pause longer than that makes the next turn re-write the whole cached prefix instead of
|
|
192
|
+
reading it, at 12x the cost. A 1-hour TTL is what makes the numbers below hold in practice.
|
|
193
|
+
|
|
194
|
+
## What layer 0 costs
|
|
195
|
+
|
|
196
|
+
The block sits in the system prompt, so it is sent on every turn of every session. It is built whenever prevention is on: a rule the model only meets after the write is a rule it already broke. Gating the block on the read also had a failure mode, since a refused connection and a model with nothing to say look the same from the outside, and the rules ended up nowhere with nothing reporting it. Pi marks the block cacheable, and Anthropic bills cache writes at 1.25x and reads at 0.1x:
|
|
197
|
+
|
|
198
|
+
| | tokens |
|
|
199
|
+
| ---------------------------------------- | --------------------------------------------- |
|
|
200
|
+
| The catalog | 986 |
|
|
201
|
+
| Per turn, cache warm | 99 |
|
|
202
|
+
| Per turn, cache cold | 1,085 |
|
|
203
|
+
| `review.md` | 4,586, and only when the agent calls the tool |
|
|
204
|
+
|
|
205
|
+
A 30-turn session costs about 5k tokens with a 1-hour cache. The same session costs 47k if
|
|
206
|
+
every single turn goes cold, which is what a 5-minute TTL plus normal think time produces.
|
|
207
|
+
|
|
208
|
+
That table is a computed estimate, not a measurement. `/timbre` now reports the average
|
|
209
|
+
`cacheRead`/`cacheWrite` Anthropic itself billed per turn, straight from each reply's own
|
|
210
|
+
`usage`, so the estimate can be checked against a real session rather than trusted on its own:
|
|
211
|
+
|
|
212
|
+
```
|
|
213
|
+
cache (prevention on): avg 118 read / 3 write tokens/turn over 12 turns
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
A high write average relative to read means the prefix is not actually staying cached turn to
|
|
217
|
+
turn, the one failure that would silently defeat the whole design, and `/timbre` says so.
|
|
218
|
+
|
|
219
|
+
The catalog states the rules nothing else can enforce and omits the ones the scanner
|
|
220
|
+
catches for free: paying every turn to restate a rule the tool result already names by line
|
|
221
|
+
buys the same enforcement twice. `TIMBRE_PREVENT=0` drops the cost to zero and leaves
|
|
222
|
+
detection and measurement running, which is the cheapest way to build a baseline before
|
|
223
|
+
deciding whether prevention pays for itself.
|
|
224
|
+
|
|
225
|
+
## Design constraints worth keeping
|
|
226
|
+
|
|
227
|
+
**Every rule is enforced, not every rule is injected.** Coverage is a property of the whole
|
|
228
|
+
system, not of the prompt. The tool result catches 22 prose tells, 18 TypeScript rules and the statistical
|
|
229
|
+
tells for free,
|
|
230
|
+
so those do not need prompt tokens to be enforced. What only prevention can reach is the rest:
|
|
231
|
+
false agency, naming the real thing, fabrication, dropped caveats, register, discourse shape,
|
|
232
|
+
and the code smells no linter sees. `catalog.md` states exactly that set, plus the handful of
|
|
233
|
+
mechanical tells whose base rate is high enough that catching them after the fact would mean
|
|
234
|
+
constant rewrite churn.
|
|
235
|
+
|
|
236
|
+
**The block is static.** An earlier version varied per turn, which was the worst possible
|
|
237
|
+
property for the one string that sits in front of the entire conversation.
|
|
238
|
+
|
|
239
|
+
The block is computed once per project and then frozen for the session. Recomputing it per
|
|
240
|
+
turn would be ruinous: memory changes on every reply and every file write, and the
|
|
241
|
+
system prompt sits in front of the whole conversation, so one shifted word there invalidates
|
|
242
|
+
the entire cached prefix. It still adapts across sessions, which is the timescale a
|
|
243
|
+
writing habit actually moves on.
|
|
244
|
+
|
|
245
|
+
**Constraints are injected, procedures are served.** A constraint ("never an em dash") is
|
|
246
|
+
short and useless after the fact, so it belongs in the prompt before generation. A procedure
|
|
247
|
+
for judging and repairing existing text is long and irrelevant on any turn that is not doing
|
|
248
|
+
that, so it sits behind the `timbre_review_guide` tool. That split is what lets timbre carry
|
|
249
|
+
the whole of `prose` and `anti-slop` without paying for the whole of it every turn.
|
|
250
|
+
|
|
251
|
+
**Every tell is stated once in `patterns.ts`.** `guidance/catalog.md` is the only place a
|
|
252
|
+
constraint's prose lives. `patterns.ts` carries detection regexes and nothing else. An earlier
|
|
253
|
+
version paired each pattern with its own copy of the rule text, which made two sources of
|
|
254
|
+
truth for the same sentence and a standing invitation to drift.
|
|
255
|
+
|
|
256
|
+
**One catalog, chosen by nothing.** A regex over the prompt text used to pick between rule
|
|
257
|
+
sets, scoring 0.375 recall at 0.250 precision over 17 recorded sessions, and the switch cost
|
|
258
|
+
a live subcommand, a persisted setting, a compaction latch and a second cache signature.
|
|
259
|
+
`catalog.md` is now injected on every turn, sent with the rewrite request and read by System
|
|
260
|
+
One.
|
|
261
|
+
|
|
262
|
+
Every section it states is a rule no detector checks: the actor, the real thing, the claim,
|
|
263
|
+
the voice, the register, the code comment. Every section it omits has a detector behind it.
|
|
264
|
+
Framing tells maps to six patterns, Structures to avoid to seven, Shape's cadence to the
|
|
265
|
+
Tier-2 statistics, Conversation to `chat-artifact`, the TypeScript rules to the vendored
|
|
266
|
+
word lists to `ai-vocabulary`. Two exceptions moved rather than
|
|
267
|
+
vanished: the typographic ellipsis, which no pattern matches, sits in Never, and the line
|
|
268
|
+
about justifying a type assertion with a type rather than a comment is absent, since
|
|
269
|
+
`require-safety-comment-for-type-assertion` is enforced and the catalog would have been
|
|
270
|
+
arguing with it.
|
|
271
|
+
|
|
272
|
+
`review.md` stays separate. It is a procedure rather than a rule set, so it is served on
|
|
273
|
+
demand.
|
|
274
|
+
|
|
275
|
+
**Everything fails open, but a degraded state is never silent.** A broken scan, an unwritable
|
|
276
|
+
state file or malformed memory leaves the turn exactly as Pi would have run it. File findings
|
|
277
|
+
are appended to a successful tool result rather than raised as an error: the write did happen,
|
|
278
|
+
and a false error sends the agent rewriting a file that is already correct on disk. Failing
|
|
279
|
+
open is not the same as failing invisibly: a missing `guidance/` directory (a packaging slip)
|
|
280
|
+
or an unreadable catalog both degrade a whole layer to a no-op, and `/timbre`
|
|
281
|
+
names both rather than reporting "prevention on" over an empty block.
|
|
282
|
+
|
|
283
|
+
**A hit is evidence, not a verdict.** Backtick spans, fenced and indented code, URLs and
|
|
284
|
+
link targets are masked, so a doc naming a tell is not committing it. Markdown blockquotes
|
|
285
|
+
and double-quoted phrases are **not** masked and will be flagged: the escape hatch there is
|
|
286
|
+
`<!-- lint-disable -- <reason> -->` in the first 400 bytes, which is how this README avoids flagging itself.
|
|
287
|
+
The reason must run at least 8 characters, and a bare marker is not honoured, so an opt-out
|
|
288
|
+
always says why.
|
|
289
|
+
|
|
290
|
+
**Spans over scattered hits.** Fifteen em dashes and a handful of throat-clearing phrases
|
|
291
|
+
need the agent to know which line ranges are machine-shaped. `segments.ts` merges adjacent
|
|
292
|
+
flagged lines into contiguous spans and computes the flagged character fraction, so
|
|
293
|
+
`/timbre fix` can say "lines 40-58 are machine-shaped, rewrite from your outline" rather
|
|
294
|
+
than deliver a scattered list.
|
|
295
|
+
|
|
296
|
+
**A weak model argues with a note, so the typography tells block instead.** Layers 0 and 1
|
|
297
|
+
both route through the model's cooperation: the prompt asks, the report tells. A
|
|
298
|
+
`nemotron-3.5-lightning-free` session put twenty em dashes in one document with the rule in
|
|
299
|
+
its system prompt, read the report naming every line, and concluded the detector was wrong.
|
|
300
|
+
A block is the one enforcement that does not need the model to agree, so the tells that
|
|
301
|
+
cannot be a false positive after masking take that path. The rest stay notes, because a
|
|
302
|
+
wrongly blocked write costs a full retry round trip.
|
|
303
|
+
|
|
304
|
+
**The block groups by id, not by hit.** Seven em dashes used to print seven identical hint
|
|
305
|
+
lines and no location, which reads as noise and leaves the agent guessing which spans to
|
|
306
|
+
rewrite. The message now names the path, one line per id with every line number it fired on,
|
|
307
|
+
and the first two offending lines quoted. An edit's numbers count the hunk text rather than
|
|
308
|
+
the file, and the message says so, since `edits[].newText` is all the preflight sees.
|
|
309
|
+
|
|
310
|
+
**A write scans the whole file. An edit scans only its hunks.** Both come straight from the
|
|
311
|
+
tool call, never a disk read. `write` always replaces a file wholesale, so the whole new file
|
|
312
|
+
is the agent's own text. `edit` carries `edits[].newText`, so a three-line change to a long
|
|
313
|
+
document is judged on those three lines, not on every tell already sitting in the rest of the
|
|
314
|
+
file. That also keeps a page under active edit from dominating the sample window with
|
|
315
|
+
near-duplicate reads of its own unchanged bulk.
|
|
316
|
+
|
|
317
|
+
## State
|
|
318
|
+
|
|
319
|
+
One JSON file per project under `$XDG_STATE_HOME/timbre/`, keyed by a hash of the project
|
|
320
|
+
path. Writes are atomic (temp file, then rename) so a crash mid-write cannot leave a
|
|
321
|
+
truncated file that breaks the next session. Nothing leaves the machine. A prose write is
|
|
322
|
+
noticed once per file per session, keyed by session and path, so a rewrite cannot spend a
|
|
323
|
+
second notice on the same file. `/timbre` counts what was noticed and what was held back.
|
|
324
|
+
|
|
325
|
+
## Known limits
|
|
326
|
+
|
|
327
|
+
**Thesis still unverified by field data.** `index.test.ts` fires the real `before_agent_start`,
|
|
328
|
+
`message_end`, and `tool_result` handlers the way Pi would, so the wiring is verified. The thesis, that preventing before generation reduces tells, is not
|
|
329
|
+
verified: a project that has only ever run with prevention on has no baseline, so the effect
|
|
330
|
+
reads `null`. A measured effect requires running `/timbre off` for a stretch first.
|
|
331
|
+
|
|
332
|
+
**Calibration.** The burstiness threshold (CoV below 0.35 over 6+ sentences) is a starting
|
|
333
|
+
value, not a derived one. nopus published intervention rates from 5,337 measured responses.
|
|
334
|
+
Nothing here has that backing yet, which is precisely what `/timbre` exists to produce.
|
|
335
|
+
|
|
336
|
+
**Measurement caveats that survive.** Hits are counted once per pattern per line, so a hard
|
|
337
|
+
wrapped paragraph scores higher than the same text unwrapped. Chat replies are never
|
|
338
|
+
wrapped and file prose usually is, so the two are not perfectly comparable even within the
|
|
339
|
+
prose domain. Concurrent sessions merge on write rather than lock, so a simultaneous write
|
|
340
|
+
can still drop one sample.
|
|
341
|
+
|
|
342
|
+
**Pattern rot.** Every pattern is calibrated against current models. Wikipedia's own guide
|
|
343
|
+
already notes some signs are outdated for recent releases. Patterns live in `patterns.ts` as
|
|
344
|
+
data for that reason.
|
|
345
|
+
|
|
346
|
+
**Corpus contamination, if voice learning is ever added.** Learning an author's voice from a
|
|
347
|
+
git history that already contains model-written commits teaches the system to reproduce slop
|
|
348
|
+
as "their voice". A clean corpus would mean pre-2023 commits or explicitly human-verified
|
|
349
|
+
text. Getting this wrong inverts the product silently, so it is not implemented on a guess.
|
|
350
|
+
|
|
351
|
+
**English only.** The pattern list is English. A French or Vietnamese repository gets layer 0
|
|
352
|
+
and the forensic tier, and little else of value.
|
|
353
|
+
|
|
354
|
+
## Files
|
|
355
|
+
|
|
356
|
+
| File | Contents |
|
|
357
|
+
| ------------------------ | ---------------------------------------------------------------------- |
|
|
358
|
+
| `guidance/catalog.md` | The rules nothing else can enforce. Injected every turn |
|
|
359
|
+
| `guidance/review.md` | The judgment layer: triage, catalog, false positives, de-slop workflow |
|
|
360
|
+
| `rules.ts` | Loads and caches the catalog and the review guide |
|
|
361
|
+
| `guide.ts` | Serves `guidance/review.md` through the `timbre_review_guide` tool |
|
|
362
|
+
| `patterns.ts` | Every mechanically detectable tell, as data. Detection only |
|
|
363
|
+
| `scan.ts` | Layer 1 prose. Pure functions, no I/O |
|
|
364
|
+
| `stats.ts` | Layer 2. Burstiness |
|
|
365
|
+
| `register.ts` | Path to register classification |
|
|
366
|
+
| `prevent.ts` | Layer 0. Composes the block and the recurring-tells line |
|
|
367
|
+
| `memory.ts` | Layer 4. Per-project state and the effect calculation |
|
|
368
|
+
| `report.ts` | Formatting for the model and for the terminal |
|
|
369
|
+
| `index.ts` | Event wiring |
|
|
370
|
+
|
|
371
|
+
## Development
|
|
372
|
+
|
|
373
|
+
From the repo root:
|
|
374
|
+
|
|
375
|
+
```sh
|
|
376
|
+
just test timbre/
|
|
377
|
+
just check
|
|
378
|
+
```
|
|
379
|
+
|
|
380
|
+
`just check` runs the typecheck, plain Oxlint, the repo anti-slop pass, and
|
|
381
|
+
the full test suite, all from `extensions/`, which is where `tsconfig.json` and
|
|
382
|
+
`node_modules` actually live. Running `tsc`/`oxlint` directly from any other directory
|
|
383
|
+
finds neither. The anti-slop pass stays scoped to `timbre/` rather than the whole package:
|
|
384
|
+
turning it on repo-wide would also fail against node_modules and pre-existing files this
|
|
385
|
+
extension never touched.
|
package/src/code.ts
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process'
|
|
2
|
+
import { existsSync } from 'node:fs'
|
|
3
|
+
import { homedir } from 'node:os'
|
|
4
|
+
import { promisify } from 'node:util'
|
|
5
|
+
import { dirname, isAbsolute, join, resolve } from 'node:path'
|
|
6
|
+
import { fileURLToPath } from 'node:url'
|
|
7
|
+
import type { ToolResultEvent } from '@earendil-works/pi-coding-agent'
|
|
8
|
+
import type { Hit } from './scan.js'
|
|
9
|
+
import { Validator } from 'ata-validator'
|
|
10
|
+
import { t } from 'ata-validator/t'
|
|
11
|
+
|
|
12
|
+
const HERE = dirname(fileURLToPath(import.meta.url))
|
|
13
|
+
function findOxlintBin(): string {
|
|
14
|
+
let dir = HERE
|
|
15
|
+
while (true) {
|
|
16
|
+
const candidate = join(dir, 'node_modules', '.bin', 'oxlint')
|
|
17
|
+
if (existsSync(candidate)) return candidate
|
|
18
|
+
const parent = dirname(dir)
|
|
19
|
+
if (parent === dir) return join(HERE, '..', '..', 'node_modules', '.bin', 'oxlint')
|
|
20
|
+
dir = parent
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
const OXLINT_BIN = findOxlintBin()
|
|
24
|
+
const OXLINT_CONFIG = join(homedir(), '.agents', 'tools', 'oxlint', 'anti-slop.config.ts')
|
|
25
|
+
const VENDORED = `${join(homedir(), '.agents', 'tools', 'oxlint')}/`
|
|
26
|
+
|
|
27
|
+
const TS_FILE = /\.(ts|tsx|js|jsx|mjs|cjs)$/i
|
|
28
|
+
const SKIP_CODE = [
|
|
29
|
+
/\.d\.(ts|mts|cts)$/i,
|
|
30
|
+
/\.min\.(ts|tsx|js|jsx|mjs|cjs|css)$/i,
|
|
31
|
+
/\.snap$/i,
|
|
32
|
+
/\.gen\.(ts|tsx|js|jsx)$/i,
|
|
33
|
+
/(^|\/)(package-lock\.json|pnpm-lock\.yaml|yarn\.lock|bun\.lockb)$/i
|
|
34
|
+
]
|
|
35
|
+
const TIMEOUT_MS = 10_000
|
|
36
|
+
const PREFIX = 'anti-slop('
|
|
37
|
+
|
|
38
|
+
const execFileAsync = promisify(execFile)
|
|
39
|
+
|
|
40
|
+
type OxlintDiagnostic = {
|
|
41
|
+
code: string
|
|
42
|
+
filename?: string
|
|
43
|
+
message?: string
|
|
44
|
+
labels?: Array<{ span?: { line?: number } }>
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const OxlintReportSchema = t.object({ diagnostics: t.optional(t.unknown()) })
|
|
48
|
+
const isOxlintReportSchema = new Validator(OxlintReportSchema).isValidObject
|
|
49
|
+
const OxlintDiagnosticSchema = t.object({
|
|
50
|
+
code: t.string(),
|
|
51
|
+
filename: t.optional(t.string()),
|
|
52
|
+
message: t.optional(t.string()),
|
|
53
|
+
labels: t.optional(
|
|
54
|
+
t.array(
|
|
55
|
+
t.object({
|
|
56
|
+
span: t.optional(t.object({ line: t.optional(t.number()) }))
|
|
57
|
+
})
|
|
58
|
+
)
|
|
59
|
+
)
|
|
60
|
+
})
|
|
61
|
+
const isOxlintDiagnosticSchema = new Validator(OxlintDiagnosticSchema).isValidObject
|
|
62
|
+
|
|
63
|
+
export function codePath(
|
|
64
|
+
event: Pick<ToolResultEvent, 'toolName' | 'input'>,
|
|
65
|
+
cwd: string,
|
|
66
|
+
isError = false
|
|
67
|
+
): string | null {
|
|
68
|
+
if (isError) return null
|
|
69
|
+
if (event.toolName !== 'write' && event.toolName !== 'edit') return null
|
|
70
|
+
const path = String(event.input['path'] ?? '')
|
|
71
|
+
if (!TS_FILE.test(path)) return null
|
|
72
|
+
const abs = isAbsolute(path) ? path : resolve(cwd, path)
|
|
73
|
+
if (abs.startsWith(VENDORED)) return null
|
|
74
|
+
if (SKIP_CODE.some((re) => re.test(abs))) return null
|
|
75
|
+
return abs
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export type CodeFinding = Hit & { path: string }
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* One oxlint process for the whole turn.
|
|
82
|
+
*
|
|
83
|
+
* The spawn is ~130ms of fixed cost and the marginal file is 2-3ms: forty files
|
|
84
|
+
* measured 230ms against 135ms for one. So the check runs once per turn over
|
|
85
|
+
* every code file the turn touched, off the write's critical path, rather than
|
|
86
|
+
* once per write where the fixed cost was paid N times for nothing.
|
|
87
|
+
*/
|
|
88
|
+
// A spawn that cannot find its config returns null, which reads the same as a clean file, so
|
|
89
|
+
// the failure is otherwise invisible. Both paths live outside this package: the rules are a
|
|
90
|
+
// vendored copy under ~/.agents/tools/oxlint, so a checkout on another machine checks nothing.
|
|
91
|
+
export function codeCheckProblem(bin: string = OXLINT_BIN, config: string = OXLINT_CONFIG): string | undefined {
|
|
92
|
+
if (!existsSync(bin)) return `the code check is off: no oxlint binary at ${bin}`
|
|
93
|
+
if (!existsSync(config)) return `the code check is off: no rule config at ${config}`
|
|
94
|
+
return undefined
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export async function scanCodeBatch(
|
|
98
|
+
paths: string[],
|
|
99
|
+
bin = OXLINT_BIN,
|
|
100
|
+
config = OXLINT_CONFIG
|
|
101
|
+
): Promise<CodeFinding[] | null> {
|
|
102
|
+
const unique = [...new Set(paths)].filter((path) => existsSync(path))
|
|
103
|
+
if (unique.length === 0) return []
|
|
104
|
+
try {
|
|
105
|
+
const { stdout } = await execFileAsync(bin, ['--config', config, '--format', 'json', ...unique], {
|
|
106
|
+
encoding: 'utf-8',
|
|
107
|
+
timeout: TIMEOUT_MS,
|
|
108
|
+
maxBuffer: 32 * 1024 * 1024
|
|
109
|
+
})
|
|
110
|
+
return parseOxlint(stdout)
|
|
111
|
+
} catch (err) {
|
|
112
|
+
// absent for a spawn failure, so the field is optional.
|
|
113
|
+
return parseOxlint((err as { stdout?: string }).stdout ?? '')
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function parseOxlint(stdout: string): CodeFinding[] | null {
|
|
118
|
+
let parsed: unknown
|
|
119
|
+
try {
|
|
120
|
+
parsed = JSON.parse(stdout)
|
|
121
|
+
} catch {
|
|
122
|
+
return null
|
|
123
|
+
}
|
|
124
|
+
if (!isOxlintReportSchema(parsed)) return null
|
|
125
|
+
const report = parsed
|
|
126
|
+
// A report whose diagnostics field is present but not a list is malformed, and
|
|
127
|
+
// reading it as an empty list would report a broken spawn as a clean file.
|
|
128
|
+
if (report.diagnostics !== undefined && !Array.isArray(report.diagnostics)) return null
|
|
129
|
+
const diagnostics = report.diagnostics ?? []
|
|
130
|
+
const seen = new Set<string>()
|
|
131
|
+
const out: CodeFinding[] = []
|
|
132
|
+
for (const d of diagnostics) {
|
|
133
|
+
if (!isAntiSlop(d)) continue
|
|
134
|
+
const line = d.labels?.[0]?.span?.line ?? 0
|
|
135
|
+
const path = d.filename ?? ''
|
|
136
|
+
const key = `${path}:${d.code}:${line}`
|
|
137
|
+
if (seen.has(key)) continue
|
|
138
|
+
seen.add(key)
|
|
139
|
+
out.push({
|
|
140
|
+
path,
|
|
141
|
+
line,
|
|
142
|
+
id: d.code.slice(PREFIX.length, -1),
|
|
143
|
+
tier: 1 as const,
|
|
144
|
+
hint: d.message ?? '',
|
|
145
|
+
text: ''
|
|
146
|
+
})
|
|
147
|
+
}
|
|
148
|
+
return out
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function isAntiSlop(d: unknown): d is OxlintDiagnostic {
|
|
152
|
+
if (!isOxlintDiagnosticSchema(d)) return false
|
|
153
|
+
return d.code.startsWith(PREFIX)
|
|
154
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export type Config = {
|
|
2
|
+
enabled: boolean
|
|
3
|
+
prevent: boolean
|
|
4
|
+
watchChat: boolean
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
const FALSE = /^(0|false|no|off)$/i
|
|
8
|
+
|
|
9
|
+
function flag(name: string, fallback: boolean): boolean {
|
|
10
|
+
const raw = process.env[name]
|
|
11
|
+
if (raw === undefined || raw === '') return fallback
|
|
12
|
+
return !FALSE.test(raw)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function loadConfig(): Config {
|
|
16
|
+
return {
|
|
17
|
+
enabled: flag('TIMBRE', true),
|
|
18
|
+
prevent: flag('TIMBRE_PREVENT', true),
|
|
19
|
+
watchChat: flag('TIMBRE_WATCH_CHAT', true)
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
<!-- lint-disable -- this file names the tells it forbids -->
|
|
2
|
+
|
|
3
|
+
# Writing constraints
|
|
4
|
+
|
|
5
|
+
## Never
|
|
6
|
+
|
|
7
|
+
Em or en dashes, or a double hyphen as one. A semicolon joining two clauses, split the
|
|
8
|
+
sentence. A typographic ellipsis. Title Case Headings, use sentence case. "utilize" or
|
|
9
|
+
"leverage" for "use". Throat-clearing ("it's worth noting", "here's the thing"). Announcing
|
|
10
|
+
what comes next ("let's dive in"). An opening hook ("Ever wondered", "In today's
|
|
11
|
+
fast-paced world"). A summary or upbeat closing line. A self-answered beat ("The result?").
|
|
12
|
+
"not X, it's Y".
|
|
13
|
+
|
|
14
|
+
Code: comment only a non-obvious invariant, a security reason, or a contract. No debug
|
|
15
|
+
logs, commented-out code, scaffolding, test ceremony where a plain test is clearer, or
|
|
16
|
+
adapter layers the neighbouring code does without. The malformed-input cases that survive
|
|
17
|
+
belong at real trust boundaries. The gate lint enforces these on every check.
|