pi-continual-harness 0.2.0 → 0.3.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 +43 -7
- package/package.json +1 -1
- package/src/auto-refine.ts +61 -0
- package/src/config.ts +100 -0
- package/src/harness.ts +81 -8
- package/src/index.ts +4 -0
- package/src/refine.ts +49 -23
- package/src/remind.ts +50 -0
- package/src/store.ts +39 -5
package/README.md
CHANGED
|
@@ -59,6 +59,9 @@ Durable I/O (round-trip with pi-reflect):
|
|
|
59
59
|
/harness status # counts + durable file presence/mtime
|
|
60
60
|
/harness export [path] # write active items to a markdown file
|
|
61
61
|
/harness import [--prune] [path] # parse it back and merge (offline edits win)
|
|
62
|
+
/harness prune [--decay <days>] # drop items below the importance floor
|
|
63
|
+
/harness keep <id> # nudge importance up (+0.1)
|
|
64
|
+
/harness drop <id> # nudge importance down (−0.1)
|
|
62
65
|
```
|
|
63
66
|
|
|
64
67
|
`import` reconciles the file into the live store: items whose id matches an
|
|
@@ -80,6 +83,32 @@ The model-facing tools:
|
|
|
80
83
|
Active items are injected into the system prompt each turn as a structured
|
|
81
84
|
block, appended to (never replacing) the base prompt.
|
|
82
85
|
|
|
86
|
+
## Configuration
|
|
87
|
+
|
|
88
|
+
Optional config at `~/.pi/agent/harness.json` (missing or malformed → defaults):
|
|
89
|
+
|
|
90
|
+
```json
|
|
91
|
+
{
|
|
92
|
+
"durableScope": "global",
|
|
93
|
+
"remindRefine": { "enabled": false, "everyTurns": 50 },
|
|
94
|
+
"autoRefine": { "enabled": false, "everyTurns": 100, "commit": false }
|
|
95
|
+
}
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
- **`durableScope`** — `"global"` (default) writes the durable markdown to
|
|
99
|
+
`~/.pi/agent/harness-state.md`; `"project"` writes to
|
|
100
|
+
`~/.pi/agent/harness-state/<slug>.md` (slug derived from `cwd`) so each
|
|
101
|
+
project keeps separate state for pi-reflect.
|
|
102
|
+
- **`remindRefine`** — opt-in `turn_end` nudge. `{ "enabled": true,
|
|
103
|
+
"everyTurns": 50 }` notifies you to run `/refine` on a cadence. It is
|
|
104
|
+
informational only — it never mutates state.
|
|
105
|
+
- **`autoRefine`** — opt-in autonomous self-improvement (**off by default**).
|
|
106
|
+
When `enabled`, the agent runs `/refine` itself every `everyTurns` turns
|
|
107
|
+
(default 100). This is the package's one autonomous-self-mutation path: it
|
|
108
|
+
reuses the exact `/refine` routine (audited `REFINE_ENTRY` tagged
|
|
109
|
+
`source: "auto"`, branch-local, `/tree` rollback) and notifies before firing.
|
|
110
|
+
`commit: true` also flushes durable state on each run.
|
|
111
|
+
|
|
83
112
|
## How it works
|
|
84
113
|
|
|
85
114
|
1. `/refine` gathers recent trajectory evidence from the current session branch.
|
|
@@ -103,15 +132,22 @@ model-agnostic, and keeps every delta visible and reviewable in the transcript.
|
|
|
103
132
|
|
|
104
133
|
## Status
|
|
105
134
|
|
|
106
|
-
0.
|
|
107
|
-
|
|
135
|
+
0.2.x. Implemented:
|
|
136
|
+
|
|
137
|
+
- Unified harness-state store with branch-local snapshots (`/tree` rollback).
|
|
138
|
+
- Online `/refine` + `harness_mutate` / `harness_list` tools.
|
|
139
|
+
- Two-way durable round-trip with pi-reflect (`/harness import|export|status`).
|
|
140
|
+
- Importance hygiene: `/harness prune [--decay <days>]` and `/harness keep|drop
|
|
141
|
+
<id>`.
|
|
142
|
+
- Optional config (`~/.pi/agent/harness.json`): project-local durable scope, an
|
|
143
|
+
opt-in `turn_end` reminder, and opt-in `turn_end` auto-refine (the one
|
|
144
|
+
autonomous-self-mutation path; off by default).
|
|
145
|
+
|
|
146
|
+
Open extension points (see `docs/ROADMAP.md`):
|
|
108
147
|
|
|
109
148
|
- A pluggable delta proposer (currently the agent itself, via steering).
|
|
110
|
-
-
|
|
111
|
-
-
|
|
112
|
-
default — autonomous self-mutation is a sharp edge).
|
|
113
|
-
- Importance decay/prune policy — `decayAndPrune()` exists but is not yet wired.
|
|
114
|
-
- pi-mem ingestion of the durable export (manual/future).
|
|
149
|
+
- pi-mem ingestion of the durable export.
|
|
150
|
+
- Outcome-driven importance (currently model-set + manual nudges).
|
|
115
151
|
|
|
116
152
|
## License
|
|
117
153
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-continual-harness",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.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,61 @@
|
|
|
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(pi, ctx, { commit: config.autoRefine?.commit ?? false }, "auto");
|
|
57
|
+
} catch (err) {
|
|
58
|
+
ctx.ui.notify(`Auto-refine failed: ${(err as Error).message}`, "error");
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
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
|
+
}
|
|
33
|
+
|
|
34
|
+
export const DEFAULT_CONFIG: HarnessConfig = {
|
|
35
|
+
durableScope: "global",
|
|
36
|
+
remindRefine: { enabled: false, everyTurns: DEFAULT_EVERY_TURNS },
|
|
37
|
+
autoRefine: { enabled: false, everyTurns: DEFAULT_AUTO_EVERY_TURNS, commit: false },
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export const CONFIG_PATH = join(homedir(), ".pi", "agent", "harness.json");
|
|
41
|
+
|
|
42
|
+
let cached: HarnessConfig | undefined;
|
|
43
|
+
|
|
44
|
+
/** Merge a (possibly partial) parsed file over the defaults. */
|
|
45
|
+
function mergeConfig(over: Partial<HarnessConfig>): HarnessConfig {
|
|
46
|
+
return {
|
|
47
|
+
durableScope: over.durableScope === "project" ? "project" : "global",
|
|
48
|
+
remindRefine: {
|
|
49
|
+
enabled: over.remindRefine?.enabled ?? false,
|
|
50
|
+
everyTurns: over.remindRefine?.everyTurns ?? DEFAULT_EVERY_TURNS,
|
|
51
|
+
},
|
|
52
|
+
autoRefine: {
|
|
53
|
+
enabled: over.autoRefine?.enabled ?? false,
|
|
54
|
+
everyTurns: over.autoRefine?.everyTurns ?? DEFAULT_AUTO_EVERY_TURNS,
|
|
55
|
+
commit: over.autoRefine?.commit ?? false,
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Load the config, merged over defaults. Tolerant: missing/malformed → defaults.
|
|
61
|
+
* Cached for the process lifetime (config is read once per session). */
|
|
62
|
+
export async function loadConfig(path: string = CONFIG_PATH): Promise<HarnessConfig> {
|
|
63
|
+
if (cached) return cached;
|
|
64
|
+
try {
|
|
65
|
+
const raw = await readFile(path, "utf8");
|
|
66
|
+
cached = mergeConfig(JSON.parse(raw) as Partial<HarnessConfig>);
|
|
67
|
+
} catch {
|
|
68
|
+
cached = { ...DEFAULT_CONFIG };
|
|
69
|
+
}
|
|
70
|
+
return cached;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Test hook: drop the in-process cache. */
|
|
74
|
+
export function resetConfigCache(): void {
|
|
75
|
+
cached = undefined;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Resolve the durable markdown path for the configured scope.
|
|
80
|
+
* - global → ~/.pi/agent/harness-state.md (the default, unchanged)
|
|
81
|
+
* - project → ~/.pi/agent/harness-state/<slug>.md (slug derived from cwd)
|
|
82
|
+
*/
|
|
83
|
+
export function resolveDurablePath(config: HarnessConfig, cwd?: string): string {
|
|
84
|
+
if (config.durableScope !== "project") return DEFAULT_DURABLE_PATH;
|
|
85
|
+
return join(homedir(), ".pi", "agent", "harness-state", `${projectSlug(cwd)}.md`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Stable, filesystem-safe slug from a directory path. Falls back to "default". */
|
|
89
|
+
export function projectSlug(cwd?: string): string {
|
|
90
|
+
const base = (cwd ?? "").trim();
|
|
91
|
+
if (!base) return "default";
|
|
92
|
+
const slug = base
|
|
93
|
+
.replace(/[\\/]+/g, "-")
|
|
94
|
+
.replace(/[^a-z0-9-]+/gi, "")
|
|
95
|
+
.replace(/-{2,}/g, "-")
|
|
96
|
+
.replace(/^-|-$/g, "")
|
|
97
|
+
.toLowerCase()
|
|
98
|
+
.slice(-80);
|
|
99
|
+
return slug || "default";
|
|
100
|
+
}
|
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,8 @@ 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 };
|
package/src/refine.ts
CHANGED
|
@@ -12,11 +12,11 @@
|
|
|
12
12
|
// agent loop, is model-agnostic, and keeps every delta visible and reviewable
|
|
13
13
|
// in the transcript. No hidden model calls.
|
|
14
14
|
|
|
15
|
-
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
15
|
+
import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
16
16
|
import { exportDurable, REFINE_ENTRY } from "./store.js";
|
|
17
17
|
|
|
18
|
-
const DEFAULT_LOOKBACK_TURNS = 25;
|
|
19
18
|
const DEFAULT_EVIDENCE_BYTES = 16000;
|
|
19
|
+
export const DEFAULT_LOOKBACK_TURNS = 25;
|
|
20
20
|
|
|
21
21
|
// Minimal entry shape for trajectory walking; kept loose to avoid coupling to
|
|
22
22
|
// pi's internal session entry types.
|
|
@@ -35,29 +35,55 @@ export function registerRefine(pi: ExtensionAPI): void {
|
|
|
35
35
|
"Online self-improvement: review recent trajectory and propose evidence-backed CRUD deltas to the harness state. Usage: /refine [lookback-turns] [--commit]",
|
|
36
36
|
handler: async (args, ctx) => {
|
|
37
37
|
const { lookback, commit } = parseArgs(args);
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
38
|
+
await runRefine(pi, ctx, { lookback, commit });
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
42
|
|
|
43
|
-
|
|
44
|
-
|
|
43
|
+
export interface RefineOptions {
|
|
44
|
+
lookback?: number;
|
|
45
|
+
commit?: boolean;
|
|
46
|
+
}
|
|
45
47
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
} catch (err) {
|
|
53
|
-
ctx.ui.notify(`Durable export failed: ${(err as Error).message}`, "warning");
|
|
54
|
-
}
|
|
55
|
-
}
|
|
48
|
+
export interface RefineResult {
|
|
49
|
+
evidenceBytes: number;
|
|
50
|
+
lookback: number;
|
|
51
|
+
commit: boolean;
|
|
52
|
+
source: "manual" | "auto";
|
|
53
|
+
}
|
|
56
54
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
55
|
+
/** Core refine routine shared by /refine (manual) and turn_end auto-refine.
|
|
56
|
+
* Gathers trajectory evidence, records an audit entry, optionally flushes the
|
|
57
|
+
* durable state, then sends the steering user message that drives the agent to
|
|
58
|
+
* propose evidence-backed CRUD deltas. `source` tags the audit entry so
|
|
59
|
+
* autonomous runs are distinguishable in the transcript. */
|
|
60
|
+
export async function runRefine(
|
|
61
|
+
pi: ExtensionAPI,
|
|
62
|
+
ctx: ExtensionContext,
|
|
63
|
+
options: RefineOptions = {},
|
|
64
|
+
source: "manual" | "auto" = "manual",
|
|
65
|
+
): Promise<RefineResult> {
|
|
66
|
+
const lookback = options.lookback ?? DEFAULT_LOOKBACK_TURNS;
|
|
67
|
+
const commit = options.commit ?? false;
|
|
68
|
+
ctx.ui.setStatus("harness", `Refining (last ${lookback} turns)…`);
|
|
69
|
+
const evidence = gatherEvidence(ctx, lookback);
|
|
70
|
+
const prompt = buildSteeringPrompt(evidence, lookback);
|
|
71
|
+
// Audit trail (branchable via /tree). `source` distinguishes manual /refine
|
|
72
|
+
// from opt-in auto-refine so autonomous runs are visible in the transcript.
|
|
73
|
+
pi.appendEntry(REFINE_ENTRY, { lookback, commit, startedAt: Date.now(), source });
|
|
74
|
+
if (commit) {
|
|
75
|
+
// Flush current state to the durable file first so the agent refines
|
|
76
|
+
// against the same view pi-reflect / pi-mem would see.
|
|
77
|
+
try {
|
|
78
|
+
const path = await exportDurable();
|
|
79
|
+
ctx.ui.notify(`Durable state exported to ${path}`, "info");
|
|
80
|
+
} catch (err) {
|
|
81
|
+
ctx.ui.notify(`Durable export failed: ${(err as Error).message}`, "warning");
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
pi.sendUserMessage(prompt);
|
|
85
|
+
ctx.ui.setStatus("harness", undefined);
|
|
86
|
+
return { evidenceBytes: evidence.length, lookback, commit, source };
|
|
61
87
|
}
|
|
62
88
|
|
|
63
89
|
function parseArgs(args: string): { lookback: number; commit: boolean } {
|
|
@@ -71,7 +97,7 @@ function parseArgs(args: string): { lookback: number; commit: boolean } {
|
|
|
71
97
|
return { lookback, commit };
|
|
72
98
|
}
|
|
73
99
|
|
|
74
|
-
function gatherEvidence(ctx:
|
|
100
|
+
function gatherEvidence(ctx: ExtensionContext, lookback: number): string {
|
|
75
101
|
const entries = ctx.sessionManager.getBranch() as AnyEntry[];
|
|
76
102
|
const messages = entries.filter((e) => e.type === "message" && e.message);
|
|
77
103
|
const recent = messages.slice(-lookback * 2); // ~2 entries per turn (user+assistant)
|
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
|
@@ -120,15 +120,49 @@ export function reconstruct(entries: Iterable<unknown>): void {
|
|
|
120
120
|
version = 0;
|
|
121
121
|
}
|
|
122
122
|
|
|
123
|
-
/**
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
123
|
+
/**
|
|
124
|
+
* Age importance of stale items, then prune below the floor.
|
|
125
|
+
* - decayAfterDays: if set, items whose updatedAt is older than this get
|
|
126
|
+
* importance -= decayStep (default 0.1) before pruning. Time-since-update
|
|
127
|
+
* is a weak proxy for staleness; pair with /harness keep|drop for signal.
|
|
128
|
+
* - Items below IMPORTANCE_FLOOR after decay are removed.
|
|
129
|
+
*/
|
|
130
|
+
export function decayAndPrune(
|
|
131
|
+
options: { decayAfterDays?: number; decayStep?: number } = {},
|
|
132
|
+
persist: (snapshot: HarnessState, version: number) => void,
|
|
133
|
+
): { pruned: number; decayed: number } {
|
|
134
|
+
let decayed = 0;
|
|
135
|
+
const step = options.decayStep ?? 0.1;
|
|
136
|
+
const ms = options.decayAfterDays !== undefined ? options.decayAfterDays * 86_400_000 : undefined;
|
|
137
|
+
const now = Date.now();
|
|
138
|
+
if (ms !== undefined) {
|
|
139
|
+
for (const i of state.items) {
|
|
140
|
+
if (now - i.updatedAt > ms) {
|
|
141
|
+
i.importance = clamp(i.importance - step);
|
|
142
|
+
decayed += 1;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
127
146
|
const before = state.items.length;
|
|
128
147
|
state.items = state.items.filter((i) => i.importance >= IMPORTANCE_FLOOR);
|
|
129
148
|
version += 1;
|
|
130
149
|
persist(state, version);
|
|
131
|
-
return { pruned: before - state.items.length };
|
|
150
|
+
return { pruned: before - state.items.length, decayed };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Nudge an item's importance by delta (clamped to [0,1]) and touch updatedAt. */
|
|
154
|
+
export function bumpImportance(
|
|
155
|
+
id: string,
|
|
156
|
+
delta: number,
|
|
157
|
+
persist: (snapshot: HarnessState, version: number) => void,
|
|
158
|
+
): HarnessItem | undefined {
|
|
159
|
+
const item = state.items.find((i) => i.id === id);
|
|
160
|
+
if (!item) return undefined;
|
|
161
|
+
item.importance = clamp(item.importance + delta);
|
|
162
|
+
item.updatedAt = Date.now();
|
|
163
|
+
version += 1;
|
|
164
|
+
persist(state, version);
|
|
165
|
+
return item;
|
|
132
166
|
}
|
|
133
167
|
|
|
134
168
|
export { STATE_ENTRY, REFINE_ENTRY, IMPORTANCE_FLOOR };
|