pi-continual-harness 0.3.0 → 0.5.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 +104 -18
- package/package.json +1 -1
- package/src/auto-refine.ts +6 -1
- package/src/config.ts +17 -0
- package/src/harness.ts +61 -2
- package/src/index.ts +16 -0
- package/src/outcome.ts +104 -0
- package/src/proposer.ts +174 -0
- package/src/refine.ts +86 -45
- package/src/store.ts +7 -0
package/README.md
CHANGED
|
@@ -16,8 +16,9 @@ with:
|
|
|
16
16
|
The durable markdown file (`.pi/harness-state.md`) is the composition seam,
|
|
17
17
|
and it is **two-way**: `/refine --commit` and `/harness export` write it;
|
|
18
18
|
`/harness import` parses it back and merges into the live store (offline edits
|
|
19
|
-
win on conflict), so refinements pi-reflect makes flow back online.
|
|
20
|
-
|
|
19
|
+
win on conflict), so refinements pi-reflect makes flow back online. `/harness push-mem`
|
|
20
|
+
pushes active items into pi-mem's semantic store (see
|
|
21
|
+
[Composing with pi-mem](#composing-with-pi-mem)).
|
|
21
22
|
|
|
22
23
|
## Why
|
|
23
24
|
|
|
@@ -40,7 +41,7 @@ leaving offline and storage to the packages that already do them well.
|
|
|
40
41
|
## Install
|
|
41
42
|
|
|
42
43
|
```
|
|
43
|
-
pi install
|
|
44
|
+
pi install npm:pi-continual-harness
|
|
44
45
|
```
|
|
45
46
|
|
|
46
47
|
Or drop `src/index.ts` into `~/.pi/agent/extensions/`.
|
|
@@ -51,6 +52,7 @@ Or drop `src/index.ts` into `~/.pi/agent/extensions/`.
|
|
|
51
52
|
/refine # review last 25 turns, propose deltas
|
|
52
53
|
/refine 50 # review last 50 turns
|
|
53
54
|
/refine 25 --commit # also export durable state to ~/.pi/agent/harness-state.md
|
|
55
|
+
/refine --proposer dedupe # run the rule-based dedupe proposer instead of steering
|
|
54
56
|
```
|
|
55
57
|
|
|
56
58
|
Durable I/O (round-trip with pi-reflect):
|
|
@@ -62,6 +64,7 @@ Durable I/O (round-trip with pi-reflect):
|
|
|
62
64
|
/harness prune [--decay <days>] # drop items below the importance floor
|
|
63
65
|
/harness keep <id> # nudge importance up (+0.1)
|
|
64
66
|
/harness drop <id> # nudge importance down (−0.1)
|
|
67
|
+
/harness push-mem [--all|--kind <kind>] # persist active items to pi-mem (save_memory)
|
|
65
68
|
```
|
|
66
69
|
|
|
67
70
|
`import` reconciles the file into the live store: items whose id matches an
|
|
@@ -83,6 +86,24 @@ The model-facing tools:
|
|
|
83
86
|
Active items are injected into the system prompt each turn as a structured
|
|
84
87
|
block, appended to (never replacing) the base prompt.
|
|
85
88
|
|
|
89
|
+
## Composing with pi-mem
|
|
90
|
+
|
|
91
|
+
`/harness push-mem` copies active harness items into
|
|
92
|
+
[pi-mem](https://github.com/georgebashi/pi-mem)'s semantic memory store, so they
|
|
93
|
+
become searchable across sessions. It works by **steering** the agent to call
|
|
94
|
+
pi-mem's `save_memory` tool — there is **no dependency** on pi-mem: it is a
|
|
95
|
+
soft-fail composition. If pi-mem (or any memory tool) is not installed, the
|
|
96
|
+
agent tells you so rather than fabricating one.
|
|
97
|
+
|
|
98
|
+
```
|
|
99
|
+
pi install npm:pi-mem # optional companion
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
By default only **memory**-kind items are pushed (the clean 1:1 mapping); use
|
|
103
|
+
`--all` for every active item or `--kind prompt|skill|subagent` for a specific
|
|
104
|
+
kind. The harness store itself is unchanged by a push — pi-mem gets a separate
|
|
105
|
+
copy.
|
|
106
|
+
|
|
86
107
|
## Configuration
|
|
87
108
|
|
|
88
109
|
Optional config at `~/.pi/agent/harness.json` (missing or malformed → defaults):
|
|
@@ -90,8 +111,10 @@ Optional config at `~/.pi/agent/harness.json` (missing or malformed → defaults
|
|
|
90
111
|
```json
|
|
91
112
|
{
|
|
92
113
|
"durableScope": "global",
|
|
114
|
+
"proposer": "steering",
|
|
93
115
|
"remindRefine": { "enabled": false, "everyTurns": 50 },
|
|
94
|
-
"autoRefine": { "enabled": false, "everyTurns": 100, "commit": false }
|
|
116
|
+
"autoRefine": { "enabled": false, "everyTurns": 100, "commit": false },
|
|
117
|
+
"outcomeImportance": { "enabled": false, "bump": 0.03 }
|
|
95
118
|
}
|
|
96
119
|
```
|
|
97
120
|
|
|
@@ -99,29 +122,84 @@ Optional config at `~/.pi/agent/harness.json` (missing or malformed → defaults
|
|
|
99
122
|
`~/.pi/agent/harness-state.md`; `"project"` writes to
|
|
100
123
|
`~/.pi/agent/harness-state/<slug>.md` (slug derived from `cwd`) so each
|
|
101
124
|
project keeps separate state for pi-reflect.
|
|
125
|
+
- **`proposer`** — which delta proposer `/refine` and auto-refine use. Defaults
|
|
126
|
+
to `steering` (the agent reasons via a steering message). `dedupe` applies a
|
|
127
|
+
rule-based dedupe directly. See [Proposers](#proposers).
|
|
102
128
|
- **`remindRefine`** — opt-in `turn_end` nudge. `{ "enabled": true,
|
|
103
129
|
"everyTurns": 50 }` notifies you to run `/refine` on a cadence. It is
|
|
104
130
|
informational only — it never mutates state.
|
|
105
131
|
- **`autoRefine`** — opt-in autonomous self-improvement (**off by default**).
|
|
106
132
|
When `enabled`, the agent runs `/refine` itself every `everyTurns` turns
|
|
107
|
-
(default 100).
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
133
|
+
(default 100). It is one of the package's opt-in autonomous paths: it reuses
|
|
134
|
+
the exact `/refine` routine (audited `REFINE_ENTRY` tagged `source: "auto"`,
|
|
135
|
+
branch-local, `/tree` rollback) and notifies before firing. `commit: true`
|
|
136
|
+
also flushes durable state on each run.
|
|
137
|
+
- **`outcomeImportance`** — opt-in autonomous **promotion** loop (**off by
|
|
138
|
+
default**). When `enabled`, a `turn_end` hook bumps (+`bump`, default 0.03)
|
|
139
|
+
the importance of any active item the agent cited by its `[h_xxxx]` tag in the
|
|
140
|
+
turn's output. Referenced items gain importance and get their `updatedAt`
|
|
141
|
+
touched (so they survive time-based decay); ignored items keep decaying. This
|
|
142
|
+
is the package's second opt-in autonomous path — promotion only, never
|
|
143
|
+
deletes, persisted/branchable like `harness_mutate`. Autonomous demotion from
|
|
144
|
+
outcomes is intentionally NOT done (high false-positive); use `/harness drop`,
|
|
145
|
+
`prune --decay`, or the `dedupe` proposer for that.
|
|
111
146
|
|
|
112
147
|
## How it works
|
|
113
148
|
|
|
114
149
|
1. `/refine` gathers recent trajectory evidence from the current session branch.
|
|
115
|
-
2.
|
|
116
|
-
CRUD deltas via
|
|
117
|
-
|
|
118
|
-
|
|
150
|
+
2. A **proposer** decides what to do. The default (`steering`) sends a steering
|
|
151
|
+
user message asking the agent to propose evidence-backed CRUD deltas via
|
|
152
|
+
`harness_mutate`; rule-based proposers (e.g. `dedupe`) return deltas the
|
|
153
|
+
harness applies directly. See [Proposers](#proposers).
|
|
154
|
+
3. The agent calls the tools (steering path); each accepted delta updates the
|
|
155
|
+
in-memory state, which is snapshotted to the session via
|
|
156
|
+
`appendEntry("harness-state", ...)`. Direct-apply proposers snapshot the
|
|
157
|
+
same way.
|
|
119
158
|
4. Because pi's session tree branches at any entry, `/tree` navigation gives
|
|
120
159
|
rollback to any pre-refinement point for free — no bespoke snapshot system.
|
|
121
160
|
|
|
122
161
|
This reuses the existing agent loop (no nested/hidden model calls), is
|
|
123
162
|
model-agnostic, and keeps every delta visible and reviewable in the transcript.
|
|
124
163
|
|
|
164
|
+
## Proposers
|
|
165
|
+
|
|
166
|
+
`/refine` is split into two stages: **propose** (given evidence + state, decide
|
|
167
|
+
what deltas to pursue) and **apply** (send a steering message, or apply returned
|
|
168
|
+
deltas directly). The propose stage is pluggable via a registry
|
|
169
|
+
(`src/proposer.ts`).
|
|
170
|
+
|
|
171
|
+
| Name | What it does |
|
|
172
|
+
|---|---|
|
|
173
|
+
| `steering` (default) | Delegates reasoning to the agent via a steering message — reuses the agent loop, model-agnostic, fully visible. |
|
|
174
|
+
| `dedupe` | Rule-based: drops near-duplicate active items (token-overlap ≥ 0.6), keeping the higher-importance one. No model call. |
|
|
175
|
+
|
|
176
|
+
Select a proposer per run with `/refine --proposer <name>`, or set the default
|
|
177
|
+
for auto-refine via `proposer` in the [config](#configuration). Both paths are
|
|
178
|
+
audited: the `harness-refinement` entry records which proposer ran and how many
|
|
179
|
+
deltas it applied directly.
|
|
180
|
+
|
|
181
|
+
A dedicated-model proposer (a hidden LLM call) is the obvious next alternate
|
|
182
|
+
the interface supports, but is intentionally not shipped — non-visible model
|
|
183
|
+
spend is a tradeoff kept as a separate decision (see `docs/ROADMAP.md`).
|
|
184
|
+
|
|
185
|
+
Register your own proposer from another extension (the registry is re-exported
|
|
186
|
+
from the package entry):
|
|
187
|
+
|
|
188
|
+
```ts
|
|
189
|
+
import { registerProposer } from "pi-continual-harness";
|
|
190
|
+
|
|
191
|
+
registerProposer({
|
|
192
|
+
name: "my-proposer",
|
|
193
|
+
async propose({ evidence, state }) {
|
|
194
|
+
/* inspect evidence + state, return deltas (and/or a steering message) */
|
|
195
|
+
return { deltas: [/* { delta, rationale } */] };
|
|
196
|
+
},
|
|
197
|
+
});
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
Then `"my-proposer"` is selectable via `/refine --proposer my-proposer` or
|
|
201
|
+
`"proposer": "my-proposer"` in the config.
|
|
202
|
+
|
|
125
203
|
## Scope and non-goals
|
|
126
204
|
|
|
127
205
|
- **In scope:** unified state store, online `/refine`, structured deltas,
|
|
@@ -132,22 +210,30 @@ model-agnostic, and keeps every delta visible and reviewable in the transcript.
|
|
|
132
210
|
|
|
133
211
|
## Status
|
|
134
212
|
|
|
135
|
-
0.
|
|
213
|
+
0.5.x. Implemented:
|
|
136
214
|
|
|
137
215
|
- Unified harness-state store with branch-local snapshots (`/tree` rollback).
|
|
138
216
|
- Online `/refine` + `harness_mutate` / `harness_list` tools.
|
|
139
217
|
- Two-way durable round-trip with pi-reflect (`/harness import|export|status`).
|
|
140
218
|
- Importance hygiene: `/harness prune [--decay <days>]` and `/harness keep|drop
|
|
141
219
|
<id>`.
|
|
220
|
+
- **pi-mem composition**: `/harness push-mem [--all|--kind]` steers the agent
|
|
221
|
+
to persist active items into pi-mem (soft-fail; no dependency).
|
|
142
222
|
- Optional config (`~/.pi/agent/harness.json`): project-local durable scope, an
|
|
143
|
-
opt-in `turn_end` reminder,
|
|
144
|
-
|
|
223
|
+
opt-in `turn_end` reminder, opt-in `turn_end` auto-refine, and an opt-in
|
|
224
|
+
`turn_end` outcome-importance loop — the package's two opt-in autonomous
|
|
225
|
+
paths (both off by default).
|
|
226
|
+
- **Pluggable delta proposers** with a registry: `steering` (default) and
|
|
227
|
+
`dedupe` (rule-based) shipped; `registerProposer()` for custom ones.
|
|
145
228
|
|
|
146
229
|
Open extension points (see `docs/ROADMAP.md`):
|
|
147
230
|
|
|
148
|
-
- A
|
|
149
|
-
|
|
150
|
-
-
|
|
231
|
+
- A dedicated-model proposer (interface ready; the hidden-model-spend tradeoff
|
|
232
|
+
is a separate decision).
|
|
233
|
+
- Correction-side outcome signals (promotion is shipped; autonomous demotion
|
|
234
|
+
is high-false-positive, so it is served by `/harness drop`, `prune --decay`,
|
|
235
|
+
and the `dedupe` proposer — a fuzzy `corrections` proposer is the natural
|
|
236
|
+
future extension).
|
|
151
237
|
|
|
152
238
|
## License
|
|
153
239
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-continual-harness",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
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
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
package/src/auto-refine.ts
CHANGED
|
@@ -53,7 +53,12 @@ export function registerAutoRefine(pi: ExtensionAPI): void {
|
|
|
53
53
|
const every = config.autoRefine?.everyTurns ?? DEFAULT_AUTO_EVERY_TURNS;
|
|
54
54
|
ctx.ui.notify(`Auto-refine: running /refine (every ${every} turns, opt-in).`, "info");
|
|
55
55
|
try {
|
|
56
|
-
await runRefine(
|
|
56
|
+
await runRefine(
|
|
57
|
+
pi,
|
|
58
|
+
ctx,
|
|
59
|
+
{ commit: config.autoRefine?.commit ?? false, ...(config.proposer ? { proposer: config.proposer } : {}) },
|
|
60
|
+
"auto",
|
|
61
|
+
);
|
|
57
62
|
} catch (err) {
|
|
58
63
|
ctx.ui.notify(`Auto-refine failed: ${(err as Error).message}`, "error");
|
|
59
64
|
}
|
package/src/config.ts
CHANGED
|
@@ -17,6 +17,8 @@ import { DEFAULT_DURABLE_PATH } from "./store.js";
|
|
|
17
17
|
|
|
18
18
|
export const DEFAULT_EVERY_TURNS = 50;
|
|
19
19
|
export const DEFAULT_AUTO_EVERY_TURNS = 100;
|
|
20
|
+
/** Default per-reference importance bump for the opt-in outcome loop. */
|
|
21
|
+
export const DEFAULT_REF_BUMP = 0.03;
|
|
20
22
|
|
|
21
23
|
export interface HarnessConfig {
|
|
22
24
|
durableScope?: "global" | "project";
|
|
@@ -29,12 +31,22 @@ export interface HarnessConfig {
|
|
|
29
31
|
everyTurns?: number;
|
|
30
32
|
commit?: boolean;
|
|
31
33
|
};
|
|
34
|
+
/** Delta proposer name (see proposer.ts registry). Defaults to "steering". */
|
|
35
|
+
proposer?: string;
|
|
36
|
+
/** Opt-in turn_end outcome loop: promote importance of items the agent
|
|
37
|
+
* references by their [h_xxxx] tag. Off by default (autonomous mutation). */
|
|
38
|
+
outcomeImportance?: {
|
|
39
|
+
enabled?: boolean;
|
|
40
|
+
bump?: number;
|
|
41
|
+
};
|
|
32
42
|
}
|
|
33
43
|
|
|
34
44
|
export const DEFAULT_CONFIG: HarnessConfig = {
|
|
35
45
|
durableScope: "global",
|
|
36
46
|
remindRefine: { enabled: false, everyTurns: DEFAULT_EVERY_TURNS },
|
|
37
47
|
autoRefine: { enabled: false, everyTurns: DEFAULT_AUTO_EVERY_TURNS, commit: false },
|
|
48
|
+
proposer: "steering",
|
|
49
|
+
outcomeImportance: { enabled: false, bump: DEFAULT_REF_BUMP },
|
|
38
50
|
};
|
|
39
51
|
|
|
40
52
|
export const CONFIG_PATH = join(homedir(), ".pi", "agent", "harness.json");
|
|
@@ -54,6 +66,11 @@ function mergeConfig(over: Partial<HarnessConfig>): HarnessConfig {
|
|
|
54
66
|
everyTurns: over.autoRefine?.everyTurns ?? DEFAULT_AUTO_EVERY_TURNS,
|
|
55
67
|
commit: over.autoRefine?.commit ?? false,
|
|
56
68
|
},
|
|
69
|
+
proposer: over.proposer ?? "steering",
|
|
70
|
+
outcomeImportance: {
|
|
71
|
+
enabled: over.outcomeImportance?.enabled ?? false,
|
|
72
|
+
bump: over.outcomeImportance?.bump ?? DEFAULT_REF_BUMP,
|
|
73
|
+
},
|
|
57
74
|
};
|
|
58
75
|
}
|
|
59
76
|
|
package/src/harness.ts
CHANGED
|
@@ -22,14 +22,15 @@ import {
|
|
|
22
22
|
reconstructFromDurable,
|
|
23
23
|
} from "./store.js";
|
|
24
24
|
import { loadConfig, resolveDurablePath } from "./config.js";
|
|
25
|
-
import type { ComponentKind } from "./types.js";
|
|
25
|
+
import type { ComponentKind, HarnessItem } from "./types.js";
|
|
26
26
|
|
|
27
27
|
export function registerHarness(pi: ExtensionAPI): void {
|
|
28
28
|
pi.registerCommand("harness", {
|
|
29
29
|
description:
|
|
30
30
|
"Durable harness-state I/O + importance hygiene. Subcommands: " +
|
|
31
31
|
"import [--prune] [path] · export [path] · status [path] · " +
|
|
32
|
-
"prune [--decay <days>] · keep <id> · drop <id
|
|
32
|
+
"prune [--decay <days>] · keep <id> · drop <id> · " +
|
|
33
|
+
"push-mem [--all|--kind <kind>] (persist active items to pi-mem).",
|
|
33
34
|
handler: async (args, ctx) => {
|
|
34
35
|
const parts = args.trim().split(/\s+/).filter(Boolean);
|
|
35
36
|
const sub = (parts[0] ?? "status").toLowerCase();
|
|
@@ -50,6 +51,9 @@ export function registerHarness(pi: ExtensionAPI): void {
|
|
|
50
51
|
case "drop":
|
|
51
52
|
await handleBump(pi, ctx, rest, -0.1, "drop");
|
|
52
53
|
return;
|
|
54
|
+
case "push-mem":
|
|
55
|
+
await handlePushMem(pi, ctx, rest);
|
|
56
|
+
return;
|
|
53
57
|
case "status":
|
|
54
58
|
default:
|
|
55
59
|
await handleStatus(ctx, rest);
|
|
@@ -160,6 +164,61 @@ async function handleBump(
|
|
|
160
164
|
ctx.ui.notify(`${label}: "${preview}" → importance ${item.importance.toFixed(2)}`, "info");
|
|
161
165
|
}
|
|
162
166
|
|
|
167
|
+
/** Compose a steering message that asks the agent to persist the given active
|
|
168
|
+
* items to long-term memory via a memory tool (pi-mem's save_memory).
|
|
169
|
+
* Tool-agnostic: if no memory tool is present the agent says so; we never
|
|
170
|
+
* fabricate one. No dependency on pi-mem — soft-fail composition. */
|
|
171
|
+
function buildPushMemPrompt(items: HarnessItem[], scope: string): string {
|
|
172
|
+
const lines = [
|
|
173
|
+
`/harness push-mem — persist ${items.length} Continual Harness ${scope} to long-term memory`,
|
|
174
|
+
"",
|
|
175
|
+
"For EACH item below, call your memory tool (pi-mem exposes `save_memory`) once, mapping it as shown. This copies harness state into the semantic memory store so it is searchable across sessions.",
|
|
176
|
+
"",
|
|
177
|
+
"If you do NOT have a memory tool, do not fabricate one: tell the user to install pi-mem (`pi install npm:pi-mem`) and stop.",
|
|
178
|
+
"",
|
|
179
|
+
];
|
|
180
|
+
items.forEach((i, n) => {
|
|
181
|
+
const title = `${i.id} ${i.content.slice(0, 48)}`;
|
|
182
|
+
const text = `${i.content} (evidence: ${i.evidence})`;
|
|
183
|
+
lines.push(
|
|
184
|
+
`${n + 1}. [${i.id}] ${i.content}`,
|
|
185
|
+
` evidence: ${i.evidence}`,
|
|
186
|
+
` → save_memory({ title: ${JSON.stringify(title)}, text: ${JSON.stringify(text)}, concepts: ["${i.kind}", "continual-harness"] })`,
|
|
187
|
+
"",
|
|
188
|
+
);
|
|
189
|
+
});
|
|
190
|
+
return lines.join("\n");
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async function handlePushMem(
|
|
194
|
+
pi: ExtensionAPI,
|
|
195
|
+
ctx: ExtensionCommandContext,
|
|
196
|
+
rest: string[],
|
|
197
|
+
): Promise<void> {
|
|
198
|
+
const all = rest.includes("--all");
|
|
199
|
+
const kindIdx = rest.indexOf("--kind");
|
|
200
|
+
let kind: ComponentKind | undefined;
|
|
201
|
+
if (kindIdx >= 0 && kindIdx + 1 < rest.length) {
|
|
202
|
+
const k = rest[kindIdx + 1]!;
|
|
203
|
+
if (k === "prompt" || k === "memory" || k === "skill" || k === "subagent") kind = k;
|
|
204
|
+
}
|
|
205
|
+
// Default: memory kind (the clean 1:1 mapping). --all or --kind override.
|
|
206
|
+
const items = getState().items.filter((i) =>
|
|
207
|
+
i.active && (all || (kind ? i.kind === kind : i.kind === "memory")),
|
|
208
|
+
);
|
|
209
|
+
if (items.length === 0) {
|
|
210
|
+
ctx.ui.notify(
|
|
211
|
+
"No active items to push (default: memory kind; use --all or --kind <kind>).",
|
|
212
|
+
"warning",
|
|
213
|
+
);
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
const scope = all ? "item(s)" : `${kind ?? "memory"} item(s)`;
|
|
217
|
+
const msg = buildPushMemPrompt(items, scope);
|
|
218
|
+
pi.sendUserMessage(msg);
|
|
219
|
+
ctx.ui.notify(`Steering agent to persist ${items.length} ${scope} to pi-mem.`, "info");
|
|
220
|
+
}
|
|
221
|
+
|
|
163
222
|
async function handleStatus(ctx: ExtensionCommandContext, rest: string[]): Promise<void> {
|
|
164
223
|
const path = await resolvePath(rest, ctx);
|
|
165
224
|
const items = getState().items;
|
package/src/index.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { registerRefine } from "./refine.js";
|
|
|
13
13
|
import { registerTools } from "./tools.js";
|
|
14
14
|
import { registerReminder } from "./remind.js";
|
|
15
15
|
import { registerAutoRefine } from "./auto-refine.js";
|
|
16
|
+
import { registerOutcome } from "./outcome.js";
|
|
16
17
|
import { getState, reconstruct, STATE_ENTRY } from "./store.js";
|
|
17
18
|
|
|
18
19
|
export default function continualHarness(pi: ExtensionAPI): void {
|
|
@@ -32,7 +33,22 @@ export default function continualHarness(pi: ExtensionAPI): void {
|
|
|
32
33
|
registerRefine(pi);
|
|
33
34
|
registerHarness(pi);
|
|
34
35
|
registerReminder(pi);
|
|
36
|
+
// Register outcome BEFORE auto-refine: the test fake overwrites turn_end on
|
|
37
|
+
// each registration (Map.set), and the auto-refine integration tests assume
|
|
38
|
+
// auto-refine is the surviving handler. In production pi, all three run.
|
|
39
|
+
registerOutcome(pi);
|
|
35
40
|
registerAutoRefine(pi);
|
|
36
41
|
}
|
|
37
42
|
|
|
38
43
|
export { STATE_ENTRY };
|
|
44
|
+
|
|
45
|
+
// Public extension API: other extensions can register their own delta proposer
|
|
46
|
+
// (see src/proposer.ts) and it becomes selectable via /refine --proposer <name>
|
|
47
|
+
// or the `proposer` config key.
|
|
48
|
+
export {
|
|
49
|
+
listProposers,
|
|
50
|
+
registerProposer,
|
|
51
|
+
type DeltaProposer,
|
|
52
|
+
type ProposeInput,
|
|
53
|
+
type ProposeResult,
|
|
54
|
+
} from "./proposer.js";
|
package/src/outcome.ts
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// turn_end outcome loop (Phase 5 / limit B-phase-2). The package's SECOND
|
|
2
|
+
// autonomous-mutation path (after auto-refine), so — like auto-refine — it is
|
|
3
|
+
// locked behind an explicit opt-in:
|
|
4
|
+
//
|
|
5
|
+
// ~/.pi/agent/harness.json
|
|
6
|
+
// { "outcomeImportance": { "enabled": true, "bump": 0.03 } }
|
|
7
|
+
//
|
|
8
|
+
// What it does: when the agent's own output cites an injected item by its
|
|
9
|
+
// [h_xxxx] tag, that is unambiguous POSITIVE evidence the item was useful, so
|
|
10
|
+
// we nudge its importance up (+bump). This closes the outcome half of the
|
|
11
|
+
// fitness loop that manual /harness keep|drop (B-phase-1) and /harness prune
|
|
12
|
+
// --decay (A) started: useful items gain importance AND get their updatedAt
|
|
13
|
+
// touched (so they survive time-based decay); ignored items keep decaying.
|
|
14
|
+
//
|
|
15
|
+
// Hard scope cut — CORRECTION/demotion from outcomes is genuinely fuzzy and
|
|
16
|
+
// high-false-positive, so it is intentionally NOT autonomized here. Demotion is
|
|
17
|
+
// served by the existing audited, reviewable primitives: /harness drop,
|
|
18
|
+
// /harness prune --decay, and the dedupe proposer. (See ROADMAP.md.)
|
|
19
|
+
//
|
|
20
|
+
// Safety properties (promotion only, never deletes): clamped to [0,1]; persisted
|
|
21
|
+
// via the same harness-state entries as harness_mutate (branch-local, /tree
|
|
22
|
+
// rollback); visible (notify per turn). Pure reference extraction is factored
|
|
23
|
+
// into findReferences() so it is unit-testable without a live pi runtime.
|
|
24
|
+
|
|
25
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
26
|
+
import { DEFAULT_REF_BUMP, type HarnessConfig, loadConfig } from "./config.js";
|
|
27
|
+
import { bumpImportance, getState } from "./store.js";
|
|
28
|
+
|
|
29
|
+
// Minimal entry shape; kept loose to avoid coupling to pi's internal types.
|
|
30
|
+
type AnyEntry = {
|
|
31
|
+
type?: string;
|
|
32
|
+
message?: {
|
|
33
|
+
role?: string;
|
|
34
|
+
content?: Array<{ type?: string; text?: string }>;
|
|
35
|
+
};
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
// Matches [h_xxxx] citation tags (the same form inject.ts emits).
|
|
39
|
+
const REF_RE = /\[(h_[a-z0-9_]+)\]/gi;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Extract distinct harness-item ids the agent cited by their [h_xxxx] tag,
|
|
43
|
+
* keeping only those that are currently known (active or not). Pure.
|
|
44
|
+
*/
|
|
45
|
+
export function findReferences(text: string, knownIds: Set<string>): string[] {
|
|
46
|
+
const found = new Set<string>();
|
|
47
|
+
for (const m of text.matchAll(REF_RE)) {
|
|
48
|
+
const id = m[1]!.toLowerCase();
|
|
49
|
+
if (knownIds.has(id)) found.add(id);
|
|
50
|
+
}
|
|
51
|
+
return [...found];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Number of assistant message-entries already consumed. -1 = unseen; the first
|
|
55
|
+
// observed turn_end seeds the cursor (so enabling mid-session never bumps
|
|
56
|
+
// retroactively for the whole history).
|
|
57
|
+
let scannedMessages = -1;
|
|
58
|
+
|
|
59
|
+
/** Test hook: reset internal cursor. */
|
|
60
|
+
export function resetOutcome(): void {
|
|
61
|
+
scannedMessages = -1;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Subscribe to turn_end and promote importance of referenced items (opt-in). */
|
|
65
|
+
export function registerOutcome(pi: ExtensionAPI): void {
|
|
66
|
+
pi.on("turn_end", async (_event, ctx) => {
|
|
67
|
+
const config = await loadConfig();
|
|
68
|
+
if (!config.outcomeImportance?.enabled) return;
|
|
69
|
+
const bump = config.outcomeImportance.bump ?? DEFAULT_REF_BUMP;
|
|
70
|
+
if (bump <= 0) return;
|
|
71
|
+
|
|
72
|
+
const entries = ctx.sessionManager.getBranch() as AnyEntry[];
|
|
73
|
+
const asst = entries.filter((e) => e.type === "message" && e.message?.role === "assistant");
|
|
74
|
+
if (scannedMessages < 0) {
|
|
75
|
+
scannedMessages = asst.length; // seed: no retroactive bump
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
const fresh = asst.slice(scannedMessages);
|
|
79
|
+
scannedMessages = asst.length;
|
|
80
|
+
|
|
81
|
+
const text = fresh
|
|
82
|
+
.map((e) => (e.message?.content ?? []).map((c) => c.text ?? "").join(" "))
|
|
83
|
+
.join("\n");
|
|
84
|
+
if (!text.trim()) return;
|
|
85
|
+
|
|
86
|
+
const activeIds = new Set(getState().items.filter((i) => i.active).map((i) => i.id));
|
|
87
|
+
const refs = findReferences(text, activeIds);
|
|
88
|
+
if (refs.length === 0) return;
|
|
89
|
+
|
|
90
|
+
let bumped = 0;
|
|
91
|
+
for (const id of refs) {
|
|
92
|
+
const item = bumpImportance(id, bump, (snapshot, ver) => {
|
|
93
|
+
pi.appendEntry("harness-state", { state: snapshot, version: ver });
|
|
94
|
+
});
|
|
95
|
+
if (item) bumped += 1;
|
|
96
|
+
}
|
|
97
|
+
if (bumped > 0) {
|
|
98
|
+
ctx.ui.notify(
|
|
99
|
+
`Outcome: +${bump} importance for ${bumped} referenced item(s): ${refs.join(", ")}`,
|
|
100
|
+
"info",
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
}
|
package/src/proposer.ts
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
// Pluggable delta proposers (Phase 4 of the roadmap).
|
|
2
|
+
//
|
|
3
|
+
// /refine is split into two stages:
|
|
4
|
+
// 1. PROPOSE — given trajectory evidence + current state, decide what deltas
|
|
5
|
+
// to apply (or what to ask the agent to do).
|
|
6
|
+
// 2. APPLY — either send a steering message (the agent reasons + calls
|
|
7
|
+
// harness_mutate) or apply returned deltas directly.
|
|
8
|
+
//
|
|
9
|
+
// This file owns stage 1. The default `steeringProposer` preserves the
|
|
10
|
+
// original behavior exactly (delegates to the agent via a steering message,
|
|
11
|
+
// reusing the agent loop — model-agnostic, fully visible). Alternate proposers
|
|
12
|
+
// produce deltas directly:
|
|
13
|
+
// - `dedupeProposer` (rule-based): drops near-duplicate active items by token
|
|
14
|
+
// overlap, keeping the higher-importance one. Pure function of state —
|
|
15
|
+
// deterministic, no model call, fully testable.
|
|
16
|
+
//
|
|
17
|
+
// A dedicated-model proposer (a hidden LLM call) is the obvious next alternate
|
|
18
|
+
// the interface supports, but is intentionally NOT shipped here: it is the one
|
|
19
|
+
// tradeoff (non-visible model spend) the roadmap flags as a separate decision.
|
|
20
|
+
//
|
|
21
|
+
// Extension is open: call registerProposer() from your own extension to add a
|
|
22
|
+
// named proposer, then select it via /refine --proposer <name> or config.
|
|
23
|
+
|
|
24
|
+
import type { Delta, HarnessItem, HarnessState } from "./types.js";
|
|
25
|
+
|
|
26
|
+
/** Inputs to a proposer. */
|
|
27
|
+
export interface ProposeInput {
|
|
28
|
+
/** Recent trajectory evidence (the same string /refine gathers). */
|
|
29
|
+
evidence: string;
|
|
30
|
+
/** Current harness state as a defensive copy; mutations are ignored — use
|
|
31
|
+
* the returned deltas to change state. */
|
|
32
|
+
state: HarnessState;
|
|
33
|
+
/** Lookback window in turns. */
|
|
34
|
+
lookback: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** A single delta plus a human-readable reason for the audit trail. */
|
|
38
|
+
export interface ProposedDelta {
|
|
39
|
+
delta: Delta;
|
|
40
|
+
rationale: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface ProposeResult {
|
|
44
|
+
/** Deltas the proposer produced directly (rule-based / model proposers). */
|
|
45
|
+
deltas?: ProposedDelta[];
|
|
46
|
+
/**
|
|
47
|
+
* A steering message for the agent, when the proposer delegates reasoning to
|
|
48
|
+
* the agent loop (the default). runRefine sends this via sendUserMessage.
|
|
49
|
+
* A proposer MAY return both — deltas are applied first, then steering sent.
|
|
50
|
+
*/
|
|
51
|
+
steeringMessage?: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** A strategy for turning evidence + state into (or toward) harness deltas. */
|
|
55
|
+
export interface DeltaProposer {
|
|
56
|
+
readonly name: string;
|
|
57
|
+
propose(input: ProposeInput): Promise<ProposeResult>;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ---- default: steering (delegates reasoning to the agent) ------------------
|
|
61
|
+
|
|
62
|
+
function buildSteeringPrompt(evidence: string, lookback: number): string {
|
|
63
|
+
return [
|
|
64
|
+
`/refine (online self-improvement, last ${lookback} turns as evidence)`,
|
|
65
|
+
"",
|
|
66
|
+
"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.",
|
|
67
|
+
"",
|
|
68
|
+
"Steps:",
|
|
69
|
+
"1. Call harness_list to see the current state.",
|
|
70
|
+
"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.",
|
|
71
|
+
"3. Keep prompt notes terse and behavioral. Memory facts should be specific. Skill/sub-agent entries should describe reusable patterns, not one task.",
|
|
72
|
+
"",
|
|
73
|
+
"Constraints: never rewrite the whole store; ground every change in evidence; if nothing durable is worth recording, say so and do nothing.",
|
|
74
|
+
"",
|
|
75
|
+
"## Trajectory evidence",
|
|
76
|
+
evidence,
|
|
77
|
+
].join("\n");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Default proposer: delegates reasoning to the agent via a steering message. */
|
|
81
|
+
export const steeringProposer: DeltaProposer = {
|
|
82
|
+
name: "steering",
|
|
83
|
+
async propose({ evidence, lookback }): Promise<ProposeResult> {
|
|
84
|
+
return { steeringMessage: buildSteeringPrompt(evidence, lookback) };
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
// ---- rule-based alternate: dedupe -----------------------------------------
|
|
89
|
+
|
|
90
|
+
export const DEDUPE_THRESHOLD = 0.6;
|
|
91
|
+
|
|
92
|
+
/** Tokenize for overlap comparison: lowercase alnum tokens. */
|
|
93
|
+
export function tokenize(text: string): string[] {
|
|
94
|
+
return text.toLowerCase().replace(/[^a-z0-9\s]/g, " ").split(/\s+/).filter(Boolean);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Jaccard token overlap in [0,1]. 0 if either side has no tokens. */
|
|
98
|
+
export function tokenOverlap(a: string, b: string): number {
|
|
99
|
+
const sa = new Set(tokenize(a));
|
|
100
|
+
const sb = new Set(tokenize(b));
|
|
101
|
+
if (sa.size === 0 || sb.size === 0) return 0;
|
|
102
|
+
let inter = 0;
|
|
103
|
+
for (const t of sa) if (sb.has(t)) inter++;
|
|
104
|
+
return inter / (sa.size + sb.size - inter);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function truncate(s: string, n = 48): string {
|
|
108
|
+
return s.length > n ? `${s.slice(0, n - 1)}…` : s;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Rule-based proposer: drops near-duplicate ACTIVE items (same kind, Jaccard
|
|
113
|
+
* token overlap >= DEDUPE_THRESHOLD), keeping the higher-importance one.
|
|
114
|
+
*
|
|
115
|
+
* Greedy and contradiction-free: items are considered in importance-descending
|
|
116
|
+
* order; the first item is always a keeper, and each later item is compared
|
|
117
|
+
* only against keepers, so a keeper is never subsequently dropped.
|
|
118
|
+
*/
|
|
119
|
+
export const dedupeProposer: DeltaProposer = {
|
|
120
|
+
name: "dedupe",
|
|
121
|
+
async propose({ state }): Promise<ProposeResult> {
|
|
122
|
+
const ordered = state.items
|
|
123
|
+
.filter((i) => i.active)
|
|
124
|
+
.sort((a, b) => b.importance - a.importance);
|
|
125
|
+
const keepers: HarnessItem[] = [];
|
|
126
|
+
const proposals: ProposedDelta[] = [];
|
|
127
|
+
for (const cand of ordered) {
|
|
128
|
+
let dup: HarnessItem | undefined;
|
|
129
|
+
let best = 0;
|
|
130
|
+
for (const k of keepers) {
|
|
131
|
+
if (k.kind !== cand.kind) continue;
|
|
132
|
+
const sim = tokenOverlap(k.content, cand.content);
|
|
133
|
+
if (sim >= DEDUPE_THRESHOLD && sim > best) {
|
|
134
|
+
best = sim;
|
|
135
|
+
dup = k;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
if (dup) {
|
|
139
|
+
proposals.push({
|
|
140
|
+
delta: {
|
|
141
|
+
op: "delete",
|
|
142
|
+
id: cand.id,
|
|
143
|
+
reason: `near-duplicate of ${dup.id} (overlap ${best.toFixed(2)})`,
|
|
144
|
+
},
|
|
145
|
+
rationale: `dedupe: "${truncate(cand.content)}" ≈ keeper "${truncate(dup.content)}" (Jaccard ${best.toFixed(2)}); kept higher-importance ${dup.id}.`,
|
|
146
|
+
});
|
|
147
|
+
} else {
|
|
148
|
+
keepers.push(cand);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return proposals.length ? { deltas: proposals } : {};
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
// ---- registry -------------------------------------------------------------
|
|
156
|
+
|
|
157
|
+
const registry = new Map<string, DeltaProposer>([
|
|
158
|
+
["steering", steeringProposer],
|
|
159
|
+
["dedupe", dedupeProposer],
|
|
160
|
+
]);
|
|
161
|
+
|
|
162
|
+
/** Register (or replace) a named proposer. For external extensions. */
|
|
163
|
+
export function registerProposer(p: DeltaProposer): void {
|
|
164
|
+
registry.set(p.name, p);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Look up a proposer by name; falls back to the steering default. */
|
|
168
|
+
export function getProposer(name: string | undefined): DeltaProposer {
|
|
169
|
+
return registry.get(name ?? "steering") ?? steeringProposer;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function listProposers(): string[] {
|
|
173
|
+
return [...registry.keys()];
|
|
174
|
+
}
|
package/src/refine.ts
CHANGED
|
@@ -1,19 +1,25 @@
|
|
|
1
|
-
// /refine — the online optimizer. Manual
|
|
1
|
+
// /refine — the online optimizer. Manual by default; opt-in turn_end
|
|
2
|
+
// auto-refine lives in auto-refine.ts and reuses runRefine().
|
|
2
3
|
//
|
|
3
4
|
// Flow:
|
|
4
5
|
// 1. Gather recent trajectory evidence from the current branch.
|
|
5
|
-
// 2.
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
6
|
+
// 2. Hand evidence + current state to a pluggable DeltaProposer (proposer.ts).
|
|
7
|
+
// The default `steering` proposer returns a steering user message; the
|
|
8
|
+
// agent then reasons and calls harness_mutate. Rule-based/model proposers
|
|
9
|
+
// return deltas directly, which runRefine applies itself.
|
|
10
|
+
// 3. Every mutation — whether from the agent (harness_mutate) or a direct-apply
|
|
11
|
+
// proposer — is recorded as a session entry, so /tree gives rollback for
|
|
12
|
+
// free. Which proposer ran is captured in the audit entry.
|
|
10
13
|
//
|
|
11
|
-
// Why a steering message instead of a nested LLM call: it reuses
|
|
12
|
-
// agent loop, is model-agnostic, and keeps every delta visible and
|
|
13
|
-
// in the transcript.
|
|
14
|
+
// Why a steering message as the DEFAULT instead of a nested LLM call: it reuses
|
|
15
|
+
// the existing agent loop, is model-agnostic, and keeps every delta visible and
|
|
16
|
+
// reviewable in the transcript. Alternate proposers can opt out via the
|
|
17
|
+
// registry; the dedicated-model variant is intentionally not shipped (hidden
|
|
18
|
+
// model spend is a tradeoff the roadmap keeps as a separate decision).
|
|
14
19
|
|
|
15
20
|
import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
16
|
-
import { exportDurable, REFINE_ENTRY } from "./store.js";
|
|
21
|
+
import { applyDeltas, exportDurable, REFINE_ENTRY, snapshotState } from "./store.js";
|
|
22
|
+
import { getProposer } from "./proposer.js";
|
|
17
23
|
|
|
18
24
|
const DEFAULT_EVIDENCE_BYTES = 16000;
|
|
19
25
|
export const DEFAULT_LOOKBACK_TURNS = 25;
|
|
@@ -32,10 +38,10 @@ type AnyEntry = {
|
|
|
32
38
|
export function registerRefine(pi: ExtensionAPI): void {
|
|
33
39
|
pi.registerCommand("refine", {
|
|
34
40
|
description:
|
|
35
|
-
"Online self-improvement: review recent trajectory and propose evidence-backed CRUD deltas to the harness state. Usage: /refine [lookback-turns] [--commit]",
|
|
41
|
+
"Online self-improvement: review recent trajectory and propose evidence-backed CRUD deltas to the harness state. Usage: /refine [lookback-turns] [--commit] [--proposer steering|dedupe]",
|
|
36
42
|
handler: async (args, ctx) => {
|
|
37
|
-
const { lookback, commit } = parseArgs(args);
|
|
38
|
-
await runRefine(pi, ctx, { lookback, commit });
|
|
43
|
+
const { lookback, commit, proposer } = parseArgs(args);
|
|
44
|
+
await runRefine(pi, ctx, { lookback, commit, ...(proposer ? { proposer } : {}) });
|
|
39
45
|
},
|
|
40
46
|
});
|
|
41
47
|
}
|
|
@@ -43,6 +49,8 @@ export function registerRefine(pi: ExtensionAPI): void {
|
|
|
43
49
|
export interface RefineOptions {
|
|
44
50
|
lookback?: number;
|
|
45
51
|
commit?: boolean;
|
|
52
|
+
/** Proposer name (see proposer.ts registry). Defaults to "steering". */
|
|
53
|
+
proposer?: string;
|
|
46
54
|
}
|
|
47
55
|
|
|
48
56
|
export interface RefineResult {
|
|
@@ -50,13 +58,18 @@ export interface RefineResult {
|
|
|
50
58
|
lookback: number;
|
|
51
59
|
commit: boolean;
|
|
52
60
|
source: "manual" | "auto";
|
|
61
|
+
/** Which proposer ran. */
|
|
62
|
+
proposer: string;
|
|
63
|
+
/** Deltas the proposer applied directly (0 for the steering path). */
|
|
64
|
+
applied: number;
|
|
53
65
|
}
|
|
54
66
|
|
|
55
|
-
/**
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
67
|
+
/**
|
|
68
|
+
* Core refine routine shared by /refine (manual) and turn_end auto-refine.
|
|
69
|
+
* Resolves a proposer, gathers evidence, lets the proposer decide what to do,
|
|
70
|
+
* then either applies returned deltas directly or sends a steering message.
|
|
71
|
+
* `source` tags the audit entry so autonomous runs are distinguishable.
|
|
72
|
+
*/
|
|
60
73
|
export async function runRefine(
|
|
61
74
|
pi: ExtensionAPI,
|
|
62
75
|
ctx: ExtensionContext,
|
|
@@ -65,12 +78,40 @@ export async function runRefine(
|
|
|
65
78
|
): Promise<RefineResult> {
|
|
66
79
|
const lookback = options.lookback ?? DEFAULT_LOOKBACK_TURNS;
|
|
67
80
|
const commit = options.commit ?? false;
|
|
68
|
-
|
|
81
|
+
const proposer = getProposer(options.proposer);
|
|
82
|
+
ctx.ui.setStatus("harness", `Refining (last ${lookback} turns)… [${proposer.name}]`);
|
|
83
|
+
|
|
69
84
|
const evidence = gatherEvidence(ctx, lookback);
|
|
70
|
-
|
|
85
|
+
// Pass a defensive copy: proposers are a public extension point and must not
|
|
86
|
+
// be able to mutate the live store outside applyDeltas (no audit / no rollback).
|
|
87
|
+
const result = await proposer.propose({ evidence, state: snapshotState(), lookback });
|
|
88
|
+
const proposedDeltas = result.deltas ?? [];
|
|
89
|
+
|
|
90
|
+
let applied = 0;
|
|
91
|
+
if (proposedDeltas.length > 0) {
|
|
92
|
+
// Direct-apply path (rule-based / model proposers): persist each batch the
|
|
93
|
+
// same way harness_mutate does, so /tree rollback covers these too.
|
|
94
|
+
const appliedDeltas = applyDeltas(
|
|
95
|
+
proposedDeltas.map((d) => d.delta),
|
|
96
|
+
(snapshot, ver) => pi.appendEntry("harness-state", { state: snapshot, version: ver }),
|
|
97
|
+
);
|
|
98
|
+
applied = appliedDeltas.length;
|
|
99
|
+
ctx.ui.notify(`Proposer "${proposer.name}" applied ${applied} delta(s) directly.`, "info");
|
|
100
|
+
}
|
|
101
|
+
|
|
71
102
|
// Audit trail (branchable via /tree). `source` distinguishes manual /refine
|
|
72
|
-
// from opt-in auto-refine
|
|
73
|
-
|
|
103
|
+
// from opt-in auto-refine; `proposer` records WHICH strategy ran; the
|
|
104
|
+
// rationales make rule-based decisions visible in the transcript.
|
|
105
|
+
pi.appendEntry(REFINE_ENTRY, {
|
|
106
|
+
lookback,
|
|
107
|
+
commit,
|
|
108
|
+
startedAt: Date.now(),
|
|
109
|
+
source,
|
|
110
|
+
proposer: proposer.name,
|
|
111
|
+
applied,
|
|
112
|
+
rationales: proposedDeltas.map((d) => d.rationale),
|
|
113
|
+
});
|
|
114
|
+
|
|
74
115
|
if (commit) {
|
|
75
116
|
// Flush current state to the durable file first so the agent refines
|
|
76
117
|
// against the same view pi-reflect / pi-mem would see.
|
|
@@ -81,20 +122,38 @@ export async function runRefine(
|
|
|
81
122
|
ctx.ui.notify(`Durable export failed: ${(err as Error).message}`, "warning");
|
|
82
123
|
}
|
|
83
124
|
}
|
|
84
|
-
|
|
125
|
+
|
|
126
|
+
if (result.steeringMessage) {
|
|
127
|
+
pi.sendUserMessage(result.steeringMessage);
|
|
128
|
+
}
|
|
85
129
|
ctx.ui.setStatus("harness", undefined);
|
|
86
|
-
return {
|
|
130
|
+
return {
|
|
131
|
+
evidenceBytes: evidence.length,
|
|
132
|
+
lookback,
|
|
133
|
+
commit,
|
|
134
|
+
source,
|
|
135
|
+
proposer: proposer.name,
|
|
136
|
+
applied,
|
|
137
|
+
};
|
|
87
138
|
}
|
|
88
139
|
|
|
89
|
-
function parseArgs(args: string): {
|
|
140
|
+
function parseArgs(args: string): {
|
|
141
|
+
lookback: number;
|
|
142
|
+
commit: boolean;
|
|
143
|
+
proposer: string | undefined;
|
|
144
|
+
} {
|
|
90
145
|
const parts = args.trim().split(/\s+/).filter(Boolean);
|
|
91
146
|
let lookback = DEFAULT_LOOKBACK_TURNS;
|
|
92
147
|
let commit = false;
|
|
93
|
-
|
|
148
|
+
let proposer: string | undefined;
|
|
149
|
+
for (let i = 0; i < parts.length; i++) {
|
|
150
|
+
const p = parts[i]!;
|
|
94
151
|
if (p === "--commit") commit = true;
|
|
152
|
+
else if (p === "--proposer" && i + 1 < parts.length) proposer = parts[++i];
|
|
153
|
+
else if (p.startsWith("--proposer=")) proposer = p.slice("--proposer=".length);
|
|
95
154
|
else if (/^\d+$/.test(p)) lookback = Math.max(1, Math.min(200, Number(p)));
|
|
96
155
|
}
|
|
97
|
-
return { lookback, commit };
|
|
156
|
+
return { lookback, commit, proposer };
|
|
98
157
|
}
|
|
99
158
|
|
|
100
159
|
function gatherEvidence(ctx: ExtensionContext, lookback: number): string {
|
|
@@ -121,21 +180,3 @@ function gatherEvidence(ctx: ExtensionContext, lookback: number): string {
|
|
|
121
180
|
}
|
|
122
181
|
return lines.join("\n") || "(no recent trajectory evidence found)";
|
|
123
182
|
}
|
|
124
|
-
|
|
125
|
-
function buildSteeringPrompt(evidence: string, lookback: number): string {
|
|
126
|
-
return [
|
|
127
|
-
`/refine (online self-improvement, last ${lookback} turns as evidence)`,
|
|
128
|
-
"",
|
|
129
|
-
"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.",
|
|
130
|
-
"",
|
|
131
|
-
"Steps:",
|
|
132
|
-
"1. Call harness_list to see the current state.",
|
|
133
|
-
"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.",
|
|
134
|
-
"3. Keep prompt notes terse and behavioral. Memory facts should be specific. Skill/sub-agent entries should describe reusable patterns, not one task.",
|
|
135
|
-
"",
|
|
136
|
-
"Constraints: never rewrite the whole store; ground every change in evidence; if nothing durable is worth recording, say so and do nothing.",
|
|
137
|
-
"",
|
|
138
|
-
"## Trajectory evidence",
|
|
139
|
-
evidence,
|
|
140
|
-
].join("\n");
|
|
141
|
-
}
|
package/src/store.ts
CHANGED
|
@@ -32,6 +32,13 @@ export function getState(): HarnessState {
|
|
|
32
32
|
return state;
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
/** A defensive deep-enough copy of current state (items are flat). Use when
|
|
36
|
+
* handing state to untrusted/external code (e.g. a DeltaProposer) so it cannot
|
|
37
|
+
* mutate the live store outside applyDeltas. */
|
|
38
|
+
export function snapshotState(): HarnessState {
|
|
39
|
+
return { items: state.items.map((i) => ({ ...i })) };
|
|
40
|
+
}
|
|
41
|
+
|
|
35
42
|
export function listItems(kind?: ComponentKind): HarnessItem[] {
|
|
36
43
|
return kind ? state.items.filter((i) => i.kind === kind) : state.items;
|
|
37
44
|
}
|