specpi 0.29.0 → 0.30.0

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/CHANGELOG.md CHANGED
@@ -1,5 +1,37 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.30.0 - 2026-09-21
4
+
5
+ - Replace the evaluations page with Terminal-Bench 2.0. The suite that filled it was written in this repository, and it was retired for the reason it kept reporting: nearly every harness passed nearly every task, so the thing it was built to measure was the thing it could not see, and three rounds of harder tasks did not move it. A suite cannot mark its own homework. The page now carries somebody else's benchmark on tasks nobody here chose -- Pi with no harness, SpecPi + Jev, Oh My Pi and Claude Code, 334 scored attempts on one model through one endpoint.
6
+ - Report the result the outside benchmark actually gives, which is that it does not separate the harnesses either. Solve rate spans 0.767 to 0.838 across the four arms; Pi against SpecPi + Jev is Fisher p = 0.83, and exactly 30/39 each on the slice the comparison rests on. That is the second suite to find no difference in capability, and this one's tasks were not chosen here and are failed about a fifth of the time, so the usual explanation -- that the tasks are too easy -- does not cover it.
7
+ - Make spend the column the page is about, because it is the one that separates. SpecPi + Jev reaches the same solve rate as bare Pi on 26% fewer prompt tokens and 23% less cost per attempt, and cost per attempt spans 1.7x across the three rows whose cached share could be measured. Score is the control here rather than the finding.
8
+ - Publish the run unfinished, and say so in the first paragraph. Terminal-Bench 2.0's hard split and a SWE-Gym slice have not run, plain SpecPi is not an arm so nothing separates the harness from the advisory layer, and Oh My Pi's widened slice holds six attempts per task rather than three because two launches of the same script raced into one output directory. Section 07 of the page lists each of these rather than leaving the reader to infer them from a table that looks complete.
9
+ - Publish Claude Code's cached share as absent rather than as zero. Its traffic crosses the Messages/chat-completions translation, which recorded no cached tokens at all on these runs, and a zero would have read as "Claude Code destroys cache efficiency" -- which the run does not show, since Pi and SpecPi see 94% on the same endpoint in the same sitting. Its cost is published as an upper bound for the same reason, because the cached share is what would discount it.
10
+ - Map Anthropic's cache tokens onto the pair Anthropic reports them as. `scripts/eval-anthropic.mjs` was passing the provider's prompt total through as `input_tokens` and emitting no `cache_read_input_tokens`, but Anthropic counts cache reads outside the input total rather than inside it, so the two have to split the prompt. This is the fault above, fixed; the rows it spoiled predate it and are labelled rather than silently re-priced, because the tokens are not recoverable from an archived aggregate.
11
+ - Count auxiliary calls as spend. `modelRequests()` answers how many turns an attempt took, and the token totals were reading the same filtered list, so the session-title call Claude Code makes on every attempt cost nothing in any report. Billing and conversation counting are separate questions now: totals and pricing cover the auxiliary calls, while the turn series and the per-turn figures stay conversation-only, because an auxiliary is not a turn.
12
+ - Fail a Responses reply the provider reported as failed. `scripts/eval-responses.mjs` inspected only `incomplete_details`, so an HTTP 200 stream carrying `response.failed` was assembled into an ordinary reply and returned with a `stop` finish reason -- a provider failure arriving at the harness as a short answer, which is the one shape that cannot be told from the harness giving up. The proxy returns 502 with the provider's own code and message instead. `max_output_tokens` stays a finish reason rather than a failure, because that turn happened and its text is real.
13
+ - Admit a row to the controlled-window comparison only when the window was enforced. `scripts/tier6-metrics.mjs` accepted any attempt that had compacted at all, which is evidence that the harness compacts and not that it compacted at the declared window: an 80,000-token attempt with one compaction was being compared against attempts held to 24,000. The bound is measured rather than picked -- at that window, enforced attempts peak at 1.58x it and provider-default attempts start at 1.89x -- and it excludes rather than admits when it is unsure.
14
+ - Let an eval run drop one part of the SpecPi layer. `SPECPI_EVAL_ABLATE` takes `agents`, `tools` and `goal`, combinable, so a run can strip the harness to Pi's exact tool surface and measure what that alone accounts for. It was built to test a specific claim of mine -- that SpecPi's four extra tools explained a collapse on one model -- and it refuted it, which is the use an ablation is for.
15
+ - Delete the retired suite's publishers rather than leaving them aimed at sections that no longer exist. `scripts/tb-metrics.mjs`, `scripts/tb-site.mjs` and `scripts/tier6-site.mjs` go with the three data files they wrote. `scripts/eval-site.mjs` keeps the part with other callers -- reading run reports, and the chart and table renderers the new page draws with -- and loses the page-specific rendering it no longer has a page for. `scripts/tier6-metrics.mjs` still runs, writing beside the runs it describes instead of into a published directory.
16
+
17
+ - Add Claude Code to the eval suite as `--harness=claude-code`. It is the first harness that speaks the Anthropic Messages API, and the provider the whole suite runs against is chat-completions only, so measuring it at all meant translating both directions. `scripts/eval-anthropic.mjs` does that and nothing else.
18
+ - Translate at the edge rather than throughout. The request becomes a chat-completions request before the proxy records anything, and the reply is converted back only on the way out, so `summarizeRequest`, `toolOutcomeOf`, `extractUsage`, `extractToolCalls` and the cost pipeline all keep reading the one shape they have always read. The alternative -- teaching each of them a second shape -- is six places to drift instead of one place to read.
19
+ - Rebuild the stream rather than piping it. `startProxy` already buffers the whole upstream reply before answering, so there is no streaming fidelity to lose: the OpenAI deltas are collected into one finished message and re-emitted as a well-formed Anthropic event sequence. `tests/eval-anthropic.test.mjs` covers the cases where a wrong conversion would be scored as the harness failing the task rather than as the suite losing its work -- several tool results in one turn, a failed tool result, fragmented tool arguments, and arguments that never parse.
20
+ - Read no Anthropic credential and spend none. Claude Code normally authenticates against a stored subscription login, so a run against a real configuration could fall back to it and bill a subscription for an eval. The row uses a disposable `CLAUDE_CONFIG_DIR` with no login to fall back on, points `ANTHROPIC_BASE_URL` at the logging proxy, and the proxy discards the client's token and sends `EVAL_FORWARD_KEY` upstream. Runs bill the same OpenCode Go subscription as every other harness; the probe confirms it, reporting `apiKeySource: none`.
21
+ - Answer the reachability probe instead of recording it. Claude Code sends `HEAD /api/hello` before it starts. It carries no body and names no model, so recording it added a turn with no tokens, and forwarding it posted an empty payload to the provider's completions endpoint and billed for the reply.
22
+ - Keep the session-title call out of the turn count. A Messages request carrying no tool schema is the harness titling its own session, which is the rule `conversationSummary` has always used to find the real first call; it is now tagged so its spend stays in the cost column while it stops inflating turns. Claude Code is the first proxy harness to make one -- OpenCode makes one too but reports its own per-step usage rather than going through this log. Uncorrected, a single-prompt attempt read as three turns instead of one.
23
+ - Stop losing a finished attempt to a directory that will not delete. On Windows a directory stays locked while any process holds it as a working directory, and a harness that leaves a helper alive for a moment after its own exit keeps the attempt's workspace locked with it. The attempt's result is already computed by then, so throwing discarded a real measurement to report a temporary file the operating system cleans up anyway. It now retries for longer, then says so and moves on.
24
+ - Note what this row cannot do. Claude Code exposes no context-window setting -- its compaction triggers off the model's own window -- so it cannot be held to a tier's declared window, and tier 6 reads its attempts as unwindowed the way it did for Codex and OpenCode before those learned to read one.
25
+ - Withdraw compaction guidance from the Jev layer. It was the layer's only system that had ever applied a verdict at scale, and the only measurement of it says it cost solve rate. Tier 6 ran the pair comparison twice, months apart on two versions: SpecPi 0.28.0 gave 5/8 against plain SpecPi's 8/8 on the long-session task, and 0.29.0 gave 3/8 against 6/8. Pooled that is 8/16 against 14/16, Fisher exact p = 0.054. The shorter task showed nothing either time. `extensions/jev-advisor/questions/compaction.mjs`, both hooks that used it, its gate thresholds and its budget are gone, and the managed set drops with them.
26
+ - Attribute the loss before removing the thing. Over those attempts `compaction` accounted for 139 of the 142 verdicts the layer applied; `retention` applied nothing across 287 calls and `progress` applied three. Whatever separated the two arms was almost entirely this one system's doing, which is what made a removal defensible on a comparison that never reached significance.
27
+ - Say plainly what this does not establish. p = 0.054 is not significance. A within-arm check of whether heavier compaction predicts failure reaches only p = 0.251. The mechanism was never isolated: the system could not delete anything -- it appended one sentence to the summariser's prompt, leaving Pi's own cut point and token budget alone -- so any harm ran through a summary that kept the wrong material, most likely because the sentence told the summariser to *prioritise* one kind of content on a task built so that a fact from the first turn must survive. What carried the decision was three weak signals agreeing and the asymmetry of the bet, not a result.
28
+ - Take the `/tree` branch labels with it. They rode the compaction switch by design, on the reasoning that someone who has decided the advisor may steer a summary has decided that once, and the same `decide()` that built the harmful sentence also fed the branch-summary path. Nothing measured the labels either way; they are removed because the machinery they shared is.
29
+ - Bump the settings schema to 5 and drop both `systems.compaction` and `budgets.compaction` on the way through, the way schema 4 dropped the guard keys. Nothing anyone had switched on is being switched off in any meaningful sense: the hooks those keys gated no longer exist, so a retained preference could only describe a system that cannot run. The panel in SpecPi Chat drops the row with it, and a test pins the panel's shape to the advisor's so the two cannot drift.
30
+ - Keep the runs that led here in the record rather than deleting them. The tier-6 tables carried the compaction row and both pair comparisons until this release retired that page; they remain in this repository's history, and the Jev page points at this changelog for the comparison and the p-values. A measurement that led to a removal is the reason the removal can be defended, so it outlives the page it was published on.
31
+ - Grade tier 1 and 2 answers by content rather than by the line endings they were written with. A file written through a Windows shell redirection ends its lines CRLF and one written through a file tool ends them LF; six checkers compared bytes, so they failed correct work for the route it took. Five of the six tier 1-2 failures in the published DeepSeek run were this, all on Codex, which published Codex at 30/37 and a 0.742 score against the 35/37 and 0.862 the regrade measures. Files a task says not to touch stay byte-exact, because rewriting one with different endings is still rewriting it.
32
+ - Supersede eval cells rather than pooling them. Each attempt's workspace is discarded, so a cell cannot be re-graded, only re-run; the site now keeps one cell per harness, task and tier and takes the last report that measured it. Without that rule the regrade above would have been averaged with the readings it corrects.
33
+ - Reach a provider on the wire it actually speaks. Muse Spark serves only the Responses API and returns 503 "Endpoint is unavailable" to a chat-completions request, as gpt-5.6-luna and grok-4.6 do; Codex already spoke Responses and reached it unaided, so the model was measurable on one row out of eight. `scripts/eval-responses.mjs` translates at the edge, the seam `eval-anthropic.mjs` already uses, and `EVAL_FORWARD_WIRE` declares the wire rather than probing for it, so latency is not doubled and the wire stays a property of the run. The report records which wire was used and the method says so, because seven rows having been translated is a material fact about how they were measured.
34
+ - Mint an OpenCode session only for the endpoint that routes on one. The gate was on forwarding rather than on the endpoint, so a run against any other provider spent an OpenCode call per attempt, billed its tokens to the run, and needed a login the run was not otherwise using, to produce an id the provider ignores. The published method claimed every forwarded run minted one, which was never true of a direct provider.
3
35
  ## 0.29.0 - 2026-09-20
4
36
 
5
37
  - Return the command guard to `specpi-jev-guard`, and leave it there. 0.28.0 built the guard into the layer as its eighth system, for three stated reasons that were properties of the package as it stood: a global configuration file with no session scope, a key read from the environment only, and a fail-closed posture. Two of the three are fixed upstream. Guard 0.3.0 ships `/jev-guard setup | on | off [--global] | check | model | backend`, so it has a session scope and a switch of its own, and it resolves Pi's saved login before the environment, so `/login openrouter` serves it exactly as it serves the advisor. What remains is fail-closed, which is a disclosed trade rather than a defect. `extensions/jev-advisor/risk.mjs` and `questions/guard.mjs` are gone, the package is pinned again, and the base is eight.
package/NPM_RELEASE.md CHANGED
@@ -6,7 +6,7 @@ Publishing, tags, deprecation, ownership changes and GitHub Releases require exp
6
6
 
7
7
  ## Prepare
8
8
 
9
- 1. This candidate is SpecPi **0.29.0**, paired with the Chat **0.14.0** VSIX. Browser QA **0.3.0** is already independently published; do not republish or edit its source as part of this release. Choose an unused version; update `package.json`, the dated `CHANGELOG.md` entry, README, and the website. Check Chat's version and VSIX download links. Preserve historical changelog entries.
9
+ 1. This candidate is SpecPi **0.30.0**, paired with the Chat **0.14.0** VSIX. Browser QA **0.3.0** is already independently published; do not republish or edit its source as part of this release. Choose an unused version; update `package.json`, the dated `CHANGELOG.md` entry, README, and the website. Check Chat's version and VSIX download links. Preserve historical changelog entries.
10
10
  2. Validate:
11
11
 
12
12
  ```sh
package/README.md CHANGED
@@ -73,36 +73,35 @@ The gap between the two SpecPi bars comes from a few separate switches, so the e
73
73
  ## Harness evaluations
74
74
 
75
75
  The chart above counts characters. It says nothing about what a harness costs to
76
- actually use, or whether it finishes the job. That is what the eval suite is for:
77
- the same tasks, the same model and the same frozen price list, with only the
78
- harness changing.
76
+ actually use, or whether it finishes the job. That is what the evals are for:
77
+ [Terminal-Bench 2.0](https://www.tbench.ai/), the same model and the same frozen
78
+ price list, with only the harness changing.
79
79
 
80
80
  <!-- eval-summary -->
81
81
 
82
- **259 attempts across 7 harnesses and 24 tasks**, all on `deepseek-v4.1-flash`.
82
+ **334 scored attempts across 20 tasks and 4 harnesses**,
83
+ all on `deepseek-v4.1-flash`.
83
84
 
84
- | Harness | Solved | Cost/attempt | Prompt tokens | Sent before any work |
85
- | --- | --- | --- | --- | --- |
86
- | Pi | 34/37 | $0.0038 | 112,681 | 5,522 |
87
- | SpecPi | 35/37 | $0.0042 | 134,859 | 13,799 |
88
- | OpenCode | 31/37 | $0.0043 | 143,830 | 38,097 |
89
- | SpecPi + Jev | 35/37 | $0.0046 | 153,077 | 13,799 |
90
- | Codex CLI | 30/37 | $0.0067 | 242,031 | 38,148 |
91
- | Oh My Pi | 35/37 | $0.0070 | 257,783 | 62,612 |
92
- | DeepSeek Harness | 35/37 | $0.0078 | 281,676 | 31,750 |
85
+ | Harness | Solved | Rate | Cost/attempt | Prompt tokens | Cache hit |
86
+ | --- | --- | --- | --- | --- | --- |
87
+ | SpecPi + Jev | 62/74 | 0.838 | $0.0125 | 381,153 | 94.5% |
88
+ | Pi (base) | 60/74 | 0.811 | $0.0161 | 512,903 | 93.7% |
89
+ | Oh My Pi | 90/113 | 0.796 | $0.0216 | 1,103,136 | 97.4% |
90
+ | Claude Code | 56/73 | 0.767 | $0.1128 or less | 630,410 | not measured |
93
91
 
94
- Cost is the harness's own model spend, priced from recorded usage against a
95
- dated price file. The last column is the tool schema plus system instructions
96
- riding every single request, which is the fixed toll a harness charges before
97
- the model does anything.
92
+ Solve rate does not separate these harnesses -- Pi against SpecPi + Jev is Fisher
93
+ p = 0.83 -- so the column worth reading is what each one spent reaching the same
94
+ answers. Cost is recomputed from recorded tokens against a dated price file, never
95
+ taken from a harness's self-report. Claude Code's cached share was not recorded on
96
+ this run, so its cost is an upper bound.
98
97
 
99
98
  <!-- /eval-summary -->
100
99
 
101
- Read the method, the charts and the caveats on the
102
- [evaluations page](https://tannermidd.github.io/SpecPi/evaluations/). The runner,
103
- the tasks and the recorded results are in [evals/](evals/), and this table is
104
- regenerated from those reports by `node scripts/eval-site.mjs`, so it cannot
105
- drift from the published figures.
100
+ This run is still in progress. Read the method, the charts and the caveats on the
101
+ [evaluations page](https://tannermidd.github.io/SpecPi/evaluations/), which lists
102
+ what is missing and what is known to be wrong. The table above is regenerated
103
+ from the run data by `node scripts/tb2-site.mjs`, so it cannot drift from the
104
+ published figures.
106
105
 
107
106
  ## Install
108
107
 
@@ -6,8 +6,8 @@
6
6
  // inside sanitize.
7
7
  //
8
8
  // AWAIT ONLY THE SYSTEMS THAT MUTATE WHAT THEY INSPECT. Retention must be awaited, because its
9
- // answer replaces the tool result it was asked about; so must compaction, the branch hook and the
10
- // two tool_call systems, which return a patch or edit `event.input` in place. A system that acts on
9
+ // answer replaces the tool result it was asked about; so must the two tool_call systems, which
10
+ // return a patch or edit `event.input` in place. A system that acts on
11
11
  // a later turn must not be awaited: at roughly 300ms a call, a turn-level system firing thirty
12
12
  // times would add nine seconds to an attempt that takes a hundred and thirty, to deliver advice
13
13
  // that was never going to change the turn it was asked during.
@@ -22,7 +22,6 @@ import { writeUsage } from "./usage.mjs";
22
22
 
23
23
  export const SYSTEM_LABELS = Object.freeze({
24
24
  retention: "Tool-result retention",
25
- compaction: "Compaction guidance",
26
25
  gap: "Capability-gap triage",
27
26
  sources: "Delegation source ranking",
28
27
  progress: "Progress and thrash detection",
@@ -18,15 +18,7 @@ import path from "node:path";
18
18
  import { randomUUID } from "node:crypto";
19
19
 
20
20
  /** Systems that may run inside a session. Offline scripts are not gated here. */
21
- export const SYSTEM_NAMES = Object.freeze([
22
- "retention",
23
- "compaction",
24
- "gap",
25
- "sources",
26
- "progress",
27
- "untrusted",
28
- "capability",
29
- ]);
21
+ export const SYSTEM_NAMES = Object.freeze(["retention", "gap", "sources", "progress", "untrusted", "capability"]);
30
22
 
31
23
  /**
32
24
  * What a confident stuck verdict is allowed to do. `notify` tells the person and cannot be wrong in
@@ -57,14 +49,14 @@ const MAX_TOTAL_BUDGET = 2048;
57
49
  * session, and which one won would be decided by event ordering rather than by anyone's policy.
58
50
  *
59
51
  * So the ceiling is two-level: each system gets its own, and the total is a real constraint because
60
- * it is deliberately less than their sum -- 2048 against 2322. That relationship is the invariant,
52
+ * it is deliberately less than their sum -- 2048 against 2274. That relationship is the invariant,
61
53
  * not either number: raising the total without raising the per-system ceilings would leave a total
62
54
  * no combination of systems could ever reach, which is a limit that reads as a limit and is not
63
55
  * one. `tests/jev-advisor.test.mjs` pins the inequality so a future change to one has to consider
64
56
  * the other. Running out of one system's budget stops that system and nothing else.
65
57
  *
66
58
  * The per-system numbers follow how often each one can fire: retention on every large read-only
67
- * result, compaction once or twice in a long session, gap per report, sources per delegation batch.
59
+ * result, gap per report, sources per delegation batch.
68
60
  */
69
61
  export const DEFAULT_BUDGETS = Object.freeze({
70
62
  // A backstop, not a working limit, and the number says which. Measured, a full tier-3 task -- a
@@ -92,7 +84,6 @@ export const DEFAULT_BUDGETS = Object.freeze({
92
84
  // add up to is not a constraint at all -- see below. They are scaled rather than re-derived:
93
85
  // each one's rationale is a firing frequency, and none of those frequencies changed.
94
86
  retention: 832,
95
- compaction: 48,
96
87
  gap: 192,
97
88
  sources: 128,
98
89
  // Turn-level, but gated behind local signals and a four-turn cooldown, so it only spends on
@@ -144,7 +135,7 @@ export function regularFile(file, label) {
144
135
  /** Every unknown shape collapses to the same all-off default rather than a partial enable. */
145
136
  export function defaultSettings() {
146
137
  return {
147
- schema: 4,
138
+ schema: 5,
148
139
  master: false,
149
140
  startup: false,
150
141
  systems: Object.fromEntries(SYSTEM_NAMES.map((name) => [name, false])),
@@ -206,6 +197,29 @@ function migrateToFour(raw) {
206
197
  return { ...rest, schema: 4, systems };
207
198
  }
208
199
 
200
+ /**
201
+ * Schema 5 removes compaction guidance, which was withdrawn rather than fixed.
202
+ *
203
+ * Two tier-6 runs, on SpecPi 0.28.0 and 0.29.0, both measured the arm carrying it solving fewer
204
+ * long-session tasks than plain SpecPi: 14/16 against 8/16 pooled, Fisher exact p = 0.054. Over the
205
+ * same attempts compaction was 55 of 56 applied verdicts, so the arm's behaviour was almost entirely
206
+ * this system's, and no other system in the layer applied enough to be a candidate.
207
+ *
208
+ * The evidence never reached significance and the mechanism was never isolated. The system was
209
+ * removed anyway, because a system that steers a summary has to earn the risk it takes, and one
210
+ * whose only measurement says it costs solve rate has not.
211
+ *
212
+ * Both the `systems.compaction` switch and the per-system `budgets.compaction` ceiling go. Dropping
213
+ * them turns nothing off that a user had on in any meaningful sense: the hooks they gated no longer
214
+ * exist, so a retained preference could only describe a system that cannot run.
215
+ */
216
+ function migrateToFive(raw) {
217
+ const { compaction: _system, ...systems } = raw?.systems ?? {};
218
+ const { compaction: _budget, ...budgets } = raw?.budgets ?? {};
219
+
220
+ return { ...raw, schema: 5, systems, budgets };
221
+ }
222
+
209
223
  /**
210
224
  * What the advisor will read, given a settings object, without writing it anywhere.
211
225
  *
@@ -222,15 +236,16 @@ export function normalizeSettings(raw) {
222
236
 
223
237
  function normalize(raw) {
224
238
  const one = raw?.schema === 1 ? migrate(raw) : raw;
225
- const source = one?.schema === 2 || one?.schema === 3 ? migrateToFour(one) : one;
226
- if (source?.schema !== 4) {
239
+ const four = one?.schema === 2 || one?.schema === 3 ? migrateToFour(one) : one;
240
+ const source = four?.schema === 4 ? migrateToFive(four) : four;
241
+ if (source?.schema !== 5) {
227
242
  return defaultSettings();
228
243
  }
229
244
 
230
245
  const systems = Object.fromEntries(SYSTEM_NAMES.map((name) => [name, source.systems?.[name] === true]));
231
246
 
232
247
  return {
233
- schema: 4,
248
+ schema: 5,
234
249
  master: source.master === true,
235
250
  startup: source.startup === true,
236
251
  systems,
@@ -268,7 +283,7 @@ export function writeFileAtomic(file, contents) {
268
283
  export function saveSettings(settings) {
269
284
  // A caller handing back an older shape is migrated rather than reset, so a round trip through
270
285
  // an old reader cannot quietly disable the layer.
271
- const next = normalize([1, 2, 3].includes(settings?.schema) ? settings : { ...settings, schema: 4 });
286
+ const next = normalize([1, 2, 3, 4].includes(settings?.schema) ? settings : { ...settings, schema: 5 });
272
287
  const file = settingsFile();
273
288
  if (fs.existsSync(file)) {
274
289
  regularFile(file, "Jev settings");
@@ -50,12 +50,11 @@
50
50
  // was unreachable, and running the layer could never have revealed it, because a system that
51
51
  // never fires looks exactly like a system whose advice was always to do nothing.
52
52
  //
53
- // compaction's `unresolved_thread` had the same problem at high 0.85: the clearest open
54
- // investigation the fixture can express scores 0.63-0.65. It is lowered to 0.60, which is
55
- // defensible only because of what that branch does -- add one sentence to a summariser prompt
56
- // that is being rebuilt from scratch anyway. It is the cheapest action in the layer, so it can
57
- // afford the loosest gate. gap keeps 0.85 because its Noul reaches 0.96 on the case that matters
58
- // and because a firing there blocks a write.
53
+ // Compaction guidance had the same problem and was lowered to 0.60 to clear it. That system has
54
+ // since been withdrawn -- two tier-6 runs measured the arm carrying it solving fewer long-session
55
+ // tasks than plain SpecPi -- so the loosest gate in the layer is gone with it. gap keeps 0.85
56
+ // because its Noul reaches 0.96 on the case that matters and because a firing there blocks a
57
+ // write.
59
58
 
60
59
  /**
61
60
  * The figures the comment above cites, in a form a test can check against the artifact. A citation
@@ -91,16 +90,6 @@ export const THRESHOLDS = Object.freeze({
91
90
  // Measured 0.06-0.07 on the spent case, so this clears with room.
92
91
  low: 0.1,
93
92
  }),
94
- compaction: Object.freeze({
95
- scoreConfidence: 0.6,
96
- boundary: 0.3,
97
- choiceConfidence: 0.75,
98
- margin: 0.25,
99
- // Lowered from 0.85: the clearest open investigation scores 0.63-0.65, and the action is
100
- // one sentence added to a prompt that is being rebuilt regardless.
101
- high: 0.6,
102
- low: 0.15,
103
- }),
104
93
  gap: Object.freeze({
105
94
  scoreConfidence: 0.6,
106
95
  boundary: 0.3,
@@ -8,7 +8,6 @@ import { createBroker } from "./broker.mjs";
8
8
  import { ledgerPath, read as readLedger } from "./ledger.mjs";
9
9
  import { usagePath } from "./usage.mjs";
10
10
  import * as retention from "./questions/retention.mjs";
11
- import * as compaction from "./questions/compaction.mjs";
12
11
  import * as gap from "./questions/gap.mjs";
13
12
  import * as sources from "./questions/sources.mjs";
14
13
  import * as progress from "./questions/progress.mjs";
@@ -334,115 +333,6 @@ export default function jevAdvisor(pi: ExtensionAPI) {
334
333
  }
335
334
  });
336
335
 
337
- // System 1b: steer the summary at the one boundary where the prompt cache is discarded anyway.
338
- // Only customInstructions is supplied; the preparation's own cut and budget are left alone.
339
- pi.on("session_before_compact", async (event: any, ctx: ExtensionContext) => {
340
- if (!enabled("compaction")) {
341
- return;
342
- }
343
-
344
- try {
345
- const result = await broker.request({
346
- system: "compaction",
347
- state: compaction.buildInput({ preparation: event.preparation, objective }),
348
- questions: compaction.questions(),
349
- ctx,
350
- root: ctx.cwd,
351
- signal: event.signal,
352
- decide: (answers: any) => {
353
- const built = compaction.decide(answers);
354
-
355
- // Nothing is shortened here, so savedBytes stays 0 and `applied` is the whole
356
- // record: either a sentence reached the summariser or Pi's own prompt ran.
357
- return { applied: Boolean(built.customInstructions), decision: built };
358
- },
359
- });
360
- if (!result.ok) {
361
- return;
362
- }
363
-
364
- const advice = result.decision;
365
- if (!advice.customInstructions) {
366
- return;
367
- }
368
-
369
- const existing = typeof event.customInstructions === "string" ? event.customInstructions.trim() : "";
370
-
371
- return {
372
- customInstructions: existing
373
- ? `${existing}\n\n${advice.customInstructions}`
374
- : advice.customInstructions,
375
- };
376
- } catch {
377
- return;
378
- }
379
- });
380
-
381
- // System 1b, second hook. Branch summarisation is the same problem at the same boundary --
382
- // something is about to be reduced to a summary and the prefix is being rebuilt regardless --
383
- // and it was simply unserved. It shares the compaction switch rather than adding a fifth
384
- // system, because a user who has decided the advisor may steer a summary has decided that once.
385
- //
386
- // `label` is the part worth having. Pi's `/tree` can filter to labelled entries, so a branch
387
- // that says what it was is the difference between a navigable tree and a list of timestamps,
388
- // and the enum is fixed so no model-written text reaches the session file.
389
- pi.on("session_before_tree", async (event: any, ctx: ExtensionContext) => {
390
- if (!enabled("compaction")) {
391
- return;
392
- }
393
-
394
- try {
395
- const entries = event?.preparation?.entriesToSummarize ?? [];
396
- if (entries.length === 0) {
397
- return;
398
- }
399
-
400
- const result = await broker.request({
401
- system: "compaction",
402
- state: compaction.buildBranchInput({ preparation: event.preparation, objective }),
403
- questions: compaction.questions({ branch: true }),
404
- ctx,
405
- root: ctx.cwd,
406
- signal: event.signal,
407
- decide: (answers: any) => {
408
- const built = compaction.decide(answers);
409
- const branchLabel = compaction.label(answers);
410
-
411
- return {
412
- applied: Boolean(branchLabel || built.customInstructions),
413
- decision: { ...built, label: branchLabel },
414
- };
415
- },
416
- });
417
- if (!result.ok) {
418
- return;
419
- }
420
-
421
- const advice = result.decision;
422
- const patch: Record<string, unknown> = {};
423
- if (advice.label) {
424
- patch.label = advice.label;
425
- }
426
-
427
- // Only when a summary is actually going to be generated. Instructions for a summariser
428
- // that will not run are bytes nobody reads, and `replaceInstructions` is left alone so
429
- // Pi's own branch prompt still frames the result.
430
- if (advice.customInstructions && event.preparation?.userWantsSummary === true) {
431
- const existing =
432
- typeof event.preparation?.customInstructions === "string"
433
- ? event.preparation.customInstructions.trim()
434
- : "";
435
- patch.customInstructions = existing
436
- ? `${existing}\n\n${advice.customInstructions}`
437
- : advice.customInstructions;
438
- }
439
-
440
- return Object.keys(patch).length > 0 ? patch : undefined;
441
- } catch {
442
- return;
443
- }
444
- });
445
-
446
336
  pi.on("tool_call", async (event: any, ctx: ExtensionContext) => {
447
337
  history.signatures.push(progress.signature(event.toolName, event.input));
448
338
  history.tools.push(event.toolName);
@@ -568,7 +458,7 @@ export default function jevAdvisor(pi: ExtensionAPI) {
568
458
  // System 5: notice a session that has stopped making progress, while it can still be helped.
569
459
  //
570
460
  // THE ONE HANDLER THAT IS NOT AWAITED. Everything else in this file mutates what it inspects --
571
- // a tool result, a compaction patch, a tool's input -- so the session has to wait for the
461
+ // a tool result, a tool's input -- so the session has to wait for the
572
462
  // answer. This one acts on the next turn, and at roughly 300ms a call, awaiting it on a
573
463
  // thrashing session would add seconds to an attempt to deliver advice that could not have
574
464
  // changed the turn it was asked during.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "specpi",
3
- "version": "0.29.0",
3
+ "version": "0.30.0",
4
4
  "description": "Scope control and a human-selected harness improvement loop for Pi",
5
5
  "author": "Tanner Middleton",
6
6
  "repository": {
@@ -60,7 +60,6 @@ const resourcePaths = [
60
60
  "extensions/jev-advisor/broker.mjs",
61
61
  "extensions/jev-advisor/gate.mjs",
62
62
  "extensions/jev-advisor/questions/retention.mjs",
63
- "extensions/jev-advisor/questions/compaction.mjs",
64
63
  "extensions/jev-advisor/questions/gap.mjs",
65
64
  "extensions/jev-advisor/questions/sources.mjs",
66
65
  "extensions/jev-advisor/questions/progress.mjs",
@@ -1,153 +0,0 @@
1
- // System 1b: guide compaction, which is the one moment the prompt cache is discarded anyway.
2
- //
3
- // Pi's `findCutPoint` is documented as "walk backwards from newest, accumulating estimated message
4
- // sizes, stop when we've accumulated >= keepRecentTokens". It is a token ruler: it cannot tell the
5
- // load-bearing finding from six dead-end greps, and it discards whichever falls on the wrong side
6
- // of the line. Since the prefix is being rebuilt regardless, improving that choice costs nothing.
7
- //
8
- // This system never sets the cut itself. It supplies `customInstructions` — an existing documented
9
- // parameter on the compaction path — so the summariser is told what this session was actually
10
- // about. The token budget still bounds the result, so bad advice can shape a summary, never blow
11
- // the budget or drop an entry the preparation meant to keep.
12
-
13
- import { choice, noul } from "../client.mjs";
14
- import { choiceValue, nounTrue } from "../gate.mjs";
15
- import { compact } from "../sanitize.mjs";
16
-
17
- export const WORK_KINDS = Object.freeze({
18
- debugging: "Tracking down why something fails",
19
- building: "Adding or changing a feature",
20
- refactoring: "Restructuring code without changing behaviour",
21
- research: "Reading and answering questions about a codebase",
22
- testing: "Writing or repairing tests",
23
- ops: "Builds, releases, configuration or tooling",
24
- review: "Reading a diff and judging it",
25
- });
26
-
27
- /**
28
- * A digest of what compaction is about to discard: entry kinds and scale, never their text. The
29
- * summariser still sees the real conversation; this only steers what it keeps.
30
- */
31
- export function buildInput({ preparation, objective }) {
32
- const messages = preparation?.messagesToSummarize ?? [];
33
- const kinds = {};
34
- for (const message of messages) {
35
- const role = typeof message?.role === "string" ? message.role : "unknown";
36
- kinds[role] = (kinds[role] ?? 0) + 1;
37
- }
38
-
39
- const files = preparation?.fileOps ?? {};
40
-
41
- return {
42
- objective: compact(objective ?? "", 180),
43
- discarding: messages.length,
44
- roles: kinds,
45
- tokensBefore: preparation?.tokensBefore ?? 0,
46
- splitTurn: preparation?.isSplitTurn === true,
47
- filesRead: [...(files.read ?? [])].slice(0, 12).map((item) => compact(item, 60)),
48
- filesWritten: [...(files.written ?? []), ...(files.edited ?? [])].slice(0, 12).map((item) => compact(item, 60)),
49
- hadPreviousSummary: typeof preparation?.previousSummary === "string",
50
- };
51
- }
52
-
53
- /**
54
- * The same digest for a branch being left behind. `/tree` hands a different preparation shape --
55
- * session entries rather than messages, and no token count, because nothing is being cut to fit a
56
- * budget -- so it gets its own builder rather than a compaction input with three fields quietly
57
- * reading undefined.
58
- */
59
- export function buildBranchInput({ preparation, objective }) {
60
- const entries = preparation?.entriesToSummarize ?? [];
61
- const kinds = {};
62
- for (const entry of entries) {
63
- const kind = typeof entry?.type === "string" ? entry.type : "unknown";
64
- kinds[kind] = (kinds[kind] ?? 0) + 1;
65
- }
66
-
67
- return {
68
- objective: compact(objective ?? "", 180),
69
- abandoning: entries.length,
70
- kinds,
71
- wantsSummary: preparation?.userWantsSummary === true,
72
- // Navigating to an ancestor is backing out of a line of work; navigating elsewhere is
73
- // moving between siblings. The distinction is most of what a label has to capture.
74
- toAncestor: preparation?.targetId === preparation?.commonAncestorId,
75
- };
76
- }
77
-
78
- /**
79
- * Short, navigational, and a fixed enum so no model-written text reaches the session file. `/tree`
80
- * can filter to labelled entries, so a branch that says what it was is the difference between a
81
- * navigable tree and a list of timestamps.
82
- */
83
- export const BRANCH_LABELS = Object.freeze({
84
- "dead end": "The branch was abandoned because the approach did not work",
85
- "alternative tried": "A different approach to the same goal, set aside for another",
86
- "work completed": "The branch finished what it set out to do",
87
- research: "The branch was reading and answering questions, not changing anything",
88
- reverted: "The branch's changes were undone",
89
- interrupted: "The branch stopped part-way for an unrelated reason",
90
- });
91
-
92
- export function questions({ branch = false } = {}) {
93
- return {
94
- ...(branch ? { branch_label: choice("What was this abandoned branch?", BRANCH_LABELS) } : {}),
95
- work_kind: choice("What kind of work has this session mostly been doing?", WORK_KINDS),
96
- unresolved_thread: noul("There is an unfinished investigation whose findings must survive compaction"),
97
- discarded_span_was_dead_ends: noul(
98
- "The work being discarded was mostly abandoned attempts that led nowhere useful",
99
- ),
100
- };
101
- }
102
-
103
- const FOCUS = Object.freeze({
104
- debugging: "the symptom, what has been ruled out, and the current hypothesis",
105
- building: "what has been implemented so far and what remains",
106
- refactoring: "the invariants being preserved and which call sites have been updated",
107
- research: "the questions answered so far, with the files each answer came from",
108
- testing: "which tests exist, which fail, and why",
109
- ops: "the commands run, their outcomes, and the current configuration state",
110
- review: "the findings raised so far and their severity",
111
- });
112
-
113
- /**
114
- * Build `customInstructions` from gated answers only. With nothing gated this returns undefined and
115
- * Pi's own default prompt is used unchanged.
116
- */
117
- /**
118
- * The label for a branch summary entry, or undefined when the answer is ungated. Separate from
119
- * `decide` because the compaction hook has no label to set and would carry a dead field.
120
- */
121
- export function label(answers) {
122
- const value = choiceValue(answers?.branch_label, "compaction");
123
-
124
- return value && Object.hasOwn(BRANCH_LABELS, value) ? value : undefined;
125
- }
126
-
127
- export function decide(answers) {
128
- const kind = choiceValue(answers?.work_kind, "compaction");
129
- const unresolved = nounTrue(answers?.unresolved_thread, "compaction");
130
- const deadEnds = nounTrue(answers?.discarded_span_was_dead_ends, "compaction");
131
- const parts = [];
132
- if (kind && FOCUS[kind]) {
133
- parts.push(`This session has mainly been ${kind}. Prioritise ${FOCUS[kind]}.`);
134
- }
135
-
136
- if (unresolved) {
137
- parts.push(
138
- "An investigation is still open. Preserve its findings and the current hypothesis in full, even at the cost of earlier detail.",
139
- );
140
- }
141
-
142
- if (deadEnds) {
143
- parts.push(
144
- "Most of the discarded work was abandoned attempts. Record what was ruled out in one line each rather than recounting them, so the same paths are not retried.",
145
- );
146
- }
147
-
148
- if (parts.length === 0) {
149
- return { customInstructions: undefined, deadEnds, unresolved };
150
- }
151
-
152
- return { customInstructions: parts.join(" "), deadEnds, unresolved };
153
- }