pi-continual-harness 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/README.md +95 -0
- package/package.json +57 -0
- package/src/index.ts +32 -0
- package/src/inject.ts +49 -0
- package/src/refine.ts +115 -0
- package/src/store.ts +169 -0
- package/src/tools.ts +110 -0
- package/src/types.ts +66 -0
package/README.md
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# pi-continual-harness
|
|
2
|
+
|
|
3
|
+
Online self-improvement layer for the [pi](https://pi.dev) coding agent.
|
|
4
|
+
|
|
5
|
+
This package owns **only the online optimizer layer**: a unified, in-trajectory
|
|
6
|
+
harness-state store (prompt notes, memory, skill descriptions, sub-agent specs)
|
|
7
|
+
plus a manual `/refine` that proposes **evidence-backed structured CRUD deltas**.
|
|
8
|
+
|
|
9
|
+
It deliberately does **not** reinvent storage or offline refinement. It composes
|
|
10
|
+
with:
|
|
11
|
+
|
|
12
|
+
- **[pi-reflect](https://github.com/jo-inc/pi-reflect)** — offline transcript
|
|
13
|
+
→ behavioral-file refinement (the "deep" path).
|
|
14
|
+
- **pi-mem / pi-memory** — durable memory storage.
|
|
15
|
+
|
|
16
|
+
The durable markdown export (`.pi/harness-state.md`) is the composition seam:
|
|
17
|
+
pi-reflect can read and refine it, pi-mem can ingest it.
|
|
18
|
+
|
|
19
|
+
## Why
|
|
20
|
+
|
|
21
|
+
Grounded in two lines of research:
|
|
22
|
+
|
|
23
|
+
- **Continual Harness** (arXiv [2605.09998](https://arxiv.org/abs/2605.09998)) —
|
|
24
|
+
reset-free online CRUD over prompt / sub-agents / skills / memory drawn from
|
|
25
|
+
the trajectory. Distinct from prompt-optimization methods that need episode
|
|
26
|
+
resets.
|
|
27
|
+
- **ACE — Agentic Context Engineering** (arXiv
|
|
28
|
+
[2510.04618](https://arxiv.org/abs/2510.04618)) — context as an evolving
|
|
29
|
+
playbook. Key design lesson applied here: the optimizer emits **structured,
|
|
30
|
+
itemized deltas**, never prose prompt rewrites, to prevent context collapse
|
|
31
|
+
and brevity bias.
|
|
32
|
+
|
|
33
|
+
Prime Intellect's Prime Agent ships a Continual Harness built on pi; this
|
|
34
|
+
package is a minimal, package-shaped take on the same idea — **online only**,
|
|
35
|
+
leaving offline and storage to the packages that already do them well.
|
|
36
|
+
|
|
37
|
+
## Install
|
|
38
|
+
|
|
39
|
+
```
|
|
40
|
+
pi install git:github.com/<owner>/pi-continual-harness
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Or drop `src/index.ts` into `~/.pi/agent/extensions/`.
|
|
44
|
+
|
|
45
|
+
## Usage
|
|
46
|
+
|
|
47
|
+
```
|
|
48
|
+
/refine # review last 25 turns, propose deltas
|
|
49
|
+
/refine 50 # review last 50 turns
|
|
50
|
+
/refine 25 --commit # also export durable state to ~/.pi/agent/harness-state.md
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
The model-facing tools:
|
|
54
|
+
|
|
55
|
+
- `harness_list [kind]` — read current state (optionally filtered by kind).
|
|
56
|
+
- `harness_mutate { deltas: [...] }` — apply a batch of `create` / `update` /
|
|
57
|
+
`delete` deltas. Every `create` requires `evidence`.
|
|
58
|
+
|
|
59
|
+
Active items are injected into the system prompt each turn as a structured
|
|
60
|
+
block, appended to (never replacing) the base prompt.
|
|
61
|
+
|
|
62
|
+
## How it works
|
|
63
|
+
|
|
64
|
+
1. `/refine` gathers recent trajectory evidence from the current session branch.
|
|
65
|
+
2. It sends a steering user message asking the agent to propose evidence-backed
|
|
66
|
+
CRUD deltas via `harness_mutate`.
|
|
67
|
+
3. The agent calls the tools; each accepted delta updates the in-memory state,
|
|
68
|
+
which is snapshotted to the session via `appendEntry("harness-state", ...)`.
|
|
69
|
+
4. Because pi's session tree branches at any entry, `/tree` navigation gives
|
|
70
|
+
rollback to any pre-refinement point for free — no bespoke snapshot system.
|
|
71
|
+
|
|
72
|
+
This reuses the existing agent loop (no nested/hidden model calls), is
|
|
73
|
+
model-agnostic, and keeps every delta visible and reviewable in the transcript.
|
|
74
|
+
|
|
75
|
+
## Scope and non-goals
|
|
76
|
+
|
|
77
|
+
- **In scope:** unified state store, online `/refine`, structured deltas,
|
|
78
|
+
prompt injection, branching rollback, durable markdown export.
|
|
79
|
+
- **Out of scope (compose instead):** durable storage engines (pi-mem), offline
|
|
80
|
+
deep refinement of behavioral files (pi-reflect), live sub-agent
|
|
81
|
+
orchestration (pi-boss / pi-room). Sub-agent *specs* are stored as data only.
|
|
82
|
+
|
|
83
|
+
## Status
|
|
84
|
+
|
|
85
|
+
Scaffold (0.1.0). Extension points to grow into:
|
|
86
|
+
|
|
87
|
+
- A pluggable delta proposer (currently the agent itself, via steering).
|
|
88
|
+
- Project-local vs global durable paths.
|
|
89
|
+
- Optional `turn_end` auto-trigger behind a setting (intentionally off by
|
|
90
|
+
default — autonomous self-mutation is a sharp edge).
|
|
91
|
+
- Importance decay/prune policy.
|
|
92
|
+
|
|
93
|
+
## License
|
|
94
|
+
|
|
95
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-continual-harness",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Online self-improvement layer for pi. A unified, in-trajectory harness-state store (prompt notes, memory, skill descriptions, sub-agent specs) with a manual /refine that proposes evidence-backed structured CRUD deltas. Composes with pi-reflect (offline) and pi-mem (storage); owns only the online optimizer layer. Inspired by Continual Harness (arXiv 2605.09998) and ACE (arXiv 2510.04618).",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"pi-package",
|
|
7
|
+
"pi",
|
|
8
|
+
"pi.dev",
|
|
9
|
+
"pi-extension",
|
|
10
|
+
"continual-harness",
|
|
11
|
+
"self-improvement",
|
|
12
|
+
"refine",
|
|
13
|
+
"memory",
|
|
14
|
+
"harness",
|
|
15
|
+
"agent"
|
|
16
|
+
],
|
|
17
|
+
"license": "MIT",
|
|
18
|
+
"type": "module",
|
|
19
|
+
"main": "src/index.ts",
|
|
20
|
+
"files": [
|
|
21
|
+
"src",
|
|
22
|
+
"README.md",
|
|
23
|
+
"LICENSE"
|
|
24
|
+
],
|
|
25
|
+
"repository": {
|
|
26
|
+
"type": "git",
|
|
27
|
+
"url": "git+https://github.com/pungggi/pi-continual-harness.git"
|
|
28
|
+
},
|
|
29
|
+
"homepage": "https://github.com/pungggi/pi-continual-harness#readme",
|
|
30
|
+
"bugs": {
|
|
31
|
+
"url": "https://github.com/pungggi/pi-continual-harness/issues"
|
|
32
|
+
},
|
|
33
|
+
"engines": {
|
|
34
|
+
"node": ">=20.0.0"
|
|
35
|
+
},
|
|
36
|
+
"peerDependencies": {
|
|
37
|
+
"@earendil-works/pi-coding-agent": ">=0.74.0"
|
|
38
|
+
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"@earendil-works/pi-coding-agent": ">=0.74.0",
|
|
41
|
+
"@earendil-works/pi-ai": ">=0.74.0",
|
|
42
|
+
"typebox": ">=1.0.0",
|
|
43
|
+
"@types/node": "^22.0.0",
|
|
44
|
+
"typescript": "^5.6.0",
|
|
45
|
+
"vitest": "^3.0.0"
|
|
46
|
+
},
|
|
47
|
+
"pi": {
|
|
48
|
+
"extensions": [
|
|
49
|
+
"./src/index.ts"
|
|
50
|
+
]
|
|
51
|
+
},
|
|
52
|
+
"scripts": {
|
|
53
|
+
"test": "vitest run",
|
|
54
|
+
"typecheck": "tsc --noEmit",
|
|
55
|
+
"check": "npm run typecheck && npm run test"
|
|
56
|
+
}
|
|
57
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// pi-continual-harness — online self-improvement layer for pi.
|
|
2
|
+
//
|
|
3
|
+
// Owns ONLY the online optimizer layer over a unified harness-state store.
|
|
4
|
+
// Composes with pi-reflect (offline refinement) and pi-mem (storage); does not
|
|
5
|
+
// reinvent either. Manual /refine only — no autonomous mutation.
|
|
6
|
+
//
|
|
7
|
+
// See README for design rationale and the research it is grounded in.
|
|
8
|
+
|
|
9
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
10
|
+
import { registerInjection } from "./inject.js";
|
|
11
|
+
import { registerRefine } from "./refine.js";
|
|
12
|
+
import { registerTools } from "./tools.js";
|
|
13
|
+
import { getState, reconstruct, STATE_ENTRY } from "./store.js";
|
|
14
|
+
|
|
15
|
+
export default function continualHarness(pi: ExtensionAPI): void {
|
|
16
|
+
// Rebuild in-memory state from the current branch on every session start /
|
|
17
|
+
// reload / resume / fork. This is what makes refinements branch-local and
|
|
18
|
+
// rollback-able via /tree.
|
|
19
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
20
|
+
reconstruct(ctx.sessionManager.getBranch() as Iterable<unknown>);
|
|
21
|
+
const n = getState().items.length;
|
|
22
|
+
if (n > 0) {
|
|
23
|
+
ctx.ui.notify(`Continual Harness: ${n} item(s) restored`, "info");
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
registerInjection(pi);
|
|
28
|
+
registerTools(pi);
|
|
29
|
+
registerRefine(pi);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export { STATE_ENTRY };
|
package/src/inject.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// before_agent_start: inject the active harness state as a structured block
|
|
2
|
+
// appended to the base system prompt. Never rewrites or replaces the base —
|
|
3
|
+
// only appends, matching Continual Harness's "immutable base + supplemental".
|
|
4
|
+
|
|
5
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import { getState } from "./store.js";
|
|
7
|
+
import type { ComponentKind } from "./types.js";
|
|
8
|
+
|
|
9
|
+
const ORDER: ComponentKind[] = ["prompt", "memory", "skill", "subagent"];
|
|
10
|
+
|
|
11
|
+
const TITLES: Record<ComponentKind, string> = {
|
|
12
|
+
prompt: "Self-improved prompt notes",
|
|
13
|
+
memory: "Remembered facts",
|
|
14
|
+
skill: "Available skill notes",
|
|
15
|
+
subagent: "Reusable sub-agent specs",
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export function renderHarnessBlock(): string {
|
|
19
|
+
const state = getState();
|
|
20
|
+
if (state.items.length === 0) return "";
|
|
21
|
+
|
|
22
|
+
const sections: string[] = [];
|
|
23
|
+
for (const kind of ORDER) {
|
|
24
|
+
const items = state.items.filter((i) => i.kind === kind && i.active);
|
|
25
|
+
if (items.length === 0) continue;
|
|
26
|
+
const bullets = items
|
|
27
|
+
.map((i) => `- [${i.id}] ${i.content}`)
|
|
28
|
+
.join("\n");
|
|
29
|
+
sections.push(`### ${TITLES[kind]}\n${bullets}`);
|
|
30
|
+
}
|
|
31
|
+
if (sections.length === 0) return "";
|
|
32
|
+
|
|
33
|
+
return [
|
|
34
|
+
"",
|
|
35
|
+
"## Continual Harness state",
|
|
36
|
+
"Self-improved notes accumulated from past trajectories via /refine.",
|
|
37
|
+
"Treat these as durable working context. Update them with the harness_mutate tool when they are wrong or stale.",
|
|
38
|
+
"",
|
|
39
|
+
sections.join("\n\n"),
|
|
40
|
+
].join("\n");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function registerInjection(pi: ExtensionAPI): void {
|
|
44
|
+
pi.on("before_agent_start", async (event) => {
|
|
45
|
+
const block = renderHarnessBlock();
|
|
46
|
+
if (!block) return;
|
|
47
|
+
return { systemPrompt: event.systemPrompt + "\n" + block };
|
|
48
|
+
});
|
|
49
|
+
}
|
package/src/refine.ts
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// /refine — the online optimizer. Manual only (no turn_end auto-trigger).
|
|
2
|
+
//
|
|
3
|
+
// Flow:
|
|
4
|
+
// 1. Gather recent trajectory evidence from the current branch.
|
|
5
|
+
// 2. Send a steering user message that asks the agent to propose evidence-
|
|
6
|
+
// backed CRUD deltas via harness_mutate (and to inspect state with
|
|
7
|
+
// harness_list first).
|
|
8
|
+
// 3. The agent does the reasoning and calls the tools. Each accepted delta is
|
|
9
|
+
// recorded as a session entry, so /tree gives rollback for free.
|
|
10
|
+
//
|
|
11
|
+
// Why a steering message instead of a nested LLM call: it reuses the existing
|
|
12
|
+
// agent loop, is model-agnostic, and keeps every delta visible and reviewable
|
|
13
|
+
// in the transcript. No hidden model calls.
|
|
14
|
+
|
|
15
|
+
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
16
|
+
import { exportDurable, REFINE_ENTRY } from "./store.js";
|
|
17
|
+
|
|
18
|
+
const DEFAULT_LOOKBACK_TURNS = 25;
|
|
19
|
+
const DEFAULT_EVIDENCE_BYTES = 16000;
|
|
20
|
+
|
|
21
|
+
// Minimal entry shape for trajectory walking; kept loose to avoid coupling to
|
|
22
|
+
// pi's internal session entry types.
|
|
23
|
+
type AnyEntry = {
|
|
24
|
+
type?: string;
|
|
25
|
+
customType?: string;
|
|
26
|
+
message?: {
|
|
27
|
+
role?: string;
|
|
28
|
+
content?: Array<{ type?: string; text?: string }>;
|
|
29
|
+
};
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export function registerRefine(pi: ExtensionAPI): void {
|
|
33
|
+
pi.registerCommand("refine", {
|
|
34
|
+
description:
|
|
35
|
+
"Online self-improvement: review recent trajectory and propose evidence-backed CRUD deltas to the harness state. Usage: /refine [lookback-turns] [--commit]",
|
|
36
|
+
handler: async (args, ctx) => {
|
|
37
|
+
const { lookback, commit } = parseArgs(args);
|
|
38
|
+
ctx.ui.setStatus("harness", `Refining (last ${lookback} turns)…`);
|
|
39
|
+
|
|
40
|
+
const evidence = gatherEvidence(ctx, lookback);
|
|
41
|
+
const prompt = buildSteeringPrompt(evidence, lookback);
|
|
42
|
+
|
|
43
|
+
// Record that a refinement was kicked off (audit trail; branchable).
|
|
44
|
+
pi.appendEntry(REFINE_ENTRY, { lookback, commit, startedAt: Date.now() });
|
|
45
|
+
|
|
46
|
+
if (commit) {
|
|
47
|
+
// Flush current state to the durable file first so the agent refines
|
|
48
|
+
// against the same view pi-reflect / pi-mem would see.
|
|
49
|
+
try {
|
|
50
|
+
const path = await exportDurable();
|
|
51
|
+
ctx.ui.notify(`Durable state exported to ${path}`, "info");
|
|
52
|
+
} catch (err) {
|
|
53
|
+
ctx.ui.notify(`Durable export failed: ${(err as Error).message}`, "warning");
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
pi.sendUserMessage(prompt);
|
|
58
|
+
ctx.ui.setStatus("harness", undefined);
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function parseArgs(args: string): { lookback: number; commit: boolean } {
|
|
64
|
+
const parts = args.trim().split(/\s+/).filter(Boolean);
|
|
65
|
+
let lookback = DEFAULT_LOOKBACK_TURNS;
|
|
66
|
+
let commit = false;
|
|
67
|
+
for (const p of parts) {
|
|
68
|
+
if (p === "--commit") commit = true;
|
|
69
|
+
else if (/^\d+$/.test(p)) lookback = Math.max(1, Math.min(200, Number(p)));
|
|
70
|
+
}
|
|
71
|
+
return { lookback, commit };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function gatherEvidence(ctx: ExtensionCommandContext, lookback: number): string {
|
|
75
|
+
const entries = ctx.sessionManager.getBranch() as AnyEntry[];
|
|
76
|
+
const messages = entries.filter((e) => e.type === "message" && e.message);
|
|
77
|
+
const recent = messages.slice(-lookback * 2); // ~2 entries per turn (user+assistant)
|
|
78
|
+
const lines: string[] = [];
|
|
79
|
+
let bytes = 0;
|
|
80
|
+
for (const e of recent) {
|
|
81
|
+
const role = e.message?.role ?? "?";
|
|
82
|
+
const text = (e.message?.content ?? [])
|
|
83
|
+
.map((c) => c.text ?? "")
|
|
84
|
+
.join(" ")
|
|
85
|
+
.replace(/\s+/g, " ")
|
|
86
|
+
.trim();
|
|
87
|
+
if (!text) continue;
|
|
88
|
+
const line = `[${role}] ${text}`;
|
|
89
|
+
if (bytes + line.length > DEFAULT_EVIDENCE_BYTES) {
|
|
90
|
+
lines.push("[…truncated…]");
|
|
91
|
+
break;
|
|
92
|
+
}
|
|
93
|
+
lines.push(line);
|
|
94
|
+
bytes += line.length;
|
|
95
|
+
}
|
|
96
|
+
return lines.join("\n") || "(no recent trajectory evidence found)";
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function buildSteeringPrompt(evidence: string, lookback: number): string {
|
|
100
|
+
return [
|
|
101
|
+
`/refine (online self-improvement, last ${lookback} turns as evidence)`,
|
|
102
|
+
"",
|
|
103
|
+
"Review the trajectory evidence below. Identify DURABLE, REUSABLE corrections — things that will help in future turns, not one-off fixes. Then update the Continual Harness state.",
|
|
104
|
+
"",
|
|
105
|
+
"Steps:",
|
|
106
|
+
"1. Call harness_list to see the current state.",
|
|
107
|
+
"2. Call harness_mutate with small, surgical CRUD deltas. Every create MUST include concrete evidence from the trajectory. Prefer updating existing items over creating duplicates; delete items that are wrong, stale, or contradicted.",
|
|
108
|
+
"3. Keep prompt notes terse and behavioral. Memory facts should be specific. Skill/sub-agent entries should describe reusable patterns, not one task.",
|
|
109
|
+
"",
|
|
110
|
+
"Constraints: never rewrite the whole store; ground every change in evidence; if nothing durable is worth recording, say so and do nothing.",
|
|
111
|
+
"",
|
|
112
|
+
"## Trajectory evidence",
|
|
113
|
+
evidence,
|
|
114
|
+
].join("\n");
|
|
115
|
+
}
|
package/src/store.ts
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// The unified harness-state store.
|
|
2
|
+
//
|
|
3
|
+
// Two persistence layers:
|
|
4
|
+
// 1. Session-scoped (the core): the full state is snapshotted via
|
|
5
|
+
// pi.appendEntry("harness-state", ...) after every mutation and
|
|
6
|
+
// reconstructed on session_start from the current branch. Because pi's
|
|
7
|
+
// session tree branches at any entry, navigating /tree to before a
|
|
8
|
+
// refinement and resuming gives rollback for free.
|
|
9
|
+
// 2. Durable (the composition seam): exportDurable() writes the active items
|
|
10
|
+
// to a markdown file pi-reflect can read and refine offline, and pi-mem can
|
|
11
|
+
// ingest. Best-effort; the package works without it.
|
|
12
|
+
|
|
13
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
14
|
+
import { homedir } from "node:os";
|
|
15
|
+
import { dirname, join } from "node:path";
|
|
16
|
+
import type { AppliedDelta, ComponentKind, Delta, HarnessItem, HarnessState } from "./types.js";
|
|
17
|
+
|
|
18
|
+
const STATE_ENTRY = "harness-state";
|
|
19
|
+
const REFINE_ENTRY = "harness-refinement";
|
|
20
|
+
|
|
21
|
+
const DEFAULT_DURABLE_PATH = join(homedir(), ".pi", "agent", "harness-state.md");
|
|
22
|
+
|
|
23
|
+
const IMPORTANCE_FLOOR = 0.3;
|
|
24
|
+
|
|
25
|
+
// Module-scoped state. Rebuilt on every session_start, so it tracks the active
|
|
26
|
+
// branch. Mutations are synchronous, so concurrent tool calls cannot interleave
|
|
27
|
+
// inside a single mutation.
|
|
28
|
+
let state: HarnessState = { items: [] };
|
|
29
|
+
let version = 0;
|
|
30
|
+
|
|
31
|
+
export function getState(): HarnessState {
|
|
32
|
+
return state;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function listItems(kind?: ComponentKind): HarnessItem[] {
|
|
36
|
+
return kind ? state.items.filter((i) => i.kind === kind) : state.items;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function genId(): string {
|
|
40
|
+
return `h_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function clamp(n: number): number {
|
|
44
|
+
return Math.max(0, Math.min(1, n));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Apply a single delta against the in-memory state. Does not persist. */
|
|
48
|
+
function applyOne(delta: Delta): AppliedDelta {
|
|
49
|
+
if (delta.op === "create") {
|
|
50
|
+
const now = Date.now();
|
|
51
|
+
const item: HarnessItem = {
|
|
52
|
+
id: genId(),
|
|
53
|
+
kind: delta.kind,
|
|
54
|
+
content: delta.content,
|
|
55
|
+
evidence: delta.evidence,
|
|
56
|
+
importance: clamp(delta.importance ?? 0.5),
|
|
57
|
+
active: true,
|
|
58
|
+
createdAt: now,
|
|
59
|
+
updatedAt: now,
|
|
60
|
+
};
|
|
61
|
+
state.items.push(item);
|
|
62
|
+
return { op: "create", item };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (delta.op === "update") {
|
|
66
|
+
const idx = state.items.findIndex((i) => i.id === delta.id);
|
|
67
|
+
if (idx < 0) throw new Error(`update: no item with id ${delta.id}`);
|
|
68
|
+
const before = state.items[idx]!;
|
|
69
|
+
const after: HarnessItem = {
|
|
70
|
+
...before,
|
|
71
|
+
content: delta.content ?? before.content,
|
|
72
|
+
evidence: delta.evidence ?? before.evidence,
|
|
73
|
+
importance: clamp(delta.importance ?? before.importance),
|
|
74
|
+
active: delta.active ?? before.active,
|
|
75
|
+
updatedAt: Date.now(),
|
|
76
|
+
};
|
|
77
|
+
state.items[idx] = after;
|
|
78
|
+
return { op: "update", before, after };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// delete
|
|
82
|
+
const idx = state.items.findIndex((i) => i.id === delta.id);
|
|
83
|
+
if (idx < 0) throw new Error(`delete: no item with id ${delta.id}`);
|
|
84
|
+
state.items.splice(idx, 1);
|
|
85
|
+
return { op: "delete", id: delta.id, reason: delta.reason };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Apply a batch of deltas. All-or-nothing: if any delta throws, nothing is
|
|
90
|
+
* applied. Returns the applied deltas and the new version.
|
|
91
|
+
*/
|
|
92
|
+
export function applyDeltas(
|
|
93
|
+
deltas: Delta[],
|
|
94
|
+
persist: (snapshot: HarnessState, version: number) => void,
|
|
95
|
+
): AppliedDelta[] {
|
|
96
|
+
const snapshotBefore = { items: state.items.map((i) => ({ ...i })) };
|
|
97
|
+
const applied: AppliedDelta[] = [];
|
|
98
|
+
try {
|
|
99
|
+
for (const d of deltas) applied.push(applyOne(d));
|
|
100
|
+
} catch (err) {
|
|
101
|
+
// Roll back in-memory state on failure.
|
|
102
|
+
state = snapshotBefore;
|
|
103
|
+
throw err;
|
|
104
|
+
}
|
|
105
|
+
version += 1;
|
|
106
|
+
persist(state, version);
|
|
107
|
+
return applied;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Reconstruct state from the current branch's last harness-state snapshot. */
|
|
111
|
+
export function reconstruct(entries: Iterable<unknown>): void {
|
|
112
|
+
let last: HarnessState | undefined;
|
|
113
|
+
for (const raw of entries) {
|
|
114
|
+
const entry = raw as { type?: string; customType?: string; data?: { state?: HarnessState } };
|
|
115
|
+
if (entry.type === "custom" && entry.customType === STATE_ENTRY && entry.data?.state) {
|
|
116
|
+
last = entry.data.state;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
state = last ? { items: last.items.map((i) => ({ ...i })) } : { items: [] };
|
|
120
|
+
version = 0;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Decrement importance of inactive/low-signal items; prune below the floor. */
|
|
124
|
+
export function decayAndPrune(persist: (snapshot: HarnessState, version: number) => void): {
|
|
125
|
+
pruned: number;
|
|
126
|
+
} {
|
|
127
|
+
const before = state.items.length;
|
|
128
|
+
state.items = state.items.filter((i) => i.importance >= IMPORTANCE_FLOOR);
|
|
129
|
+
version += 1;
|
|
130
|
+
persist(state, version);
|
|
131
|
+
return { pruned: before - state.items.length };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export { STATE_ENTRY, REFINE_ENTRY, IMPORTANCE_FLOOR };
|
|
135
|
+
|
|
136
|
+
// ---- Durable export (composition seam with pi-reflect / pi-mem) -------------
|
|
137
|
+
|
|
138
|
+
export async function exportDurable(path = DEFAULT_DURABLE_PATH): Promise<string> {
|
|
139
|
+
const lines: string[] = ["# Continual Harness State", ""];
|
|
140
|
+
const kinds: ComponentKind[] = ["prompt", "memory", "skill", "subagent"];
|
|
141
|
+
for (const kind of kinds) {
|
|
142
|
+
const items = state.items.filter((i) => i.kind === kind && i.active);
|
|
143
|
+
if (items.length === 0) continue;
|
|
144
|
+
lines.push(`## ${titleFor(kind)}`, "");
|
|
145
|
+
for (const i of items) {
|
|
146
|
+
lines.push(`- **[${i.id}]** (importance ${i.importance.toFixed(2)}) ${i.content}`);
|
|
147
|
+
lines.push(` - evidence: ${i.evidence}`);
|
|
148
|
+
}
|
|
149
|
+
lines.push("");
|
|
150
|
+
}
|
|
151
|
+
if (lines.length <= 2) lines.push("_(no active items)_", "");
|
|
152
|
+
const body = lines.join("\n");
|
|
153
|
+
await mkdir(dirname(path), { recursive: true }).catch(() => {});
|
|
154
|
+
await writeFile(path, body, "utf8");
|
|
155
|
+
return path;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function titleFor(kind: ComponentKind): string {
|
|
159
|
+
switch (kind) {
|
|
160
|
+
case "prompt":
|
|
161
|
+
return "Supplemental prompt notes";
|
|
162
|
+
case "memory":
|
|
163
|
+
return "Memory facts";
|
|
164
|
+
case "skill":
|
|
165
|
+
return "Skill descriptions";
|
|
166
|
+
case "subagent":
|
|
167
|
+
return "Sub-agent specs";
|
|
168
|
+
}
|
|
169
|
+
}
|
package/src/tools.ts
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// Model-facing tools. Two tools, kept lean for prompt economy:
|
|
2
|
+
// - harness_list: read items (optionally by kind).
|
|
3
|
+
// - harness_mutate: apply a batch of structured CRUD deltas.
|
|
4
|
+
//
|
|
5
|
+
// Deltas — not prose rewrites — are the unit of self-improvement (ACE).
|
|
6
|
+
// Each create requires `evidence`, so the model must ground every addition.
|
|
7
|
+
|
|
8
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
9
|
+
import { StringEnum } from "@earendil-works/pi-ai";
|
|
10
|
+
import { Type } from "typebox";
|
|
11
|
+
import { applyDeltas, getState, listItems } from "./store.js";
|
|
12
|
+
import type { AppliedDelta, ComponentKind, Delta } from "./types.js";
|
|
13
|
+
|
|
14
|
+
const KIND_VALUES = ["prompt", "memory", "skill", "subagent"] as const;
|
|
15
|
+
|
|
16
|
+
const CreateShape = Type.Object({
|
|
17
|
+
op: Type.Literal("create"),
|
|
18
|
+
kind: StringEnum(KIND_VALUES),
|
|
19
|
+
content: Type.String({ description: "The note/fact/description/spec text." }),
|
|
20
|
+
evidence: Type.String({
|
|
21
|
+
description: "Why this belongs in the harness state, grounded in trajectory evidence.",
|
|
22
|
+
}),
|
|
23
|
+
importance: Type.Optional(Type.Number({ minimum: 0, maximum: 1 })),
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const UpdateShape = Type.Object({
|
|
27
|
+
op: Type.Literal("update"),
|
|
28
|
+
id: Type.String({ description: "Existing item id, e.g. h_..." }),
|
|
29
|
+
content: Type.Optional(Type.String()),
|
|
30
|
+
evidence: Type.Optional(Type.String()),
|
|
31
|
+
importance: Type.Optional(Type.Number({ minimum: 0, maximum: 1 })),
|
|
32
|
+
active: Type.Optional(Type.Boolean()),
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const DeleteShape = Type.Object({
|
|
36
|
+
op: Type.Literal("delete"),
|
|
37
|
+
id: Type.String(),
|
|
38
|
+
reason: Type.String({ description: "Why this item is being removed." }),
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const DeltaShape = Type.Union([CreateShape, UpdateShape, DeleteShape]);
|
|
42
|
+
|
|
43
|
+
export function registerTools(pi: ExtensionAPI): void {
|
|
44
|
+
pi.registerTool({
|
|
45
|
+
name: "harness_list",
|
|
46
|
+
label: "List harness state",
|
|
47
|
+
description:
|
|
48
|
+
"List items in the Continual Harness state (self-improved prompt notes, memory, skill descriptions, sub-agent specs). Optionally filter by kind.",
|
|
49
|
+
promptSnippet: "Read Continual Harness self-improvement state",
|
|
50
|
+
promptGuidelines: [
|
|
51
|
+
"Call harness_list before proposing changes so you operate on current state.",
|
|
52
|
+
],
|
|
53
|
+
parameters: Type.Object({
|
|
54
|
+
kind: Type.Optional(StringEnum(KIND_VALUES)),
|
|
55
|
+
}),
|
|
56
|
+
async execute(_toolCallId, params) {
|
|
57
|
+
const kind = params.kind as ComponentKind | undefined;
|
|
58
|
+
const items = listItems(kind);
|
|
59
|
+
return {
|
|
60
|
+
content: [
|
|
61
|
+
{
|
|
62
|
+
type: "text",
|
|
63
|
+
text:
|
|
64
|
+
items.length === 0
|
|
65
|
+
? "Harness state is empty."
|
|
66
|
+
: items
|
|
67
|
+
.map(
|
|
68
|
+
(i) =>
|
|
69
|
+
`[${i.id}] (${i.kind}, importance ${i.importance.toFixed(2)}, ${i.active ? "active" : "inactive"}) ${i.content}\n evidence: ${i.evidence}`,
|
|
70
|
+
)
|
|
71
|
+
.join("\n"),
|
|
72
|
+
},
|
|
73
|
+
],
|
|
74
|
+
details: { count: items.length, items },
|
|
75
|
+
};
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
pi.registerTool({
|
|
80
|
+
name: "harness_mutate",
|
|
81
|
+
label: "Mutate harness state",
|
|
82
|
+
description:
|
|
83
|
+
"Apply a batch of structured CRUD deltas to the Continual Harness state. Each create requires evidence grounded in trajectory. Prefer many small surgical deltas over wholesale rewrites.",
|
|
84
|
+
promptSnippet: "Propose evidence-backed CRUD deltas to self-improvement state",
|
|
85
|
+
promptGuidelines: [
|
|
86
|
+
"Use harness_mutate during /refine or when you notice a durable, reusable correction. Ground every create in evidence. Prefer small surgical deltas.",
|
|
87
|
+
],
|
|
88
|
+
parameters: Type.Object({
|
|
89
|
+
deltas: Type.Array(DeltaShape, { minItems: 1, maxItems: 20 }),
|
|
90
|
+
}),
|
|
91
|
+
async execute(_toolCallId, params) {
|
|
92
|
+
const deltas = params.deltas as Delta[];
|
|
93
|
+
const applied: AppliedDelta[] = applyDeltas(deltas, (snapshot, ver) => {
|
|
94
|
+
pi.appendEntry("harness-state", { state: snapshot, version: ver });
|
|
95
|
+
});
|
|
96
|
+
const summary = summarize(applied);
|
|
97
|
+
return {
|
|
98
|
+
content: [{ type: "text", text: summary }],
|
|
99
|
+
details: { applied, version: getState().items.length },
|
|
100
|
+
};
|
|
101
|
+
},
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function summarize(applied: AppliedDelta[]): string {
|
|
106
|
+
const created = applied.filter((a) => a.op === "create").length;
|
|
107
|
+
const updated = applied.filter((a) => a.op === "update").length;
|
|
108
|
+
const deleted = applied.filter((a) => a.op === "delete").length;
|
|
109
|
+
return `Applied ${applied.length} delta(s): ${created} created, ${updated} updated, ${deleted} deleted.`;
|
|
110
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// Continual Harness — core types.
|
|
2
|
+
//
|
|
3
|
+
// Design notes (see README for the full rationale):
|
|
4
|
+
// - Four components per Continual Harness (arXiv 2605.09998): prompt, memory,
|
|
5
|
+
// skill, sub-agent spec. Each item carries `evidence` grounding it in the
|
|
6
|
+
// trajectory and an `importance` fitness signal (ACE-style).
|
|
7
|
+
// - Mutations are expressed as structured CRUD deltas, never as prose prompt
|
|
8
|
+
// rewrites. This follows ACE (arXiv 2510.04618): itemized, deterministic
|
|
9
|
+
// merges prevent context collapse and brevity bias.
|
|
10
|
+
// - The store is session-scoped (reset-free, online). A durable markdown
|
|
11
|
+
// export is the composition seam with pi-reflect / pi-mem (see store.ts).
|
|
12
|
+
|
|
13
|
+
/** The four Continual Harness components. */
|
|
14
|
+
export type ComponentKind = "prompt" | "memory" | "skill" | "subagent";
|
|
15
|
+
|
|
16
|
+
/** A single item in the unified harness store. */
|
|
17
|
+
export interface HarnessItem {
|
|
18
|
+
id: string;
|
|
19
|
+
kind: ComponentKind;
|
|
20
|
+
/** Payload: a prompt note, a memory fact, a skill description, or a sub-agent spec. */
|
|
21
|
+
content: string;
|
|
22
|
+
/** Why this item exists, grounded in trajectory evidence. Required for every create. */
|
|
23
|
+
evidence: string;
|
|
24
|
+
/** Fitness signal in [0,1]. Items below a threshold can be pruned/deactivated. */
|
|
25
|
+
importance: number;
|
|
26
|
+
/** Whether the item is injected into the system prompt each turn. */
|
|
27
|
+
active: boolean;
|
|
28
|
+
createdAt: number;
|
|
29
|
+
updatedAt: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface HarnessState {
|
|
33
|
+
items: HarnessItem[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Structured CRUD delta. The unit of self-improvement. */
|
|
37
|
+
export type Delta =
|
|
38
|
+
| {
|
|
39
|
+
op: "create";
|
|
40
|
+
kind: ComponentKind;
|
|
41
|
+
content: string;
|
|
42
|
+
evidence: string;
|
|
43
|
+
importance?: number;
|
|
44
|
+
}
|
|
45
|
+
| {
|
|
46
|
+
op: "update";
|
|
47
|
+
id: string;
|
|
48
|
+
content?: string;
|
|
49
|
+
evidence?: string;
|
|
50
|
+
importance?: number;
|
|
51
|
+
active?: boolean;
|
|
52
|
+
}
|
|
53
|
+
| { op: "delete"; id: string; reason: string };
|
|
54
|
+
|
|
55
|
+
/** Result of applying a single delta; returned to the model for confirmation. */
|
|
56
|
+
export type AppliedDelta =
|
|
57
|
+
| { op: "create"; item: HarnessItem }
|
|
58
|
+
| { op: "update"; before: HarnessItem; after: HarnessItem }
|
|
59
|
+
| { op: "delete"; id: string; reason: string };
|
|
60
|
+
|
|
61
|
+
export const KIND_LABEL: Record<ComponentKind, string> = {
|
|
62
|
+
prompt: "Supplemental prompt notes",
|
|
63
|
+
memory: "Memory facts",
|
|
64
|
+
skill: "Skill descriptions",
|
|
65
|
+
subagent: "Sub-agent specs",
|
|
66
|
+
};
|