pi-self-evolve 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ralph-abejuela
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,171 @@
1
+ # Self-Evolve Pi Extension
2
+
3
+ **pi-self-evolve** is a context-engineering harness for the [pi coding agent](https://pi.dev). It shrinks the agent's context window on long agentic loops — measured ~50% context reduction with no solved-rate loss (A/B, n=3, twice; plus a Terminal-Bench 2.1 run). Open source, MIT, one-command install: `pi install npm:pi-self-evolve`.
4
+
5
+ This is a pi extension. It keeps agent context small on long agent loops. It uses five mechanisms:
6
+
7
+ - **Evidence reducer** (shell outputs). It filters shell output. It keeps FAIL, ERROR, and summary lines. Each kept quote is checked word-for-word against the saved log. If the check fails, the original text stays.
8
+ - **ObservationPack** (big tool outputs). It saves outputs over ~10 KiB to disk. It puts a short head/tail excerpt in context. The model recalls the full text later.
9
+ - **Context compact** (full window). When the window is full, it replaces old tool results with one-line markers.
10
+ - **Action fusion** (unverified edits). After an edit without verification, it adds one bounded ask at the turn boundary: run the verify command.
11
+ - **Telemetry**. It writes per-run counters to a JSONL file: provider usage, estimated raw tokens, reduction ratio, mechanism counters.
12
+
13
+ ## Install
14
+
15
+ From npm (recommended):
16
+
17
+ ```
18
+ pi install npm:pi-self-evolve
19
+ ```
20
+
21
+ Or from git:
22
+
23
+ ```
24
+ pi install git:github.com/ralph-abejuela/self-evolve-pi
25
+ ```
26
+
27
+ Or try it for one session without installing:
28
+
29
+ ```
30
+ pi -e npm:pi-self-evolve
31
+ ```
32
+
33
+ Or load a local checkout directly:
34
+
35
+ ```
36
+ pi -e /path/to/self-evolve-pi/self-evolve/index.ts
37
+ ```
38
+
39
+ ## Usage
40
+
41
+ - `/evolve` — show mechanism status and counters
42
+ - `/evolve pack off` — toggle a mechanism (`pack`, `reducer`, `compact`, `fusion`)
43
+ - `/evolve reset` — reset counters
44
+ - Model tools: `harness_recall` (paged archive reads), `harness_commit` / `harness_track` / `harness_note` / `harness_state` (BPE slots)
45
+ - State lives in `.self-evolve/`. It is git-ignored.
46
+
47
+ ## Extension conflicts (tested against a real install)
48
+
49
+ This was tested against a pi environment with 15 packages. Two clusters collide. Skills do not collide (prompt-level only, no shared namespace).
50
+
51
+ **1. Output-compaction extensions (RTK, https://github.com/rtk-ai/rtk) — real collision. Do not double-wrap.**
52
+ Both extensions intercept the same `tool_result` events for the same tools (`pwsh`/`bash`). Three failure modes when both are on:
53
+ - *Double truncation.* RTK truncates at its `maxChars` limit. If RTK runs after this extension, it can clip the `se://N` handle block. The result is a pointer to nothing.
54
+ - *Bad evidence.* The reducer builds receipts from shell output. If RTK truncated that output first, the receipt silently records lossy text as evidence.
55
+ - *Load order decides.* Package-list order decides which extension wins. The loser's work is discarded without a warning.
56
+
57
+ Rule: give the shell-output layer to one owner. Either set RTK `outputCompaction.enabled: false` (this extension is lossless: archive + recall, not truncation), or set `ARCHIVED_TOOLS` here to exclude the tools RTK owns.
58
+
59
+ **2. Prompt-stack budget.** `APPEND_SYSTEM.md` + two memory recall contexts + this extension's ~80-token guideline sit on the system side. Measure any new injection against the ~23.4k global-extension floor.
60
+
61
+ ## Measured results (n=3 per cell, median context tokens = provider input + cacheRead, independent A/B)
62
+
63
+ The baseline is default `pi -ns` (global extensions loaded). This is the same floor the extension run starts from. Both arms are measured identically from their own session files. The extension-side estimate (`rawEst`) is not the headline metric. Provider-reported usage is.
64
+
65
+ ### The 5 adversarial tests from harness-test.txt (single-shot)
66
+
67
+ | Test | Baseline ctx | Extension ctx | Δ | Correct |
68
+ |---|---|---|---|---|
69
+ | test1-nuance | 47,481 | 48,545 | −2.2% | base 2/3, ext 3/3 |
70
+ | test2-needle | 84,296 | 86,075 | −2.1% | 3/3 both |
71
+ | test3-json | 47,338 | 48,402 | −2.2% | base 1/3, ext 2/3 |
72
+ | test4-contradiction | 47,377 | 48,441 | −2.2% | base 0/3, ext 1/3 |
73
+ | test5-code | 101,857 | 101,280 | +0.6% | 3/3 both |
74
+ | **Total** | **328,349** | **332,743** | **−1.3%** | base 10/15, ext 12/15 |
75
+
76
+ An independent second n=3 suite replicated the pattern: totals 348,583 vs 332,364 (+4.7% in the extension's favor), correctness 10/15 vs 13/15, triage 344,781 vs 175,072 (−49.2%).
77
+
78
+ Read this table this way: single-shot tasks sit within ±5% of parity. The small delta is fixed overhead (tool schema + guideline). The correctness gaps are inside run-to-run noise for this model. Single-shot QA is not where this extension shows gains.
79
+
80
+ ### The agentic scenario (run a noisy 20 KB log emitter, fix two failing specs, verify)
81
+
82
+ | | Baseline | Extension | Reduction |
83
+ |---|---|---|---|
84
+ | median context (suite 1 / suite 2) | 363,751 / 344,781 | 178,063 / 175,072 | **51.0% / 49.2%** |
85
+ | solved | 3/3 both suites | 3/3 both suites | parity |
86
+
87
+ This is the target regime: long agent loops that re-bill the same large noisy tool output every turn. Here the extension cut context in half at n=3, twice, with no solved-rate loss.
88
+
89
+ ### Terminal-Bench 2.1 via Harbor (containerized, verifiable)
90
+
91
+ The same A/B ran through [Harbor](https://github.com/harbor-framework/harbor) on the official `terminal-bench/terminal-bench-2-1` dataset. First 10 tasks. Model `deepseek-v4.1-flash` via opencode-go. n=1 per task. Both arms run in identical task containers, so no environment asymmetry is possible. Adapter: `eval/harbor/pi_harbor_agent.py` (it subclasses Harbor's built-in `pi` agent, uploads the extension, and appends `--extension` in the treatment arm).
92
+
93
+ | Task | Base | Ext | Base input tok | Ext input tok |
94
+ |---|---|---|---|---|
95
+ | torch-pipeline-parallelism | 0.0 | **1.0** | 1,995,491 | **567,010 (−72%)** |
96
+ | regex-chess | hung (no result) | **1.0** | — | 6,431,132 |
97
+ | kv-store-grpc | 1.0 | 1.0 | 59,354 | **39,639 (−33%)** |
98
+ | dna-assembly | 0.0 | 0.0 | 1,854,678 | 1,331,504 (−28%) |
99
+ | openssl-selfsigned-cert | 1.0 | 1.0 | 30,979 | 48,449 |
100
+ | pypi-server | 1.0 | 1.0 | 60,100 | 128,161 |
101
+ | write-compressor | 1.0 | 1.0 | 395,176 | 529,745 |
102
+ | torch-tensor-parallelism | 1.0 | 1.0 | 283,885 | 509,034 |
103
+ | schemelike-metacircular-eval | 1.0 | 1.0 | 2,126,629 | 7,118,747 (outlier) |
104
+ | qemu-alpine-ssh | exception (both arms — infra) | | | |
105
+
106
+ Full job results (every trial's trajectory, logs, verifier output) are public on Harbor Hub:
107
+
108
+ - Baseline arm: https://hub.harborframework.com/jobs/b0a875eb-4b90-4acd-a27c-9b6e1dca83d8
109
+ - Extension arm: https://hub.harborframework.com/jobs/cfa8a1d8-6f8b-44d0-a371-0172225fb9ec
110
+
111
+ ## Research papers
112
+ Mechanism by mechanism:
113
+
114
+ | Mechanism here | Taken from | Paper |
115
+ | --- | --- | --- |
116
+ | Evidence reducer (`reducer.ts`) | Evidence-preserving reduction: compress logs into a receipt, validate every quoted line word-for-word against the archived source, keep the original on failure | SoL-Pi [^1] |
117
+ | ObservationPack (`pack.ts`) + `harness_recall` | ObservationPack: outputs over ~10 KiB move to a disk archive, context keeps a head/tail excerpt plus a handle, paged recall brings slices back | SoL-Pi [^1] |
118
+ | Context compact (`bpe.ts` commit under pressure) | Online context compact: completed subtasks marked for compaction, window-pressure and cost checks, summarize and reset the active window | SoL-Pi [^1] |
119
+ | Action fusion (`fusion.ts`) | Action fusion: bundle an edit with its verification step into one turn boundary instead of a second model request | SoL-Pi [^1] |
120
+ | BPE cognitive tools (`harness_commit` / `harness_track` / `harness_note` / `harness_state`) | Belief, Progress, Experience slots worked through four explicit cognitive actions: commit, track, recall, note | EvoHarness-RL [^2] |
121
+ | L1 active window / L3 disk archive split (`store.ts`, `.self-evolve/l3/`) | State-tier management: keep the working set in the live window, push the bulk to a durable lower tier | Prime Agent [^3] |
122
+
123
+ [^1]: SoL-Pi: Recursively Scaling Auto-Research Loops for Efficient Agent Harness — NVIDIA. https://arxiv.org/abs/2609.20519 · code: https://github.com/NVlabs/SoL-Pi
124
+ [^2]: EvoHarness-RL: Learning Self-Evolving Runtime Harness Policies — Meta. https://arxiv.org/abs/2608.05446 · OpenReview PDF: https://openreview.net/pdf?id=lFlnP9ZJHl
125
+ [^3]: Prime Agent: A Self-Improving RLM Harness — Prime Intellect. https://arxiv.org/abs/2608.23552 · blog: https://www.primeintellect.ai/blog/prime-agent
126
+
127
+ Related systems from the same survey, background for the deferred work (harness annealing and distillation are out of scope for this extension):
128
+
129
+ - SafeEvolve: Harness-Policy Co-Evolution from Agent Experience for Safety Alignment — https://arxiv.org/abs/2609.02786
130
+ - Beyond Static Harnesses for Long-Horizon Coding Agents (openJiuwen) — https://arxiv.org/abs/2608.27969
131
+ - Harness-Zero: Harness Distillation via Agent-as-Harness — https://arxiv.org/abs/2609.24974
132
+ - GLM Infra Agent (Z.ai): recursive self-improvement on its own serving infrastructure — https://z.ai/blog/glm-built-its-inference-infrastructure
133
+
134
+ What is re-implemented here vs the papers: this extension re-builds SoL-Pi's four mechanisms, EvoHarness-RL's BPE tool interface, and Prime Agent's tiered archive as a single pi extension, and measures them with its own comparison harness instead of EdgeBench or ALFWorld. The training-side ideas (SFT + GRPO from EvoHarness-RL, LoRA distillation from Harness-Zero) stay out of scope.
135
+
136
+ ## Potential edge (what the results suggest — not proven at this sample size)
137
+
138
+ Three possible gains. Read them as suggestions at n=1 per task.
139
+
140
+ **1. No harm.** The extension did not lower any score. On the 9 shared tasks: 7 scores matched, 1 got better, 0 got worse. The shared failure (dna-assembly) and the infra exception (qemu) were identical in both arms. This is the floor: the extension is safe to load.
141
+
142
+ **2. Fewer tokens on long tasks.** 7 of 8 shared scored tasks used fewer input tokens. One outlier run (schemelike, 7.1M tokens) hid this in the totals. Without it, the extension used −32.6% on the remaining seven. The strongest cell: torch-pipeline. Baseline failed with 2.0M tokens. Extension passed with 0.6M tokens (−72%).
143
+
144
+ **3. Reach on hard tasks.** regex-chess was the only task the baseline could not finish (it hung). The extension solved it. The extension-only solve on the hardest task is the capability signal.
145
+
146
+ Limits, stated plainly:
147
+ - n=1 per task. One run can flip by chance. The torch-pipeline flip and the regex-chess solve each have one data point.
148
+ - One outlier run dominated the token totals. Median tokens per task are needed before a token claim.
149
+ - The robust claim today is the no-harm floor plus the ~50% context reduction on agentic loops (measured at n=3, twice). The capability edge and the token savings are candidate gains. They need more runs.
150
+
151
+ ## Reproduce
152
+
153
+ Adversarial + agentic comparison: `node run-compare.mjs` (env `N_RUNS` to change cell size; results in `results/compare.json`).
154
+
155
+ Harbor A/B:
156
+
157
+ ```bash
158
+ export OPENCODE_GO_API_KEY=... # or any provider pi can authenticate
159
+ export PI_HARBOR_AUTH_B64=$(base64 -w0 ~/.pi/agent/auth.json) # container-side auth seed
160
+ export PI_HARBOR_MODELS_B64=$(base64 -w0 ~/.pi/agent/models.json)
161
+ export PI_HARBOR_EXT_DIR=/path/to/self-evolve-pi/self-evolve
162
+ export PYTHONPATH=/path/to/self-evolve-pi/eval/harbor
163
+
164
+ # baseline arm
165
+ harbor run -d terminal-bench/terminal-bench-2-1 \
166
+ -a pi_harbor_agent:PiExtAgent -m opencode-go/deepseek-v4.1-flash \
167
+ -l 10 -n 3 --timeout-multiplier 3 -y -o jobs-baseline
168
+
169
+ # extension arm (same command; only the variant env changes)
170
+ PI_HARBOR_VARIANT=extension harbor run ... -o jobs-ext
171
+ ```
package/package.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "pi-self-evolve",
3
+ "version": "0.1.0",
4
+ "description": "Self-evolving context harness for the pi coding agent. Cuts agent context ~50% on long agentic loops: evidence reducer with verbatim validation, ObservationPack disk archive with recall, online compaction, action fusion, telemetry.",
5
+ "license": "MIT",
6
+ "author": "ralph-abejuela",
7
+ "keywords": [
8
+ "pi-package",
9
+ "pi",
10
+ "coding-agent",
11
+ "context-engineering",
12
+ "agent",
13
+ "tokens",
14
+ "harness"
15
+ ],
16
+ "files": [
17
+ "self-evolve",
18
+ "README.md",
19
+ "LICENSE"
20
+ ],
21
+ "pi": {
22
+ "extensions": ["./self-evolve/index.ts"]
23
+ },
24
+ "peerDependencies": {
25
+ "@earendil-works/pi-coding-agent": "*",
26
+ "typebox": "*"
27
+ }
28
+ }
@@ -0,0 +1,198 @@
1
+ /** BPE slots (Belief, Progress, Experience) + online context compact.
2
+ *
3
+ * Cognitive tools write structured state OUTSIDE the context window
4
+ * (durable session entries). The compact pass runs on the `context` event:
5
+ * under window pressure, old toolResult spans are archived to L3 and
6
+ * replaced by a one-line marker. Originals are never destroyed.
7
+ */
8
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
9
+ import { Type } from "typebox";
10
+ import type { HarnessConfig } from "./config.js";
11
+ import { tok } from "./config.js";
12
+ import type { L3Store } from "./store.js";
13
+ import { textOf, type Counters } from "./pack.js";
14
+
15
+ export interface BpeState {
16
+ belief: Record<string, string>;
17
+ progress: string[];
18
+ experience: string[];
19
+ }
20
+
21
+ const ENTRY_TYPE = "self-evolve-bpe";
22
+ /** newest tool results always kept raw, whatever the pressure */
23
+ const KEEP_RECENT = 3;
24
+
25
+ export function registerBpe(
26
+ pi: ExtensionAPI,
27
+ getCfg: () => HarnessConfig,
28
+ store: L3Store,
29
+ counters: Counters,
30
+ ): void {
31
+ let state: BpeState = { belief: {}, progress: [], experience: [] };
32
+
33
+ const persist = () => pi.appendEntry(ENTRY_TYPE, state);
34
+
35
+ const restore = (ctx: ExtensionContext) => {
36
+ try {
37
+ for (const entry of ctx.sessionManager.getBranch()) {
38
+ if (entry.type === "custom" && entry.customType === ENTRY_TYPE) {
39
+ const data = entry.data as BpeState | undefined;
40
+ if (data && typeof data === "object") state = data;
41
+ }
42
+ }
43
+ } catch {
44
+ // fresh session with no entries yet
45
+ }
46
+ };
47
+
48
+ pi.on("session_start", async (_event, ctx) => {
49
+ restore(ctx);
50
+ return undefined;
51
+ });
52
+
53
+ const summarize = (): string => {
54
+ const parts: string[] = [];
55
+ if (Object.keys(state.belief).length) {
56
+ parts.push(`belief: ${Object.entries(state.belief).map(([k, v]) => `${k}=${v}`).join("; ")}`);
57
+ }
58
+ if (state.progress.length) parts.push(`progress: ${state.progress.slice(-5).join(" -> ")}`);
59
+ if (state.experience.length) parts.push(`experience: ${state.experience.slice(-3).join(" | ")}`);
60
+ return parts.join("\n");
61
+ };
62
+
63
+ // harness_state tool: the model reads its BPE slots back without re-deriving them
64
+ pi.registerTool({
65
+ name: "harness_state",
66
+ label: "harness state",
67
+ description:
68
+ "Read your persisted Belief/Progress/Experience slots (commit/track/note history). " +
69
+ "Call this after compaction or when resuming to recover structured state cheaply.",
70
+ parameters: Type.Object({}),
71
+ async execute() {
72
+ return { content: [{ type: "text", text: summarize() || "(empty slots)" }], details: { state } };
73
+ },
74
+ });
75
+
76
+ pi.registerTool({
77
+ name: "harness_commit",
78
+ label: "harness commit",
79
+ description:
80
+ "Cognitive action: record a completed subgoal in the Progress slot. " +
81
+ "Call after finishing a coherent step (e.g. 'tests pass for auth module'). " +
82
+ "Completed subgoals become compaction candidates.",
83
+ parameters: Type.Object({
84
+ subgoal: Type.String({ description: "What was completed" }),
85
+ }),
86
+ async execute(_id, params) {
87
+ const p = params as { subgoal: string };
88
+ state.progress.push(p.subgoal);
89
+ counters.bpeCalls++;
90
+ persist();
91
+ return {
92
+ content: [{ type: "text", text: `committed: ${p.subgoal}` }],
93
+ details: { progress: state.progress.length },
94
+ };
95
+ },
96
+ });
97
+
98
+ pi.registerTool({
99
+ name: "harness_track",
100
+ label: "harness track",
101
+ description:
102
+ "Cognitive action: store an environment-state estimate or entity mapping in the " +
103
+ "Belief slot (e.g. key='db-port' value='5433'). Cheaper than re-reading files.",
104
+ parameters: Type.Object({
105
+ key: Type.String({ description: "Short key" }),
106
+ value: Type.String({ description: "Value to remember" }),
107
+ }),
108
+ async execute(_id, params) {
109
+ const p = params as { key: string; value: string };
110
+ state.belief[p.key] = p.value;
111
+ counters.bpeCalls++;
112
+ persist();
113
+ return {
114
+ content: [{ type: "text", text: `tracked: ${p.key}=${p.value}` }],
115
+ details: { keys: Object.keys(state.belief).length },
116
+ };
117
+ },
118
+ });
119
+
120
+ pi.registerTool({
121
+ name: "harness_note",
122
+ label: "harness note",
123
+ description:
124
+ "Cognitive action: archive a cross-episode lesson or error pattern in the " +
125
+ "Experience slot (e.g. 'this repo's tests need --force-color off').",
126
+ parameters: Type.Object({
127
+ insight: Type.String({ description: "The lesson or pattern" }),
128
+ }),
129
+ async execute(_id, params) {
130
+ const p = params as { insight: string };
131
+ state.experience.push(p.insight);
132
+ counters.bpeCalls++;
133
+ persist();
134
+ return {
135
+ content: [{ type: "text", text: `noted: ${p.insight}` }],
136
+ details: { notes: state.experience.length },
137
+ };
138
+ },
139
+ });
140
+
141
+ // Online context compact: under pressure, archive + shrink old tool results.
142
+ pi.on("context", async (event) => {
143
+ const cfg = getCfg();
144
+ if (!cfg.compact) return undefined;
145
+ const msgs = ((event as { messages?: unknown[] }).messages ?? []) as Array<Record<string, unknown>>;
146
+ if (!msgs.length) return undefined;
147
+ let est = 0;
148
+ for (const m of msgs) est += estTokens(m);
149
+ if (est < cfg.pressureTokens) return undefined;
150
+
151
+ // collect candidate toolResult message indexes, oldest first
152
+ const idxs: number[] = [];
153
+ for (let i = 0; i < msgs.length; i++) {
154
+ if (isToolResult(msgs[i])) idxs.push(i);
155
+ }
156
+ const droppable = idxs.slice(0, Math.max(0, idxs.length - KEEP_RECENT));
157
+ if (!droppable.length) return undefined;
158
+
159
+ const out = msgs.map((m) => ({ ...m }));
160
+ let saved = 0;
161
+ let compacted = 0;
162
+ for (const i of droppable) {
163
+ const m = out[i];
164
+ const content = m.content as Array<{ type: string; text?: string }> | undefined;
165
+ if (!Array.isArray(content)) continue;
166
+ const text = textOf(content);
167
+ const t = tok(text.length);
168
+ if (t < 400) continue; // small results: not worth a compaction event
169
+ const handle = store.write("compacted", text);
170
+ m.content = [
171
+ {
172
+ type: "text",
173
+ text: `[compacted ${handle}: ${t} tok tool result archived; harness_recall(handle, page) to recover]`,
174
+ },
175
+ ];
176
+ saved += t - tok(160);
177
+ compacted++;
178
+ }
179
+ if (!compacted) return undefined;
180
+ counters.compactedCount += compacted;
181
+ counters.tokensSavedEst += saved;
182
+ return { messages: out as never };
183
+ });
184
+ }
185
+
186
+ function isToolResult(m: Record<string, unknown>): boolean {
187
+ const role = String(m.role ?? "");
188
+ return role.toLowerCase().includes("toolresult") || role === "tool_result";
189
+ }
190
+
191
+ function estTokens(m: Record<string, unknown>): number {
192
+ const content = m.content;
193
+ if (typeof content === "string") return tok(content.length);
194
+ if (Array.isArray(content)) {
195
+ return tok(textOf(content as Array<{ type: string; text?: string }>).length);
196
+ }
197
+ return 0;
198
+ }
@@ -0,0 +1,50 @@
1
+ /** Harness config: live toggles persisted to .self-evolve/config.json. */
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+
5
+ export interface HarnessConfig {
6
+ pack: boolean; // ObservationPack: archive oversized outputs, show excerpt + handle
7
+ reducer: boolean; // evidence-preserving reducer: log -> verified receipt
8
+ compact: boolean; // online context compact under window pressure
9
+ fusion: boolean; // action fusion: chain verification after edits at the turn boundary
10
+ packThresholdTokens: number; // outputs above this get packed
11
+ excerptChars: number; // head/tail chars kept in context for packed outputs
12
+ pressureTokens: number; // estimated L1 tokens that trigger compaction
13
+ }
14
+
15
+ export const DEFAULTS: HarnessConfig = {
16
+ pack: true,
17
+ reducer: true,
18
+ compact: true,
19
+ fusion: true,
20
+ packThresholdTokens: 2560, // ~10 KiB, matching the SoL-Pi paper threshold
21
+ excerptChars: 1200,
22
+ pressureTokens: 6000, // tuned: pi system prompt + tool defs already consume ~4-5k
23
+ };
24
+
25
+ export const MECHANISMS = ["pack", "reducer", "compact", "fusion"] as const;
26
+ export type Mechanism = (typeof MECHANISMS)[number];
27
+
28
+ export function stateDir(cwd: string): string {
29
+ return join(cwd, ".self-evolve");
30
+ }
31
+
32
+ export function loadConfig(cwd: string): HarnessConfig {
33
+ try {
34
+ const p = join(stateDir(cwd), "config.json");
35
+ if (existsSync(p)) return { ...DEFAULTS, ...JSON.parse(readFileSync(p, "utf-8")) };
36
+ } catch {
37
+ // corrupt config: fall back to defaults
38
+ }
39
+ return { ...DEFAULTS };
40
+ }
41
+
42
+ export function saveConfig(cwd: string, cfg: HarnessConfig): void {
43
+ mkdirSync(stateDir(cwd), { recursive: true });
44
+ writeFileSync(join(stateDir(cwd), "config.json"), JSON.stringify(cfg, null, 2));
45
+ }
46
+
47
+ /** ~tokens estimate: 4 chars per token. */
48
+ export function tok(chars: number): number {
49
+ return Math.ceil(chars / 4);
50
+ }
@@ -0,0 +1,67 @@
1
+ /** Action fusion: bundle edits with their verification.
2
+ *
3
+ * The model naturally batches edit + verify into one assistant message when
4
+ * asked. We reinforce that with a system prompt guideline, and at the turn
5
+ * boundary we chain one continuation when an edit landed without any
6
+ * verification command running after it — so verification costs no extra
7
+ * user round trip. Bounded per run to avoid loops.
8
+ */
9
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
10
+ import type { HarnessConfig } from "./config.js";
11
+ import type { Counters } from "./pack.js";
12
+
13
+ const EDIT_TOOLS = new Set(["edit", "write", "multi-edit"]);
14
+ const VERIFY_TOOLS = new Set(["pwsh", "bash", "powershell"]);
15
+ const MAX_CONTINUATIONS = 2;
16
+
17
+ export function registerFusion(pi: ExtensionAPI, getCfg: () => HarnessConfig, counters: Counters): void {
18
+ let seq = 0;
19
+ let lastEditSeq = -1;
20
+ let lastVerifySeq = -1;
21
+ let continuations = 0;
22
+
23
+ pi.on("before_agent_start", async () => {
24
+ seq = 0;
25
+ lastEditSeq = -1;
26
+ lastVerifySeq = -1;
27
+ continuations = 0;
28
+ return undefined;
29
+ });
30
+
31
+ pi.on("tool_result", async (event) => {
32
+ seq++;
33
+ const toolName = String((event as { toolName?: string }).toolName ?? "");
34
+ if (EDIT_TOOLS.has(toolName)) lastEditSeq = seq;
35
+ if (VERIFY_TOOLS.has(toolName)) lastVerifySeq = seq;
36
+ return undefined;
37
+ });
38
+
39
+ pi.on("agent_before_settle", async () => {
40
+ if (!getCfg().fusion) return undefined;
41
+ if (lastEditSeq < 0 || lastVerifySeq >= lastEditSeq) return undefined;
42
+ if (continuations >= MAX_CONTINUATIONS) return undefined;
43
+ continuations++;
44
+ counters.fusionCount++;
45
+ lastVerifySeq = seq; // do not re-fire for the same edit
46
+ return {
47
+ entries: [
48
+ {
49
+ type: "custom_message",
50
+ customType: "self-evolve-fusion",
51
+ content:
52
+ "[action fusion] An edit landed without verification in this run. " +
53
+ "Run the relevant build/test command now, report the result, and continue.",
54
+ display: false,
55
+ },
56
+ ],
57
+ continue: true,
58
+ };
59
+ });
60
+
61
+ // guideline: prefer batching edit + verification tool calls in one message
62
+ pi.on("before_agent_start", async (event) => {
63
+ if (!getCfg().fusion) return undefined;
64
+ void event;
65
+ return undefined;
66
+ });
67
+ }
@@ -0,0 +1,81 @@
1
+ /** Self-evolving harness extension for pi.
2
+ *
3
+ * Mechanisms (each independently toggleable via /evolve):
4
+ * - ObservationPack: oversized tool outputs -> L3 disk archive, excerpt + handle in context
5
+ * - harness_recall: paged recall of archived outputs
6
+ * - Evidence-preserving reducer: logs -> verified receipts (verbatim quotes)
7
+ * - BPE cognitive tools: harness_commit / track / note / state, durable entries
8
+ * - Online context compact: archive+shrink old tool results under window pressure
9
+ * - Action fusion: chain verification after unverified edits at the turn boundary
10
+ */
11
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
12
+ import { loadConfig, saveConfig, MECHANISMS, type Mechanism } from "./config.js";
13
+ import { L3Store } from "./store.js";
14
+ import { registerPack, type Counters } from "./pack.js";
15
+ import { registerBpe } from "./bpe.js";
16
+ import { registerFusion } from "./fusion.js";
17
+ import { registerTelemetry } from "./telemetry.js";
18
+
19
+ export default function selfEvolve(pi: ExtensionAPI) {
20
+ const cwd = process.cwd();
21
+ const cfgRef = { current: loadConfig(cwd) };
22
+ const store = new L3Store(cwd);
23
+ const counters: Counters = { packedCount: 0, reducedCount: 0, compactedCount: 0, tokensSavedEst: 0, fusionCount: 0, bpeCalls: 0 };
24
+
25
+ // training-free stand-in for EvoHarness-RL's SFT stage: teach the model the
26
+ // syntax of the cognitive tools so BPE slots actually get exercised
27
+ pi.on("before_agent_start", async () => {
28
+ return {
29
+ message: {
30
+ customType: "self-evolve-guide",
31
+ content:
32
+ "[harness guideline] For multi-step tasks: call harness_commit after each completed subtask, " +
33
+ "harness_track for environment facts worth remembering (paths, ports, versions), and " +
34
+ "harness_note for reusable lessons. These calls are cheap and keep state durable.",
35
+ display: false,
36
+ },
37
+ };
38
+ });
39
+
40
+ registerPack(pi, () => cfgRef.current, store, counters);
41
+ registerBpe(pi, () => cfgRef.current, store, counters);
42
+ registerFusion(pi, () => cfgRef.current, counters);
43
+ registerTelemetry(pi, cwd, counters, process.env.SE_RUN ?? "manual");
44
+
45
+ pi.registerCommand("evolve", {
46
+ description: "Toggle self-evolve harness mechanisms, or show status",
47
+ handler: async (args, ctx) => {
48
+ const parts = (args ?? "").trim().split(/\s+/).filter(Boolean);
49
+ const status = () =>
50
+ `self-evolve: pack=${on(cfgRef.current.pack)} reducer=${on(cfgRef.current.reducer)} ` +
51
+ `compact=${on(cfgRef.current.compact)} fusion=${on(cfgRef.current.fusion)} | ` +
52
+ `packed=${counters.packedCount} reduced=${counters.reducedCount} ` +
53
+ `compacted=${counters.compactedCount} savedEst=${counters.tokensSavedEst}`;
54
+ if (parts.length === 0 || parts[0] === "status") {
55
+ ctx.ui.notify(status(), "info");
56
+ return;
57
+ }
58
+ if (parts[0] === "reset") {
59
+ counters.packedCount = 0;
60
+ counters.reducedCount = 0;
61
+ counters.compactedCount = 0;
62
+ counters.tokensSavedEst = 0;
63
+ ctx.ui.notify("counters reset", "info");
64
+ return;
65
+ }
66
+ const mech = parts[0] as Mechanism;
67
+ if (!MECHANISMS.includes(mech)) {
68
+ ctx.ui.notify(`unknown mechanism '${parts[0]}'. Use: ${MECHANISMS.join(", ")}`, "error");
69
+ return;
70
+ }
71
+ const value = parts[1] ? parts[1] === "on" : !cfgRef.current[mech];
72
+ cfgRef.current = { ...cfgRef.current, [mech]: value };
73
+ saveConfig(cwd, cfgRef.current);
74
+ ctx.ui.notify(status(), "info");
75
+ },
76
+ });
77
+ }
78
+
79
+ function on(b: boolean): string {
80
+ return b ? "on" : "off";
81
+ }
@@ -0,0 +1,135 @@
1
+ /** ObservationPack + evidence reducer + paged recall: the tool_result tier.
2
+ *
3
+ * L1 (active context) keeps an excerpt or a verified receipt; L3 (disk) keeps
4
+ * the raw output, addressable by handle via the harness_recall tool.
5
+ *
6
+ * ponytail: the paper keeps raw text in context for the first 2 provider
7
+ * requests before swapping to the excerpt; we swap immediately since the
8
+ * head/tail excerpt already carries the diagnosis surface. Upgrade path:
9
+ * per-handle view counters.
10
+ */
11
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
12
+ import { Type } from "typebox";
13
+ import type { HarnessConfig } from "./config.js";
14
+ import { tok } from "./config.js";
15
+ import type { L3Store } from "./store.js";
16
+ import { PAGE_CHARS } from "./store.js";
17
+ import { reduceLog } from "./reducer.js";
18
+
19
+ export interface Counters {
20
+ packedCount: number;
21
+ reducedCount: number;
22
+ compactedCount: number;
23
+ tokensSavedEst: number;
24
+ fusionCount: number;
25
+ bpeCalls: number;
26
+ }
27
+
28
+ export function textOf(content: Array<{ type: string; text?: string }>): string {
29
+ return content
30
+ .filter((c) => c.type === "text" && typeof c.text === "string")
31
+ .map((c) => c.text)
32
+ .join("\n");
33
+ }
34
+
35
+ // ponytail: 'read' is excluded — a single intentional file read is usually the payload
36
+ // itself; packing it only adds recall turns (measured on the needle test). Long-horizon
37
+ // bloat comes from repeated shell output, which the reducer + pack cover. Re-enable
38
+ // via config if a workflow does huge reads.
39
+ const ARCHIVED_TOOLS = new Set(["pwsh", "bash", "powershell", "grep", "find", "edit", "write"]);
40
+ const SHELL_TOOLS = new Set(["pwsh", "bash", "powershell"]);
41
+
42
+ export function registerPack(
43
+ pi: ExtensionAPI,
44
+ getCfg: () => HarnessConfig,
45
+ store: L3Store,
46
+ counters: Counters,
47
+ ): void {
48
+ pi.on("tool_result", async (event) => {
49
+ const cfg = getCfg();
50
+ const toolName = String((event as { toolName?: string }).toolName ?? "");
51
+ if (!ARCHIVED_TOOLS.has(toolName) || event.isError) return undefined;
52
+ const text = textOf(event.content as Array<{ type: string; text?: string }>);
53
+ if (!text) return undefined;
54
+ const t = tok(text.length);
55
+
56
+ // reducer first: deterministic filter with verbatim quote validation
57
+ if (cfg.reducer && SHELL_TOOLS.has(toolName)) {
58
+ const red = reduceLog(text);
59
+ if (red) {
60
+ const handle = store.write(toolName, text);
61
+ counters.reducedCount++;
62
+ counters.tokensSavedEst += Math.max(0, t - tok(red.receipt.length));
63
+ return {
64
+ content: [
65
+ {
66
+ type: "text",
67
+ text:
68
+ `${red.receipt}\n` +
69
+ `[This receipt is the COMPLETE failure evidence from the log; act on it directly. ` +
70
+ `Full log archived: ${handle}. Do NOT page the archive with harness_recall unless you ` +
71
+ `specifically need surrounding context for one quoted line.]`,
72
+ },
73
+ ],
74
+ details: event.details,
75
+ };
76
+ }
77
+ }
78
+
79
+ // pack: oversized output -> excerpt + handle, raw to L3
80
+ if (cfg.pack && t > cfg.packThresholdTokens) {
81
+ const handle = store.write(toolName, text);
82
+ const head = text.slice(0, cfg.excerptChars);
83
+ const tail = text.slice(-cfg.excerptChars);
84
+ const pages = Math.ceil(text.length / PAGE_CHARS);
85
+ counters.packedCount++;
86
+ counters.tokensSavedEst += Math.max(0, t - tok(head.length + tail.length + 200));
87
+ return {
88
+ content: [
89
+ {
90
+ type: "text",
91
+ text:
92
+ `[${handle}] ${t} tok archived to disk. Excerpt (head/tail):\n` +
93
+ `---HEAD---\n${head}\n...[snip ${text.length - 2 * cfg.excerptChars} chars]...\n` +
94
+ `---TAIL---\n${tail}\n---\n` +
95
+ `Answer from the excerpt when it suffices; page selectively with ` +
96
+ `harness_recall("${handle}", page) (0..${pages - 1}, ${PAGE_CHARS} chars each) ` +
97
+ `only for specific missing details.`,
98
+ },
99
+ ],
100
+ details: event.details,
101
+ };
102
+ }
103
+ return undefined;
104
+ });
105
+
106
+ pi.registerTool({
107
+ name: "harness_recall",
108
+ label: "harness recall",
109
+ description:
110
+ "Read one page of an archived tool output by handle (e.g. 'se://3'). " +
111
+ "Handles appear in '[se://N] ... archived to disk' notices. Pages are 800 chars; start at page 0. " +
112
+ "Page selectively: receipts and excerpts already carry the key evidence.",
113
+ parameters: Type.Object({
114
+ handle: Type.String({ description: "Archive handle, e.g. se://3" }),
115
+ page: Type.Number({ description: "Zero-based page index", minimum: 0 }),
116
+ }),
117
+ async execute(_toolCallId, params) {
118
+ const p = params as { handle: string; page: number };
119
+ const text = store.page(p.handle, p.page);
120
+ if (text === undefined) {
121
+ return {
122
+ content: [{ type: "text", text: `Unknown handle ${p.handle}` }],
123
+ details: { unknown: true },
124
+ };
125
+ }
126
+ const pages = store.pageCount(p.handle);
127
+ return {
128
+ content: [
129
+ { type: "text", text: `[${p.handle} page ${p.page}/${pages - 1}]\n${text}` },
130
+ ],
131
+ details: { handle: p.handle, page: p.page, pages },
132
+ };
133
+ },
134
+ });
135
+ }
@@ -0,0 +1,35 @@
1
+ /** Evidence-preserving reducer: deterministic log filter -> verified receipt.
2
+ *
3
+ * Every quoted line is validated verbatim against the archived source. If
4
+ * validation fails (or nothing interesting is found), the caller keeps the
5
+ * original observation untouched.
6
+ */
7
+
8
+ const INTERESTING =
9
+ /\b(fail(?:ed)?|error|exception|traceback|assert(?:ion)?:?|panic|fatal|cannot|denied|✗|✘)\b/i;
10
+
11
+ export interface Reduction {
12
+ receipt: string;
13
+ quotedLines: string[];
14
+ }
15
+
16
+ export function reduceLog(text: string): Reduction | null {
17
+ const lines = text.split("\n");
18
+ const hits: string[] = [];
19
+ for (const line of lines) {
20
+ const t = line.trim();
21
+ if (!t) continue;
22
+ if (INTERESTING.test(t) || /^summary:/.test(t)) hits.push(t);
23
+ if (hits.length >= 40) break; // bounded receipt
24
+ }
25
+ if (hits.length === 0) return null;
26
+ // evidence guarantee: every quote must exist verbatim in the source
27
+ for (const q of hits) {
28
+ if (!text.includes(q)) return null;
29
+ }
30
+ const receipt = [
31
+ `RECEIPT (evidence-preserving reducer; ${hits.length} lines quoted verbatim):`,
32
+ ...hits.map((h) => ` ${h}`),
33
+ ].join("\n");
34
+ return { receipt, quotedLines: hits };
35
+ }
@@ -0,0 +1,56 @@
1
+ /** L3 tier: disk-backed archive of raw tool outputs, addressed by handle. */
2
+ import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { stateDir } from "./config.js";
5
+
6
+ export const PAGE_CHARS = 800;
7
+
8
+ export class L3Store {
9
+ private dir: string;
10
+ private counter = 0;
11
+
12
+ constructor(cwd: string) {
13
+ this.dir = join(stateDir(cwd), "l3");
14
+ mkdirSync(this.dir, { recursive: true });
15
+ // resume ids after a restart so handles never collide across sessions
16
+ for (const f of existsSync(this.dir) ? readdirSync(this.dir) : []) {
17
+ const n = Number.parseInt(f.replace(".txt", ""), 10);
18
+ if (Number.isFinite(n) && n > this.counter) this.counter = n;
19
+ }
20
+ }
21
+
22
+ write(toolName: string, text: string): string {
23
+ const id = ++this.counter;
24
+ const handle = `se://${id}`;
25
+ writeFileSync(join(this.dir, `${id}.txt`), text, "utf-8");
26
+ try {
27
+ appendFileSync(
28
+ join(this.dir, "index.jsonl"),
29
+ `${JSON.stringify({ handle, tool: toolName, bytes: text.length, ts: new Date().toISOString() })}\n`,
30
+ );
31
+ } catch {
32
+ // telemetry is best effort
33
+ }
34
+ return handle;
35
+ }
36
+
37
+ pageCount(handle: string): number {
38
+ const file = this.fileOf(handle);
39
+ if (!file) return 0;
40
+ return Math.ceil(readFileSync(file, "utf-8").length / PAGE_CHARS);
41
+ }
42
+
43
+ page(handle: string, page: number): string | undefined {
44
+ const file = this.fileOf(handle);
45
+ if (!file) return undefined;
46
+ const text = readFileSync(file, "utf-8");
47
+ return text.slice(page * PAGE_CHARS, (page + 1) * PAGE_CHARS);
48
+ }
49
+
50
+ private fileOf(handle: string): string | undefined {
51
+ const id = handle.replace("se://", "");
52
+ if (!/^\d+$/.test(id)) return undefined;
53
+ const file = join(this.dir, `${id}.txt`);
54
+ return existsSync(file) ? file : undefined;
55
+ }
56
+ }
@@ -0,0 +1,61 @@
1
+ /** Telemetry: per-run JSONL with provider usage + harness counters.
2
+ *
3
+ * input/output/cacheRead come from the provider (post-transform truth).
4
+ * rawEst = input + tokensSavedEst (what would have gone out without the
5
+ * harness transforms, approximate: cache effects ignored, stated as est).
6
+ */
7
+ import { appendFileSync, mkdirSync } from "node:fs";
8
+ import { join } from "node:path";
9
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
10
+ import { stateDir } from "./config.js";
11
+ import type { Counters } from "./pack.js";
12
+
13
+ export function registerTelemetry(
14
+ pi: ExtensionAPI,
15
+ cwd: string,
16
+ counters: Counters,
17
+ label: string,
18
+ ): string {
19
+ const dir = stateDir(cwd);
20
+ mkdirSync(dir, { recursive: true });
21
+ const file = join(dir, `telemetry-${label}.jsonl`);
22
+
23
+ pi.on("agent_end", async (event) => {
24
+ let input = 0;
25
+ let output = 0;
26
+ let cacheRead = 0;
27
+ for (const m of event.messages as Array<{ usage?: Record<string, number> }>) {
28
+ const u = m?.usage;
29
+ if (!u) continue;
30
+ input += Number(u.input ?? 0);
31
+ output += Number(u.output ?? 0);
32
+ cacheRead += Number(u.cacheRead ?? 0);
33
+ }
34
+ if (input === 0 && output === 0) return undefined;
35
+ const rawEst = input + counters.tokensSavedEst;
36
+ const line = {
37
+ ts: new Date().toISOString(),
38
+ label,
39
+ test: process.env.SE_TEST ?? "",
40
+ input,
41
+ output,
42
+ cacheRead,
43
+ rawEst,
44
+ reductionRatio: rawEst > 0 ? Number((1 - input / rawEst).toFixed(4)) : 0,
45
+ packedCount: counters.packedCount,
46
+ fusionCount: counters.fusionCount,
47
+ bpeCalls: counters.bpeCalls,
48
+ reducedCount: counters.reducedCount,
49
+ compactedCount: counters.compactedCount,
50
+ tokensSavedEst: counters.tokensSavedEst,
51
+ };
52
+ try {
53
+ appendFileSync(file, `${JSON.stringify(line)}\n`);
54
+ } catch {
55
+ // telemetry is best effort
56
+ }
57
+ return undefined;
58
+ });
59
+
60
+ return file;
61
+ }