pi-continual-harness 0.2.0 → 0.4.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 +100 -13
- package/package.json +1 -1
- package/src/auto-refine.ts +66 -0
- package/src/config.ts +104 -0
- package/src/harness.ts +81 -8
- package/src/index.ts +15 -0
- package/src/proposer.ts +174 -0
- package/src/refine.ts +121 -54
- package/src/remind.ts +50 -0
- package/src/store.ts +46 -5
package/README.md
CHANGED
|
@@ -51,6 +51,7 @@ Or drop `src/index.ts` into `~/.pi/agent/extensions/`.
|
|
|
51
51
|
/refine # review last 25 turns, propose deltas
|
|
52
52
|
/refine 50 # review last 50 turns
|
|
53
53
|
/refine 25 --commit # also export durable state to ~/.pi/agent/harness-state.md
|
|
54
|
+
/refine --proposer dedupe # run the rule-based dedupe proposer instead of steering
|
|
54
55
|
```
|
|
55
56
|
|
|
56
57
|
Durable I/O (round-trip with pi-reflect):
|
|
@@ -59,6 +60,9 @@ Durable I/O (round-trip with pi-reflect):
|
|
|
59
60
|
/harness status # counts + durable file presence/mtime
|
|
60
61
|
/harness export [path] # write active items to a markdown file
|
|
61
62
|
/harness import [--prune] [path] # parse it back and merge (offline edits win)
|
|
63
|
+
/harness prune [--decay <days>] # drop items below the importance floor
|
|
64
|
+
/harness keep <id> # nudge importance up (+0.1)
|
|
65
|
+
/harness drop <id> # nudge importance down (−0.1)
|
|
62
66
|
```
|
|
63
67
|
|
|
64
68
|
`import` reconciles the file into the live store: items whose id matches an
|
|
@@ -80,19 +84,92 @@ The model-facing tools:
|
|
|
80
84
|
Active items are injected into the system prompt each turn as a structured
|
|
81
85
|
block, appended to (never replacing) the base prompt.
|
|
82
86
|
|
|
87
|
+
## Configuration
|
|
88
|
+
|
|
89
|
+
Optional config at `~/.pi/agent/harness.json` (missing or malformed → defaults):
|
|
90
|
+
|
|
91
|
+
```json
|
|
92
|
+
{
|
|
93
|
+
"durableScope": "global",
|
|
94
|
+
"proposer": "steering",
|
|
95
|
+
"remindRefine": { "enabled": false, "everyTurns": 50 },
|
|
96
|
+
"autoRefine": { "enabled": false, "everyTurns": 100, "commit": false }
|
|
97
|
+
}
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
- **`durableScope`** — `"global"` (default) writes the durable markdown to
|
|
101
|
+
`~/.pi/agent/harness-state.md`; `"project"` writes to
|
|
102
|
+
`~/.pi/agent/harness-state/<slug>.md` (slug derived from `cwd`) so each
|
|
103
|
+
project keeps separate state for pi-reflect.
|
|
104
|
+
- **`proposer`** — which delta proposer `/refine` and auto-refine use. Defaults
|
|
105
|
+
to `steering` (the agent reasons via a steering message). `dedupe` applies a
|
|
106
|
+
rule-based dedupe directly. See [Proposers](#proposers).
|
|
107
|
+
- **`remindRefine`** — opt-in `turn_end` nudge. `{ "enabled": true,
|
|
108
|
+
"everyTurns": 50 }` notifies you to run `/refine` on a cadence. It is
|
|
109
|
+
informational only — it never mutates state.
|
|
110
|
+
- **`autoRefine`** — opt-in autonomous self-improvement (**off by default**).
|
|
111
|
+
When `enabled`, the agent runs `/refine` itself every `everyTurns` turns
|
|
112
|
+
(default 100). This is the package's one autonomous-self-mutation path: it
|
|
113
|
+
reuses the exact `/refine` routine (audited `REFINE_ENTRY` tagged
|
|
114
|
+
`source: "auto"`, branch-local, `/tree` rollback) and notifies before firing.
|
|
115
|
+
`commit: true` also flushes durable state on each run.
|
|
116
|
+
|
|
83
117
|
## How it works
|
|
84
118
|
|
|
85
119
|
1. `/refine` gathers recent trajectory evidence from the current session branch.
|
|
86
|
-
2.
|
|
87
|
-
CRUD deltas via
|
|
88
|
-
|
|
89
|
-
|
|
120
|
+
2. A **proposer** decides what to do. The default (`steering`) sends a steering
|
|
121
|
+
user message asking the agent to propose evidence-backed CRUD deltas via
|
|
122
|
+
`harness_mutate`; rule-based proposers (e.g. `dedupe`) return deltas the
|
|
123
|
+
harness applies directly. See [Proposers](#proposers).
|
|
124
|
+
3. The agent calls the tools (steering path); each accepted delta updates the
|
|
125
|
+
in-memory state, which is snapshotted to the session via
|
|
126
|
+
`appendEntry("harness-state", ...)`. Direct-apply proposers snapshot the
|
|
127
|
+
same way.
|
|
90
128
|
4. Because pi's session tree branches at any entry, `/tree` navigation gives
|
|
91
129
|
rollback to any pre-refinement point for free — no bespoke snapshot system.
|
|
92
130
|
|
|
93
131
|
This reuses the existing agent loop (no nested/hidden model calls), is
|
|
94
132
|
model-agnostic, and keeps every delta visible and reviewable in the transcript.
|
|
95
133
|
|
|
134
|
+
## Proposers
|
|
135
|
+
|
|
136
|
+
`/refine` is split into two stages: **propose** (given evidence + state, decide
|
|
137
|
+
what deltas to pursue) and **apply** (send a steering message, or apply returned
|
|
138
|
+
deltas directly). The propose stage is pluggable via a registry
|
|
139
|
+
(`src/proposer.ts`).
|
|
140
|
+
|
|
141
|
+
| Name | What it does |
|
|
142
|
+
|---|---|
|
|
143
|
+
| `steering` (default) | Delegates reasoning to the agent via a steering message — reuses the agent loop, model-agnostic, fully visible. |
|
|
144
|
+
| `dedupe` | Rule-based: drops near-duplicate active items (token-overlap ≥ 0.6), keeping the higher-importance one. No model call. |
|
|
145
|
+
|
|
146
|
+
Select a proposer per run with `/refine --proposer <name>`, or set the default
|
|
147
|
+
for auto-refine via `proposer` in the [config](#configuration). Both paths are
|
|
148
|
+
audited: the `harness-refinement` entry records which proposer ran and how many
|
|
149
|
+
deltas it applied directly.
|
|
150
|
+
|
|
151
|
+
A dedicated-model proposer (a hidden LLM call) is the obvious next alternate
|
|
152
|
+
the interface supports, but is intentionally not shipped — non-visible model
|
|
153
|
+
spend is a tradeoff kept as a separate decision (see `docs/ROADMAP.md`).
|
|
154
|
+
|
|
155
|
+
Register your own proposer from another extension (the registry is re-exported
|
|
156
|
+
from the package entry):
|
|
157
|
+
|
|
158
|
+
```ts
|
|
159
|
+
import { registerProposer } from "pi-continual-harness";
|
|
160
|
+
|
|
161
|
+
registerProposer({
|
|
162
|
+
name: "my-proposer",
|
|
163
|
+
async propose({ evidence, state }) {
|
|
164
|
+
/* inspect evidence + state, return deltas (and/or a steering message) */
|
|
165
|
+
return { deltas: [/* { delta, rationale } */] };
|
|
166
|
+
},
|
|
167
|
+
});
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
Then `"my-proposer"` is selectable via `/refine --proposer my-proposer` or
|
|
171
|
+
`"proposer": "my-proposer"` in the config.
|
|
172
|
+
|
|
96
173
|
## Scope and non-goals
|
|
97
174
|
|
|
98
175
|
- **In scope:** unified state store, online `/refine`, structured deltas,
|
|
@@ -103,15 +180,25 @@ model-agnostic, and keeps every delta visible and reviewable in the transcript.
|
|
|
103
180
|
|
|
104
181
|
## Status
|
|
105
182
|
|
|
106
|
-
0.
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
-
|
|
110
|
-
-
|
|
111
|
-
-
|
|
112
|
-
|
|
113
|
-
-
|
|
114
|
-
-
|
|
183
|
+
0.3.x. Implemented:
|
|
184
|
+
|
|
185
|
+
- Unified harness-state store with branch-local snapshots (`/tree` rollback).
|
|
186
|
+
- Online `/refine` + `harness_mutate` / `harness_list` tools.
|
|
187
|
+
- Two-way durable round-trip with pi-reflect (`/harness import|export|status`).
|
|
188
|
+
- Importance hygiene: `/harness prune [--decay <days>]` and `/harness keep|drop
|
|
189
|
+
<id>`.
|
|
190
|
+
- Optional config (`~/.pi/agent/harness.json`): project-local durable scope, an
|
|
191
|
+
opt-in `turn_end` reminder, and opt-in `turn_end` auto-refine (the one
|
|
192
|
+
autonomous-self-mutation path; off by default).
|
|
193
|
+
- **Pluggable delta proposers** with a registry: `steering` (default) and
|
|
194
|
+
`dedupe` (rule-based) shipped; `registerProposer()` for custom ones.
|
|
195
|
+
|
|
196
|
+
Open extension points (see `docs/ROADMAP.md`):
|
|
197
|
+
|
|
198
|
+
- A dedicated-model proposer (interface ready; the hidden-model-spend tradeoff
|
|
199
|
+
is a separate decision).
|
|
200
|
+
- pi-mem ingestion of the durable export.
|
|
201
|
+
- Outcome-driven importance (currently model-set + manual nudges).
|
|
115
202
|
|
|
116
203
|
## License
|
|
117
204
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-continual-harness",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.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",
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// turn_end auto-refine (Phase 3 of the roadmap). This is the package's ONE
|
|
2
|
+
// autonomous-self-mutation path, so it is locked behind an explicit opt-in:
|
|
3
|
+
//
|
|
4
|
+
// ~/.pi/agent/harness.json
|
|
5
|
+
// { "autoRefine": { "enabled": true, "everyTurns": 100, "commit": false } }
|
|
6
|
+
//
|
|
7
|
+
// When enabled and the cadence elapses, it runs the SAME runRefine() routine as
|
|
8
|
+
// /refine — no parallel mutation logic — so it inherits all the safety
|
|
9
|
+
// properties: structured evidence-backed deltas, an audited REFINE_ENTRY
|
|
10
|
+
// (tagged source: "auto"), branch-local snapshots, and /tree rollback.
|
|
11
|
+
//
|
|
12
|
+
// It is also visible: it notifies before firing and the steering message
|
|
13
|
+
// appears in the transcript. The decision logic is factored into
|
|
14
|
+
// evaluateAutoRefine() so it is unit-testable without a live pi runtime.
|
|
15
|
+
|
|
16
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
17
|
+
import { DEFAULT_AUTO_EVERY_TURNS, type HarnessConfig, loadConfig } from "./config.js";
|
|
18
|
+
import { runRefine } from "./refine.js";
|
|
19
|
+
|
|
20
|
+
// turnIndex of the last auto-refine (or the seeded baseline). -1 = unseen.
|
|
21
|
+
let lastTurn = -1;
|
|
22
|
+
|
|
23
|
+
/** Test hook: reset internal counter. */
|
|
24
|
+
export function resetAutoRefine(): void {
|
|
25
|
+
lastTurn = -1;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Decide whether to auto-refine this turn. Side-effectful: the first observed
|
|
30
|
+
* turn seeds the baseline (no refine then); each fired refine resets it, which
|
|
31
|
+
* also prevents the refine turn itself from immediately re-triggering.
|
|
32
|
+
*/
|
|
33
|
+
export function evaluateAutoRefine(config: HarnessConfig, turnIndex: number): boolean {
|
|
34
|
+
if (!config.autoRefine?.enabled) return false;
|
|
35
|
+
const every = config.autoRefine.everyTurns ?? DEFAULT_AUTO_EVERY_TURNS;
|
|
36
|
+
if (every <= 0) return false;
|
|
37
|
+
if (lastTurn < 0) {
|
|
38
|
+
lastTurn = turnIndex;
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
if (turnIndex - lastTurn >= every) {
|
|
42
|
+
lastTurn = turnIndex;
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Subscribe to turn_end and run /refine on the configured cadence. */
|
|
49
|
+
export function registerAutoRefine(pi: ExtensionAPI): void {
|
|
50
|
+
pi.on("turn_end", async (event, ctx) => {
|
|
51
|
+
const config = await loadConfig();
|
|
52
|
+
if (!evaluateAutoRefine(config, event.turnIndex)) return;
|
|
53
|
+
const every = config.autoRefine?.everyTurns ?? DEFAULT_AUTO_EVERY_TURNS;
|
|
54
|
+
ctx.ui.notify(`Auto-refine: running /refine (every ${every} turns, opt-in).`, "info");
|
|
55
|
+
try {
|
|
56
|
+
await runRefine(
|
|
57
|
+
pi,
|
|
58
|
+
ctx,
|
|
59
|
+
{ commit: config.autoRefine?.commit ?? false, ...(config.proposer ? { proposer: config.proposer } : {}) },
|
|
60
|
+
"auto",
|
|
61
|
+
);
|
|
62
|
+
} catch (err) {
|
|
63
|
+
ctx.ui.notify(`Auto-refine failed: ${(err as Error).message}`, "error");
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// User configuration for continual-harness: ~/.pi/agent/harness.json
|
|
2
|
+
//
|
|
3
|
+
// Phase 2 introduces this file (optional; missing → defaults) to configure:
|
|
4
|
+
// - durableScope: where the durable markdown lives. "global" (default,
|
|
5
|
+
// ~/.pi/agent/harness-state.md — backward compatible) or "project"
|
|
6
|
+
// (~/.pi/agent/harness-state/<slug>.md, slug derived from cwd) so different
|
|
7
|
+
// projects keep separate harness state for pi-reflect.
|
|
8
|
+
// - remindRefine: opt-in turn_end nudge to run /refine (Phase 2 = reminder
|
|
9
|
+
// only; Phase 3 adds opt-in auto-refine).
|
|
10
|
+
//
|
|
11
|
+
// Robust by design: missing or malformed file → DEFAULT_CONFIG, never throws.
|
|
12
|
+
|
|
13
|
+
import { readFile } from "node:fs/promises";
|
|
14
|
+
import { homedir } from "node:os";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
import { DEFAULT_DURABLE_PATH } from "./store.js";
|
|
17
|
+
|
|
18
|
+
export const DEFAULT_EVERY_TURNS = 50;
|
|
19
|
+
export const DEFAULT_AUTO_EVERY_TURNS = 100;
|
|
20
|
+
|
|
21
|
+
export interface HarnessConfig {
|
|
22
|
+
durableScope?: "global" | "project";
|
|
23
|
+
remindRefine?: {
|
|
24
|
+
enabled?: boolean;
|
|
25
|
+
everyTurns?: number;
|
|
26
|
+
};
|
|
27
|
+
autoRefine?: {
|
|
28
|
+
enabled?: boolean;
|
|
29
|
+
everyTurns?: number;
|
|
30
|
+
commit?: boolean;
|
|
31
|
+
};
|
|
32
|
+
/** Delta proposer name (see proposer.ts registry). Defaults to "steering". */
|
|
33
|
+
proposer?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const DEFAULT_CONFIG: HarnessConfig = {
|
|
37
|
+
durableScope: "global",
|
|
38
|
+
remindRefine: { enabled: false, everyTurns: DEFAULT_EVERY_TURNS },
|
|
39
|
+
autoRefine: { enabled: false, everyTurns: DEFAULT_AUTO_EVERY_TURNS, commit: false },
|
|
40
|
+
proposer: "steering",
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export const CONFIG_PATH = join(homedir(), ".pi", "agent", "harness.json");
|
|
44
|
+
|
|
45
|
+
let cached: HarnessConfig | undefined;
|
|
46
|
+
|
|
47
|
+
/** Merge a (possibly partial) parsed file over the defaults. */
|
|
48
|
+
function mergeConfig(over: Partial<HarnessConfig>): HarnessConfig {
|
|
49
|
+
return {
|
|
50
|
+
durableScope: over.durableScope === "project" ? "project" : "global",
|
|
51
|
+
remindRefine: {
|
|
52
|
+
enabled: over.remindRefine?.enabled ?? false,
|
|
53
|
+
everyTurns: over.remindRefine?.everyTurns ?? DEFAULT_EVERY_TURNS,
|
|
54
|
+
},
|
|
55
|
+
autoRefine: {
|
|
56
|
+
enabled: over.autoRefine?.enabled ?? false,
|
|
57
|
+
everyTurns: over.autoRefine?.everyTurns ?? DEFAULT_AUTO_EVERY_TURNS,
|
|
58
|
+
commit: over.autoRefine?.commit ?? false,
|
|
59
|
+
},
|
|
60
|
+
proposer: over.proposer ?? "steering",
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Load the config, merged over defaults. Tolerant: missing/malformed → defaults.
|
|
65
|
+
* Cached for the process lifetime (config is read once per session). */
|
|
66
|
+
export async function loadConfig(path: string = CONFIG_PATH): Promise<HarnessConfig> {
|
|
67
|
+
if (cached) return cached;
|
|
68
|
+
try {
|
|
69
|
+
const raw = await readFile(path, "utf8");
|
|
70
|
+
cached = mergeConfig(JSON.parse(raw) as Partial<HarnessConfig>);
|
|
71
|
+
} catch {
|
|
72
|
+
cached = { ...DEFAULT_CONFIG };
|
|
73
|
+
}
|
|
74
|
+
return cached;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Test hook: drop the in-process cache. */
|
|
78
|
+
export function resetConfigCache(): void {
|
|
79
|
+
cached = undefined;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Resolve the durable markdown path for the configured scope.
|
|
84
|
+
* - global → ~/.pi/agent/harness-state.md (the default, unchanged)
|
|
85
|
+
* - project → ~/.pi/agent/harness-state/<slug>.md (slug derived from cwd)
|
|
86
|
+
*/
|
|
87
|
+
export function resolveDurablePath(config: HarnessConfig, cwd?: string): string {
|
|
88
|
+
if (config.durableScope !== "project") return DEFAULT_DURABLE_PATH;
|
|
89
|
+
return join(homedir(), ".pi", "agent", "harness-state", `${projectSlug(cwd)}.md`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Stable, filesystem-safe slug from a directory path. Falls back to "default". */
|
|
93
|
+
export function projectSlug(cwd?: string): string {
|
|
94
|
+
const base = (cwd ?? "").trim();
|
|
95
|
+
if (!base) return "default";
|
|
96
|
+
const slug = base
|
|
97
|
+
.replace(/[\\/]+/g, "-")
|
|
98
|
+
.replace(/[^a-z0-9-]+/gi, "")
|
|
99
|
+
.replace(/-{2,}/g, "-")
|
|
100
|
+
.replace(/^-|-$/g, "")
|
|
101
|
+
.toLowerCase()
|
|
102
|
+
.slice(-80);
|
|
103
|
+
return slug || "default";
|
|
104
|
+
}
|
package/src/harness.ts
CHANGED
|
@@ -14,14 +14,22 @@
|
|
|
14
14
|
|
|
15
15
|
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
16
16
|
import { stat } from "node:fs/promises";
|
|
17
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
bumpImportance,
|
|
19
|
+
decayAndPrune,
|
|
20
|
+
exportDurable,
|
|
21
|
+
getState,
|
|
22
|
+
reconstructFromDurable,
|
|
23
|
+
} from "./store.js";
|
|
24
|
+
import { loadConfig, resolveDurablePath } from "./config.js";
|
|
18
25
|
import type { ComponentKind } from "./types.js";
|
|
19
26
|
|
|
20
27
|
export function registerHarness(pi: ExtensionAPI): void {
|
|
21
28
|
pi.registerCommand("harness", {
|
|
22
29
|
description:
|
|
23
|
-
"Durable harness-state I/O
|
|
24
|
-
"
|
|
30
|
+
"Durable harness-state I/O + importance hygiene. Subcommands: " +
|
|
31
|
+
"import [--prune] [path] · export [path] · status [path] · " +
|
|
32
|
+
"prune [--decay <days>] · keep <id> · drop <id>.",
|
|
25
33
|
handler: async (args, ctx) => {
|
|
26
34
|
const parts = args.trim().split(/\s+/).filter(Boolean);
|
|
27
35
|
const sub = (parts[0] ?? "status").toLowerCase();
|
|
@@ -33,6 +41,15 @@ export function registerHarness(pi: ExtensionAPI): void {
|
|
|
33
41
|
case "export":
|
|
34
42
|
await handleExport(ctx, rest);
|
|
35
43
|
return;
|
|
44
|
+
case "prune":
|
|
45
|
+
await handlePrune(pi, ctx, rest);
|
|
46
|
+
return;
|
|
47
|
+
case "keep":
|
|
48
|
+
await handleBump(pi, ctx, rest, 0.1, "keep");
|
|
49
|
+
return;
|
|
50
|
+
case "drop":
|
|
51
|
+
await handleBump(pi, ctx, rest, -0.1, "drop");
|
|
52
|
+
return;
|
|
36
53
|
case "status":
|
|
37
54
|
default:
|
|
38
55
|
await handleStatus(ctx, rest);
|
|
@@ -42,8 +59,11 @@ export function registerHarness(pi: ExtensionAPI): void {
|
|
|
42
59
|
});
|
|
43
60
|
}
|
|
44
61
|
|
|
45
|
-
function resolvePath(rest: string[]): string {
|
|
46
|
-
|
|
62
|
+
async function resolvePath(rest: string[], ctx: ExtensionCommandContext): Promise<string> {
|
|
63
|
+
const explicit = rest.find((a) => !a.startsWith("-"));
|
|
64
|
+
if (explicit) return explicit;
|
|
65
|
+
const config = await loadConfig();
|
|
66
|
+
return resolveDurablePath(config, ctx.cwd);
|
|
47
67
|
}
|
|
48
68
|
|
|
49
69
|
async function handleImport(
|
|
@@ -52,7 +72,7 @@ async function handleImport(
|
|
|
52
72
|
rest: string[],
|
|
53
73
|
): Promise<void> {
|
|
54
74
|
const prune = rest.includes("--prune");
|
|
55
|
-
const path = resolvePath(rest);
|
|
75
|
+
const path = await resolvePath(rest, ctx);
|
|
56
76
|
ctx.ui.setStatus("harness", `Importing durable state${prune ? " (prune)" : ""}…`);
|
|
57
77
|
try {
|
|
58
78
|
const res = await reconstructFromDurable(path, { prune }, (snapshot, ver) => {
|
|
@@ -75,7 +95,7 @@ async function handleImport(
|
|
|
75
95
|
}
|
|
76
96
|
|
|
77
97
|
async function handleExport(ctx: ExtensionCommandContext, rest: string[]): Promise<void> {
|
|
78
|
-
const path = resolvePath(rest);
|
|
98
|
+
const path = await resolvePath(rest, ctx);
|
|
79
99
|
ctx.ui.setStatus("harness", "Exporting durable state…");
|
|
80
100
|
try {
|
|
81
101
|
const written = await exportDurable(path);
|
|
@@ -87,8 +107,61 @@ async function handleExport(ctx: ExtensionCommandContext, rest: string[]): Promi
|
|
|
87
107
|
ctx.ui.setStatus("harness", undefined);
|
|
88
108
|
}
|
|
89
109
|
|
|
110
|
+
async function handlePrune(
|
|
111
|
+
pi: ExtensionAPI,
|
|
112
|
+
ctx: ExtensionCommandContext,
|
|
113
|
+
rest: string[],
|
|
114
|
+
): Promise<void> {
|
|
115
|
+
let decayAfterDays: number | undefined;
|
|
116
|
+
const idx = rest.indexOf("--decay");
|
|
117
|
+
if (idx >= 0) {
|
|
118
|
+
const n = Number(rest[idx + 1]);
|
|
119
|
+
decayAfterDays = Number.isFinite(n) && n > 0 ? n : undefined;
|
|
120
|
+
}
|
|
121
|
+
ctx.ui.setStatus("harness", "Pruning…");
|
|
122
|
+
try {
|
|
123
|
+
const options: { decayAfterDays?: number; decayStep?: number } = { decayStep: 0.1 };
|
|
124
|
+
if (decayAfterDays !== undefined) options.decayAfterDays = decayAfterDays;
|
|
125
|
+
const res = decayAndPrune(options, (snapshot, ver) => {
|
|
126
|
+
pi.appendEntry("harness-state", { state: snapshot, version: ver });
|
|
127
|
+
});
|
|
128
|
+
const bits = [`${res.pruned} pruned`];
|
|
129
|
+
if (decayAfterDays !== undefined) bits.push(`${res.decayed} decayed (>${decayAfterDays}d)`);
|
|
130
|
+
ctx.ui.notify(`Harness: ${bits.join(", ")}.`, "info");
|
|
131
|
+
} catch (err) {
|
|
132
|
+
ctx.ui.notify(`Harness prune failed: ${(err as Error).message}`, "error");
|
|
133
|
+
}
|
|
134
|
+
ctx.ui.setStatus("harness", undefined);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function handleBump(
|
|
138
|
+
pi: ExtensionAPI,
|
|
139
|
+
ctx: ExtensionCommandContext,
|
|
140
|
+
rest: string[],
|
|
141
|
+
delta: number,
|
|
142
|
+
label: string,
|
|
143
|
+
): Promise<void> {
|
|
144
|
+
const id = rest.find((a) => a && !a.startsWith("-"));
|
|
145
|
+
if (!id) {
|
|
146
|
+
ctx.ui.notify(
|
|
147
|
+
`/harness ${label} requires an item id (see /harness status or harness_list).`,
|
|
148
|
+
"warning",
|
|
149
|
+
);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
const item = bumpImportance(id, delta, (snapshot, ver) => {
|
|
153
|
+
pi.appendEntry("harness-state", { state: snapshot, version: ver });
|
|
154
|
+
});
|
|
155
|
+
if (!item) {
|
|
156
|
+
ctx.ui.notify(`No harness item with id ${id}.`, "warning");
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
const preview = item.content.length > 60 ? `${item.content.slice(0, 60)}…` : item.content;
|
|
160
|
+
ctx.ui.notify(`${label}: "${preview}" → importance ${item.importance.toFixed(2)}`, "info");
|
|
161
|
+
}
|
|
162
|
+
|
|
90
163
|
async function handleStatus(ctx: ExtensionCommandContext, rest: string[]): Promise<void> {
|
|
91
|
-
const path = resolvePath(rest);
|
|
164
|
+
const path = await resolvePath(rest, ctx);
|
|
92
165
|
const items = getState().items;
|
|
93
166
|
const active = items.filter((i) => i.active);
|
|
94
167
|
const counts: Record<ComponentKind, number> = { prompt: 0, memory: 0, skill: 0, subagent: 0 };
|
package/src/index.ts
CHANGED
|
@@ -11,6 +11,8 @@ import { registerHarness } from "./harness.js";
|
|
|
11
11
|
import { registerInjection } from "./inject.js";
|
|
12
12
|
import { registerRefine } from "./refine.js";
|
|
13
13
|
import { registerTools } from "./tools.js";
|
|
14
|
+
import { registerReminder } from "./remind.js";
|
|
15
|
+
import { registerAutoRefine } from "./auto-refine.js";
|
|
14
16
|
import { getState, reconstruct, STATE_ENTRY } from "./store.js";
|
|
15
17
|
|
|
16
18
|
export default function continualHarness(pi: ExtensionAPI): void {
|
|
@@ -29,6 +31,19 @@ export default function continualHarness(pi: ExtensionAPI): void {
|
|
|
29
31
|
registerTools(pi);
|
|
30
32
|
registerRefine(pi);
|
|
31
33
|
registerHarness(pi);
|
|
34
|
+
registerReminder(pi);
|
|
35
|
+
registerAutoRefine(pi);
|
|
32
36
|
}
|
|
33
37
|
|
|
34
38
|
export { STATE_ENTRY };
|
|
39
|
+
|
|
40
|
+
// Public extension API: other extensions can register their own delta proposer
|
|
41
|
+
// (see src/proposer.ts) and it becomes selectable via /refine --proposer <name>
|
|
42
|
+
// or the `proposer` config key.
|
|
43
|
+
export {
|
|
44
|
+
listProposers,
|
|
45
|
+
registerProposer,
|
|
46
|
+
type DeltaProposer,
|
|
47
|
+
type ProposeInput,
|
|
48
|
+
type ProposeResult,
|
|
49
|
+
} from "./proposer.js";
|
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,22 +1,28 @@
|
|
|
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
|
-
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
16
|
-
import { exportDurable, REFINE_ENTRY } from "./store.js";
|
|
20
|
+
import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
21
|
+
import { applyDeltas, exportDurable, REFINE_ENTRY, snapshotState } from "./store.js";
|
|
22
|
+
import { getProposer } from "./proposer.js";
|
|
17
23
|
|
|
18
|
-
const DEFAULT_LOOKBACK_TURNS = 25;
|
|
19
24
|
const DEFAULT_EVIDENCE_BYTES = 16000;
|
|
25
|
+
export const DEFAULT_LOOKBACK_TURNS = 25;
|
|
20
26
|
|
|
21
27
|
// Minimal entry shape for trajectory walking; kept loose to avoid coupling to
|
|
22
28
|
// pi's internal session entry types.
|
|
@@ -32,46 +38,125 @@ 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
|
-
|
|
43
|
+
const { lookback, commit, proposer } = parseArgs(args);
|
|
44
|
+
await runRefine(pi, ctx, { lookback, commit, ...(proposer ? { proposer } : {}) });
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
}
|
|
39
48
|
|
|
40
|
-
|
|
41
|
-
|
|
49
|
+
export interface RefineOptions {
|
|
50
|
+
lookback?: number;
|
|
51
|
+
commit?: boolean;
|
|
52
|
+
/** Proposer name (see proposer.ts registry). Defaults to "steering". */
|
|
53
|
+
proposer?: string;
|
|
54
|
+
}
|
|
42
55
|
|
|
43
|
-
|
|
44
|
-
|
|
56
|
+
export interface RefineResult {
|
|
57
|
+
evidenceBytes: number;
|
|
58
|
+
lookback: number;
|
|
59
|
+
commit: boolean;
|
|
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;
|
|
65
|
+
}
|
|
45
66
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
+
*/
|
|
73
|
+
export async function runRefine(
|
|
74
|
+
pi: ExtensionAPI,
|
|
75
|
+
ctx: ExtensionContext,
|
|
76
|
+
options: RefineOptions = {},
|
|
77
|
+
source: "manual" | "auto" = "manual",
|
|
78
|
+
): Promise<RefineResult> {
|
|
79
|
+
const lookback = options.lookback ?? DEFAULT_LOOKBACK_TURNS;
|
|
80
|
+
const commit = options.commit ?? false;
|
|
81
|
+
const proposer = getProposer(options.proposer);
|
|
82
|
+
ctx.ui.setStatus("harness", `Refining (last ${lookback} turns)… [${proposer.name}]`);
|
|
56
83
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
84
|
+
const evidence = gatherEvidence(ctx, lookback);
|
|
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
|
+
|
|
102
|
+
// Audit trail (branchable via /tree). `source` distinguishes manual /refine
|
|
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),
|
|
60
113
|
});
|
|
114
|
+
|
|
115
|
+
if (commit) {
|
|
116
|
+
// Flush current state to the durable file first so the agent refines
|
|
117
|
+
// against the same view pi-reflect / pi-mem would see.
|
|
118
|
+
try {
|
|
119
|
+
const path = await exportDurable();
|
|
120
|
+
ctx.ui.notify(`Durable state exported to ${path}`, "info");
|
|
121
|
+
} catch (err) {
|
|
122
|
+
ctx.ui.notify(`Durable export failed: ${(err as Error).message}`, "warning");
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (result.steeringMessage) {
|
|
127
|
+
pi.sendUserMessage(result.steeringMessage);
|
|
128
|
+
}
|
|
129
|
+
ctx.ui.setStatus("harness", undefined);
|
|
130
|
+
return {
|
|
131
|
+
evidenceBytes: evidence.length,
|
|
132
|
+
lookback,
|
|
133
|
+
commit,
|
|
134
|
+
source,
|
|
135
|
+
proposer: proposer.name,
|
|
136
|
+
applied,
|
|
137
|
+
};
|
|
61
138
|
}
|
|
62
139
|
|
|
63
|
-
function parseArgs(args: string): {
|
|
140
|
+
function parseArgs(args: string): {
|
|
141
|
+
lookback: number;
|
|
142
|
+
commit: boolean;
|
|
143
|
+
proposer: string | undefined;
|
|
144
|
+
} {
|
|
64
145
|
const parts = args.trim().split(/\s+/).filter(Boolean);
|
|
65
146
|
let lookback = DEFAULT_LOOKBACK_TURNS;
|
|
66
147
|
let commit = false;
|
|
67
|
-
|
|
148
|
+
let proposer: string | undefined;
|
|
149
|
+
for (let i = 0; i < parts.length; i++) {
|
|
150
|
+
const p = parts[i]!;
|
|
68
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);
|
|
69
154
|
else if (/^\d+$/.test(p)) lookback = Math.max(1, Math.min(200, Number(p)));
|
|
70
155
|
}
|
|
71
|
-
return { lookback, commit };
|
|
156
|
+
return { lookback, commit, proposer };
|
|
72
157
|
}
|
|
73
158
|
|
|
74
|
-
function gatherEvidence(ctx:
|
|
159
|
+
function gatherEvidence(ctx: ExtensionContext, lookback: number): string {
|
|
75
160
|
const entries = ctx.sessionManager.getBranch() as AnyEntry[];
|
|
76
161
|
const messages = entries.filter((e) => e.type === "message" && e.message);
|
|
77
162
|
const recent = messages.slice(-lookback * 2); // ~2 entries per turn (user+assistant)
|
|
@@ -95,21 +180,3 @@ function gatherEvidence(ctx: ExtensionCommandContext, lookback: number): string
|
|
|
95
180
|
}
|
|
96
181
|
return lines.join("\n") || "(no recent trajectory evidence found)";
|
|
97
182
|
}
|
|
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/remind.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// turn_end reminder (Phase 2 of the roadmap). Purely informational — it nudges
|
|
2
|
+
// the user to run /refine on a cadence; it never mutates state. Opt-in via
|
|
3
|
+
// harness.json { remindRefine: { enabled: true, everyTurns: 50 } }.
|
|
4
|
+
//
|
|
5
|
+
// The decision logic is factored into evaluateReminder() so it is unit-testable
|
|
6
|
+
// without a live pi runtime; registerReminder() is thin glue over pi.on.
|
|
7
|
+
|
|
8
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
9
|
+
import { DEFAULT_EVERY_TURNS, type HarnessConfig, loadConfig } from "./config.js";
|
|
10
|
+
|
|
11
|
+
// turnIndex of the last reminder (or the baseline we seeded from). -1 = unseen.
|
|
12
|
+
let lastTurn = -1;
|
|
13
|
+
|
|
14
|
+
/** Test hook: reset internal counter. */
|
|
15
|
+
export function resetReminder(): void {
|
|
16
|
+
lastTurn = -1;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Decide whether to remind this turn. Side-effectful: the first observed turn
|
|
21
|
+
* seeds the baseline (no reminder then), and each fired reminder resets it.
|
|
22
|
+
* Returns true when it is time to nudge.
|
|
23
|
+
*/
|
|
24
|
+
export function evaluateReminder(config: HarnessConfig, turnIndex: number): boolean {
|
|
25
|
+
if (!config.remindRefine?.enabled) return false;
|
|
26
|
+
const every = config.remindRefine.everyTurns ?? DEFAULT_EVERY_TURNS;
|
|
27
|
+
if (every <= 0) return false;
|
|
28
|
+
if (lastTurn < 0) {
|
|
29
|
+
lastTurn = turnIndex;
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
if (turnIndex - lastTurn >= every) {
|
|
33
|
+
lastTurn = turnIndex;
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Subscribe to turn_end and notify on the configured cadence. */
|
|
40
|
+
export function registerReminder(pi: ExtensionAPI): void {
|
|
41
|
+
pi.on("turn_end", async (event, ctx) => {
|
|
42
|
+
const config = await loadConfig();
|
|
43
|
+
if (!evaluateReminder(config, event.turnIndex)) return;
|
|
44
|
+
const every = config.remindRefine?.everyTurns ?? DEFAULT_EVERY_TURNS;
|
|
45
|
+
ctx.ui.notify(
|
|
46
|
+
`${every} turns since the last check — consider running /refine to refine harness state.`,
|
|
47
|
+
"info",
|
|
48
|
+
);
|
|
49
|
+
});
|
|
50
|
+
}
|
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
|
}
|
|
@@ -120,15 +127,49 @@ export function reconstruct(entries: Iterable<unknown>): void {
|
|
|
120
127
|
version = 0;
|
|
121
128
|
}
|
|
122
129
|
|
|
123
|
-
/**
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
130
|
+
/**
|
|
131
|
+
* Age importance of stale items, then prune below the floor.
|
|
132
|
+
* - decayAfterDays: if set, items whose updatedAt is older than this get
|
|
133
|
+
* importance -= decayStep (default 0.1) before pruning. Time-since-update
|
|
134
|
+
* is a weak proxy for staleness; pair with /harness keep|drop for signal.
|
|
135
|
+
* - Items below IMPORTANCE_FLOOR after decay are removed.
|
|
136
|
+
*/
|
|
137
|
+
export function decayAndPrune(
|
|
138
|
+
options: { decayAfterDays?: number; decayStep?: number } = {},
|
|
139
|
+
persist: (snapshot: HarnessState, version: number) => void,
|
|
140
|
+
): { pruned: number; decayed: number } {
|
|
141
|
+
let decayed = 0;
|
|
142
|
+
const step = options.decayStep ?? 0.1;
|
|
143
|
+
const ms = options.decayAfterDays !== undefined ? options.decayAfterDays * 86_400_000 : undefined;
|
|
144
|
+
const now = Date.now();
|
|
145
|
+
if (ms !== undefined) {
|
|
146
|
+
for (const i of state.items) {
|
|
147
|
+
if (now - i.updatedAt > ms) {
|
|
148
|
+
i.importance = clamp(i.importance - step);
|
|
149
|
+
decayed += 1;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
127
153
|
const before = state.items.length;
|
|
128
154
|
state.items = state.items.filter((i) => i.importance >= IMPORTANCE_FLOOR);
|
|
129
155
|
version += 1;
|
|
130
156
|
persist(state, version);
|
|
131
|
-
return { pruned: before - state.items.length };
|
|
157
|
+
return { pruned: before - state.items.length, decayed };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Nudge an item's importance by delta (clamped to [0,1]) and touch updatedAt. */
|
|
161
|
+
export function bumpImportance(
|
|
162
|
+
id: string,
|
|
163
|
+
delta: number,
|
|
164
|
+
persist: (snapshot: HarnessState, version: number) => void,
|
|
165
|
+
): HarnessItem | undefined {
|
|
166
|
+
const item = state.items.find((i) => i.id === id);
|
|
167
|
+
if (!item) return undefined;
|
|
168
|
+
item.importance = clamp(item.importance + delta);
|
|
169
|
+
item.updatedAt = Date.now();
|
|
170
|
+
version += 1;
|
|
171
|
+
persist(state, version);
|
|
172
|
+
return item;
|
|
132
173
|
}
|
|
133
174
|
|
|
134
175
|
export { STATE_ENTRY, REFINE_ENTRY, IMPORTANCE_FLOOR };
|