pi-continual-harness 0.8.0 → 0.9.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 +60 -20
- package/package.json +1 -1
- package/src/config.ts +53 -13
- package/src/durable.ts +88 -0
- package/src/harness.ts +337 -46
- package/src/index.ts +14 -4
- package/src/refine.ts +10 -5
- package/src/store.ts +268 -30
- package/src/tools.ts +12 -0
- package/src/types.ts +21 -0
package/README.md
CHANGED
|
@@ -13,10 +13,12 @@ with:
|
|
|
13
13
|
→ behavioral-file refinement (the "deep" path).
|
|
14
14
|
- **pi-mem / pi-memory** — durable memory storage.
|
|
15
15
|
|
|
16
|
-
The durable markdown
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
16
|
+
The durable markdown layers are the composition seam — the shared
|
|
17
|
+
`~/.pi/agent/harness-state.md` plus per-project files under
|
|
18
|
+
`~/.pi/agent/harness-state/<slug>.md` — and they are **two-way**:
|
|
19
|
+
`/refine --commit` and `/harness export` write them; `/harness import` parses
|
|
20
|
+
them back and merges into the live store (offline edits win on conflict), so
|
|
21
|
+
refinements pi-reflect makes flow back online. `/harness push-mem`
|
|
20
22
|
pushes active items into pi-mem's semantic store (see
|
|
21
23
|
[Composing with pi-mem](#composing-with-pi-mem)).
|
|
22
24
|
|
|
@@ -58,23 +60,42 @@ Or drop `src/index.ts` into `~/.pi/agent/extensions/`.
|
|
|
58
60
|
Durable I/O (round-trip with pi-reflect):
|
|
59
61
|
|
|
60
62
|
```
|
|
61
|
-
/harness status # counts + durable
|
|
62
|
-
/harness export [path] #
|
|
63
|
-
/harness import [--prune] [path] #
|
|
63
|
+
/harness status # counts + durable layer presence/mtime
|
|
64
|
+
/harness export [path] # layered export (or full snapshot to a path)
|
|
65
|
+
/harness import [--prune] [path] # layered import (or single file from a path)
|
|
64
66
|
/harness prune [--decay <days>] # drop items below the importance floor
|
|
65
67
|
/harness keep <id> # nudge importance up (+0.1)
|
|
66
68
|
/harness drop <id> # nudge importance down (−0.1)
|
|
69
|
+
/harness move <id> global|project # move an item between durable layers
|
|
70
|
+
/harness split # steer the agent to classify every item's scope
|
|
67
71
|
/harness push-mem [--all|--kind <kind>|--model <provider/id|active>] # persist active items to pi-mem (save_memory)
|
|
68
72
|
```
|
|
69
73
|
|
|
70
|
-
|
|
74
|
+
Subcommands, flags, `--kind`/`--model` values, `move` scopes, and `keep`/
|
|
75
|
+
`drop`/`move` item ids all autocomplete in the TUI as you type after
|
|
76
|
+
`/harness `.
|
|
77
|
+
|
|
78
|
+
**Durable layers.** Every item carries its own durable scope: `global`
|
|
79
|
+
(the shared `~/.pi/agent/harness-state.md`) or `project`
|
|
80
|
+
(`~/.pi/agent/harness-state/<slug>.md`, slug derived from the session `cwd`).
|
|
81
|
+
`/harness export` without a path partitions items by their scope into the
|
|
82
|
+
layer files; `/harness import` without a path merges the global file always,
|
|
83
|
+
plus the current project's file (the project layer wins id collisions;
|
|
84
|
+
`--prune` drops only items absent from **every** layer). An explicit path keeps
|
|
85
|
+
the classic single-file semantics. Move items between layers with
|
|
86
|
+
`/harness move`, or classify the whole store at once with `/harness split`
|
|
87
|
+
(the agent proposes scope-only `harness_mutate` updates — visible in the
|
|
88
|
+
transcript, `/tree`-rollback-able).
|
|
89
|
+
|
|
90
|
+
`import` reconciles the file(s) into the live store: items whose id matches an
|
|
71
91
|
existing entry are updated (offline edits win on content/evidence/importance),
|
|
72
92
|
new entries are created. By default nothing is deleted — `--prune` also drops
|
|
73
|
-
active items whose id is no longer in the file (inactive items are always
|
|
74
|
-
preserved). Point pi-reflect at
|
|
93
|
+
active items whose id is no longer in the file(s) (inactive items are always
|
|
94
|
+
preserved). Point pi-reflect at either layer to refine it offline:
|
|
75
95
|
|
|
76
96
|
```
|
|
77
97
|
/reflect ~/.pi/agent/harness-state.md
|
|
98
|
+
/reflect ~/.pi/agent/harness-state/<project-slug>.md
|
|
78
99
|
```
|
|
79
100
|
|
|
80
101
|
The model-facing tools:
|
|
@@ -114,7 +135,7 @@ Optional config at `~/.pi/agent/harness.json` (missing or malformed → defaults
|
|
|
114
135
|
|
|
115
136
|
```json
|
|
116
137
|
{
|
|
117
|
-
"
|
|
138
|
+
"autoImport": false,
|
|
118
139
|
"proposer": "steering",
|
|
119
140
|
"injection": { "enabled": true, "maxTokens": 1500, "maxPerKind": 10, "charsPerToken": 4 },
|
|
120
141
|
"remindRefine": { "enabled": false, "everyTurns": 50 },
|
|
@@ -123,10 +144,15 @@ Optional config at `~/.pi/agent/harness.json` (missing or malformed → defaults
|
|
|
123
144
|
}
|
|
124
145
|
```
|
|
125
146
|
|
|
126
|
-
- **`
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
147
|
+
- **`autoImport`** — opt-in **durable sync** (off by default). `true` bundles
|
|
148
|
+
both directions: on `session_start` the durable layers are imported
|
|
149
|
+
automatically (global file always + the current project's file, with the same
|
|
150
|
+
loss-free merge as `/harness import` — an import that changes nothing stays
|
|
151
|
+
silent), and on `turn_end` the layers are re-exported whenever the live store
|
|
152
|
+
changed since the last export. This is what makes `/refine` output survive
|
|
153
|
+
into **new** sessions without manual export/import ceremony. Both halves are
|
|
154
|
+
visible (one notify line) and use the same persisted `harness-state` entries
|
|
155
|
+
as every other mutation (`/tree` rollback covers them).
|
|
130
156
|
- **`injection`** — the selection policy for WHAT gets surfaced in the system
|
|
131
157
|
prompt each turn (see [Injection selection](#injection-selection-on-by-default)).
|
|
132
158
|
ON by default: items are importance-ordered, capped at `maxPerKind` (default
|
|
@@ -154,6 +180,13 @@ Optional config at `~/.pi/agent/harness.json` (missing or malformed → defaults
|
|
|
154
180
|
outcomes is intentionally NOT done (high false-positive); use `/harness drop`,
|
|
155
181
|
`prune --decay`, or the `dedupe` proposer for that.
|
|
156
182
|
|
|
183
|
+
> **Deprecated:** `durableScope` (0.9.0). Items now carry their own scope
|
|
184
|
+
> (`/harness move`), and durable I/O is always layered — the key no longer
|
|
185
|
+
> switches anything but is still parsed so existing configs keep loading.
|
|
186
|
+
> Migration from a `durableScope: "project"` setup: run `/harness import` once
|
|
187
|
+
> in the project (its file's items adopt project scope), then move any strays
|
|
188
|
+
> with `/harness move` (or classify the whole store with `/harness split`).
|
|
189
|
+
|
|
157
190
|
## How it works
|
|
158
191
|
|
|
159
192
|
1. `/refine` gathers recent trajectory evidence from the current session branch.
|
|
@@ -310,19 +343,26 @@ Then `"my-proposer"` is selectable via `/refine --proposer my-proposer` or
|
|
|
310
343
|
|
|
311
344
|
## Status
|
|
312
345
|
|
|
313
|
-
0.
|
|
346
|
+
0.9.x. Implemented:
|
|
314
347
|
|
|
315
348
|
- Unified harness-state store with branch-local snapshots (`/tree` rollback).
|
|
316
349
|
- Online `/refine` + `harness_mutate` / `harness_list` tools.
|
|
317
|
-
- Two-way durable round-trip with pi-reflect (`/harness import|export|status`)
|
|
350
|
+
- Two-way durable round-trip with pi-reflect (`/harness import|export|status`),
|
|
351
|
+
**layered on per-item scope**: every item is `global` or `project`
|
|
352
|
+
(`/harness move`, `/harness split`), and `/harness export|import` operate on
|
|
353
|
+
both layers.
|
|
318
354
|
- Importance hygiene: `/harness prune [--decay <days>]` and `/harness keep|drop
|
|
319
355
|
<id>`.
|
|
320
356
|
- **pi-mem composition**: `/harness push-mem [--all|--kind|--model]` steers the agent
|
|
321
357
|
to persist active items into pi-mem (soft-fail; no dependency).
|
|
322
|
-
-
|
|
358
|
+
- **Opt-in durable sync** (`"autoImport": true`): session_start layered
|
|
359
|
+
auto-import + turn_end layered auto-export when the store changed —
|
|
360
|
+
`/refine` output survives into new sessions with zero ceremony. Off by
|
|
361
|
+
default; every action visible and `/tree`-rollback-able.
|
|
362
|
+
- Optional config (`~/.pi/agent/harness.json`): the durable-sync opt-in, an
|
|
323
363
|
opt-in `turn_end` reminder, opt-in `turn_end` auto-refine, and an opt-in
|
|
324
|
-
`turn_end` outcome-importance loop — the package's
|
|
325
|
-
paths (
|
|
364
|
+
`turn_end` outcome-importance loop — the package's opt-in autonomous
|
|
365
|
+
paths (all off by default).
|
|
326
366
|
- **Pluggable delta proposers** with a registry: `steering` (default) and
|
|
327
367
|
`dedupe` (rule-based) shipped; `registerProposer()` for custom ones.
|
|
328
368
|
- **Per-model isolation**: every item is bound to a `provider/id` and injected
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-continual-harness",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.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/config.ts
CHANGED
|
@@ -1,19 +1,24 @@
|
|
|
1
1
|
// User configuration for continual-harness: ~/.pi/agent/harness.json
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
// -
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
3
|
+
// This file (optional; missing → defaults) configures:
|
|
4
|
+
// - autoImport: opt-in durable sync (issue #7). `true` bundles BOTH
|
|
5
|
+
// directions: session_start layered auto-import (global file always + the
|
|
6
|
+
// current project's file) and turn_end layered auto-export whenever the
|
|
7
|
+
// live store changed since the last export. Off by default — importing is
|
|
8
|
+
// normally an explicit, reviewable /harness import.
|
|
9
|
+
// - durableScope: DEPRECATED (0.9.0). Items now carry their own scope
|
|
10
|
+
// (`/harness move <id> global|project`); durable I/O is layered on top of
|
|
11
|
+
// per-item scope and this key no longer switches anything. Still parsed so
|
|
12
|
+
// existing configs keep loading; will be removed in a future release.
|
|
13
|
+
// - remindRefine / autoRefine / outcomeImportance / injection / proposer:
|
|
14
|
+
// unchanged (see below).
|
|
10
15
|
//
|
|
11
16
|
// Robust by design: missing or malformed file → DEFAULT_CONFIG, never throws.
|
|
12
17
|
|
|
13
18
|
import { readFile } from "node:fs/promises";
|
|
14
19
|
import { homedir } from "node:os";
|
|
15
|
-
import { join } from "node:path";
|
|
16
|
-
import { DEFAULT_DURABLE_PATH } from "./store.js";
|
|
20
|
+
import { basename, dirname, join } from "node:path";
|
|
21
|
+
import { DEFAULT_DURABLE_PATH, PROJECT_DURABLE_DIR, type LayerFile, type ScopeInfo } from "./store.js";
|
|
17
22
|
import { DEFAULT_INJECTION, normalizeInjection, type NormalizedInjection } from "./select.js";
|
|
18
23
|
|
|
19
24
|
export const DEFAULT_EVERY_TURNS = 50;
|
|
@@ -22,7 +27,12 @@ export const DEFAULT_AUTO_EVERY_TURNS = 100;
|
|
|
22
27
|
export const DEFAULT_REF_BUMP = 0.03;
|
|
23
28
|
|
|
24
29
|
export interface HarnessConfig {
|
|
30
|
+
/** DEPRECATED (0.9.0): items carry their own scope now (see /harness move);
|
|
31
|
+
* durable I/O is always layered. Parsed for compatibility, ignored. */
|
|
25
32
|
durableScope?: "global" | "project";
|
|
33
|
+
/** Opt-in durable sync (issue #7): session_start layered auto-import +
|
|
34
|
+
* turn_end layered auto-export when the store changed. Default false. */
|
|
35
|
+
autoImport?: boolean;
|
|
26
36
|
remindRefine?: {
|
|
27
37
|
enabled?: boolean;
|
|
28
38
|
everyTurns?: number;
|
|
@@ -47,6 +57,7 @@ export interface HarnessConfig {
|
|
|
47
57
|
|
|
48
58
|
export const DEFAULT_CONFIG: HarnessConfig = {
|
|
49
59
|
durableScope: "global",
|
|
60
|
+
autoImport: false,
|
|
50
61
|
remindRefine: { enabled: false, everyTurns: DEFAULT_EVERY_TURNS },
|
|
51
62
|
autoRefine: { enabled: false, everyTurns: DEFAULT_AUTO_EVERY_TURNS, commit: false },
|
|
52
63
|
proposer: "steering",
|
|
@@ -65,6 +76,7 @@ let cached: HarnessConfig | undefined;
|
|
|
65
76
|
function mergeConfig(over: Partial<HarnessConfig>): HarnessConfig {
|
|
66
77
|
return {
|
|
67
78
|
durableScope: over.durableScope === "project" ? "project" : "global",
|
|
79
|
+
autoImport: over.autoImport === true,
|
|
68
80
|
remindRefine: {
|
|
69
81
|
enabled: over.remindRefine?.enabled ?? false,
|
|
70
82
|
everyTurns: over.remindRefine?.everyTurns ?? DEFAULT_EVERY_TURNS,
|
|
@@ -113,13 +125,41 @@ export function resetConfigCache(): void {
|
|
|
113
125
|
}
|
|
114
126
|
|
|
115
127
|
/**
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
* - project → ~/.pi/agent/harness-state/<slug>.md (slug derived from cwd)
|
|
128
|
+
* @deprecated Durable I/O is layered on per-item scope (0.9.0); this key no
|
|
129
|
+
* longer switches the durable path. Kept so old configs keep loading.
|
|
119
130
|
*/
|
|
120
131
|
export function resolveDurablePath(config: HarnessConfig, cwd?: string): string {
|
|
121
132
|
if (config.durableScope !== "project") return DEFAULT_DURABLE_PATH;
|
|
122
|
-
return
|
|
133
|
+
return projectDurablePath(cwd);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Path of a project's durable layer file: <projectDir>/<slug>.md. */
|
|
137
|
+
export function projectDurablePath(cwd?: string): string {
|
|
138
|
+
return join(PROJECT_DURABLE_DIR, `${projectSlug(cwd)}.md`);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** The two layers a layered import reads for a session in `cwd`: global first
|
|
142
|
+
* (so the project layer wins id collisions), the project layer only when its
|
|
143
|
+
* slug matches the session cwd. Scope defaults for untagged items ride along
|
|
144
|
+
* (a layer file's items belong to that layer). */
|
|
145
|
+
export function layerFilesFor(cwd?: string): LayerFile[] {
|
|
146
|
+
return [
|
|
147
|
+
{ path: DEFAULT_DURABLE_PATH, defaultScope: { scope: "global" } },
|
|
148
|
+
{ path: projectDurablePath(cwd), defaultScope: { scope: "project", project: projectSlug(cwd) } },
|
|
149
|
+
];
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Derive the layer scope a file at `path` represents — used when a user
|
|
153
|
+
* imports a file by explicit path: the global file → global; anything under
|
|
154
|
+
* the project dir → project + slug from the filename stem; other paths →
|
|
155
|
+
* global. Pure path-layout knowledge. */
|
|
156
|
+
export function defaultScopeForPath(path: string): ScopeInfo {
|
|
157
|
+
if (path === DEFAULT_DURABLE_PATH) return { scope: "global" };
|
|
158
|
+
if (dirname(path) === PROJECT_DURABLE_DIR) {
|
|
159
|
+
const stem = basename(path, ".md");
|
|
160
|
+
if (stem) return { scope: "project", project: stem };
|
|
161
|
+
}
|
|
162
|
+
return { scope: "global" };
|
|
123
163
|
}
|
|
124
164
|
|
|
125
165
|
/** Stable, filesystem-safe slug from a directory path. Falls back to "default". */
|
package/src/durable.ts
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// Layered durable sync (issue #7), opt-in via harness.json { "autoImport": true }.
|
|
2
|
+
//
|
|
3
|
+
// One flag bundles BOTH directions so the durable seam never lags the live
|
|
4
|
+
// store and /refine output survives into NEW sessions without manual
|
|
5
|
+
// export/import ceremony:
|
|
6
|
+
//
|
|
7
|
+
// session_start → syncDurableOnStart(): layered auto-import with the same
|
|
8
|
+
// loss-free merge semantics as /harness import — global file always, the
|
|
9
|
+
// current project's file when its slug matches the session cwd (global
|
|
10
|
+
// first, so the project layer wins id collisions). Idempotent: an import
|
|
11
|
+
// that changes nothing persists nothing and stays quiet.
|
|
12
|
+
//
|
|
13
|
+
// turn_end → registerAutoExport(): layered export when the store version
|
|
14
|
+
// changed since the last export (bounded: the layer files are small).
|
|
15
|
+
// Materializes the session-restored store on the first turn of a session,
|
|
16
|
+
// so a missing/stale durable file self-heals.
|
|
17
|
+
//
|
|
18
|
+
// Both paths are visible (one notify line) and use the same persisted
|
|
19
|
+
// harness-state entries as every other mutation (/tree rollback covers them).
|
|
20
|
+
|
|
21
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
22
|
+
import { DEFAULT_DURABLE_PATH, PROJECT_DURABLE_DIR } from "./store.js";
|
|
23
|
+
import { exportDurableLayers, getVersion, importDurableLayers } from "./store.js";
|
|
24
|
+
import type { LayerFile, LayerPaths } from "./store.js";
|
|
25
|
+
import { layerFilesFor, loadConfig, projectSlug } from "./config.js";
|
|
26
|
+
|
|
27
|
+
/** Real (homedir-based) layer paths. Tests inject their own. */
|
|
28
|
+
function layerPaths(): LayerPaths {
|
|
29
|
+
return { globalPath: DEFAULT_DURABLE_PATH, projectDir: PROJECT_DURABLE_DIR };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Version at the last auto-export. -1 = never exported this session.
|
|
33
|
+
let lastExportedVersion = -1;
|
|
34
|
+
|
|
35
|
+
/** Test hook / session reset: next turn_end re-exports once (materialize). */
|
|
36
|
+
export function resetDurableSync(): void {
|
|
37
|
+
lastExportedVersion = -1;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* session_start half of the durable sync: layered import (opt-in). `files`
|
|
42
|
+
* defaults to the real layers for ctx.cwd; tests inject temp paths.
|
|
43
|
+
*/
|
|
44
|
+
export async function syncDurableOnStart(
|
|
45
|
+
pi: ExtensionAPI,
|
|
46
|
+
ctx: ExtensionContext,
|
|
47
|
+
files?: LayerFile[],
|
|
48
|
+
): Promise<void> {
|
|
49
|
+
const config = await loadConfig();
|
|
50
|
+
if (!config.autoImport) return;
|
|
51
|
+
const layers = files ?? layerFilesFor(ctx.cwd);
|
|
52
|
+
const res = await importDurableLayers(layers, {}, (snapshot, ver) => {
|
|
53
|
+
pi.appendEntry("harness-state", { state: snapshot, version: ver });
|
|
54
|
+
});
|
|
55
|
+
if (res.missingFile) return; // nothing durable yet — stay quiet
|
|
56
|
+
const changed = res.created + res.updated + res.pruned;
|
|
57
|
+
if (changed === 0) return; // in sync — no noise
|
|
58
|
+
const bits = [`${res.created} created`, `${res.updated} updated`];
|
|
59
|
+
if (res.pruned > 0) bits.push(`${res.pruned} pruned`);
|
|
60
|
+
ctx.ui.notify(`Continual Harness: durable sync imported ${res.imported} item(s) (${bits.join(", ")}).`, "info");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* turn_end half of the durable sync: layered export when the live store
|
|
65
|
+
* changed since the last export (opt-in, bundled with autoImport). `paths`
|
|
66
|
+
* defaults to the real layer dirs; tests inject temp dirs.
|
|
67
|
+
*/
|
|
68
|
+
export function registerAutoExport(pi: ExtensionAPI, paths?: LayerPaths): void {
|
|
69
|
+
pi.on("turn_end", async (_event, ctx) => {
|
|
70
|
+
const config = await loadConfig();
|
|
71
|
+
if (!config.autoImport) return;
|
|
72
|
+
if (getVersion() === lastExportedVersion) return;
|
|
73
|
+
try {
|
|
74
|
+
const written = await exportDurableLayers(
|
|
75
|
+
paths ?? layerPaths(),
|
|
76
|
+
projectSlug(ctx.cwd),
|
|
77
|
+
);
|
|
78
|
+
lastExportedVersion = getVersion();
|
|
79
|
+
ctx.ui.notify(
|
|
80
|
+
`Continual Harness: durable state exported (${written.length} file(s)).`,
|
|
81
|
+
"info",
|
|
82
|
+
);
|
|
83
|
+
} catch (err) {
|
|
84
|
+
// Soft-fail but leave lastExportedVersion stale → retried next turn.
|
|
85
|
+
ctx.ui.notify(`Durable auto-export failed: ${(err as Error).message}`, "warning");
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
}
|