pi-continual-harness 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -16,8 +16,9 @@ with:
16
16
  The durable markdown file (`.pi/harness-state.md`) is the composition seam,
17
17
  and it is **two-way**: `/refine --commit` and `/harness export` write it;
18
18
  `/harness import` parses it back and merges into the live store (offline edits
19
- win on conflict), so refinements pi-reflect makes flow back online. pi-mem
20
- ingestion is a future/manual step.
19
+ win on conflict), so refinements pi-reflect makes flow back online. `/harness push-mem`
20
+ pushes active items into pi-mem's semantic store (see
21
+ [Composing with pi-mem](#composing-with-pi-mem)).
21
22
 
22
23
  ## Why
23
24
 
@@ -40,7 +41,7 @@ leaving offline and storage to the packages that already do them well.
40
41
  ## Install
41
42
 
42
43
  ```
43
- pi install git:github.com/<owner>/pi-continual-harness
44
+ pi install npm:pi-continual-harness
44
45
  ```
45
46
 
46
47
  Or drop `src/index.ts` into `~/.pi/agent/extensions/`.
@@ -63,6 +64,7 @@ Durable I/O (round-trip with pi-reflect):
63
64
  /harness prune [--decay <days>] # drop items below the importance floor
64
65
  /harness keep <id> # nudge importance up (+0.1)
65
66
  /harness drop <id> # nudge importance down (−0.1)
67
+ /harness push-mem [--all|--kind <kind>] # persist active items to pi-mem (save_memory)
66
68
  ```
67
69
 
68
70
  `import` reconciles the file into the live store: items whose id matches an
@@ -84,6 +86,24 @@ The model-facing tools:
84
86
  Active items are injected into the system prompt each turn as a structured
85
87
  block, appended to (never replacing) the base prompt.
86
88
 
89
+ ## Composing with pi-mem
90
+
91
+ `/harness push-mem` copies active harness items into
92
+ [pi-mem](https://github.com/georgebashi/pi-mem)'s semantic memory store, so they
93
+ become searchable across sessions. It works by **steering** the agent to call
94
+ pi-mem's `save_memory` tool — there is **no dependency** on pi-mem: it is a
95
+ soft-fail composition. If pi-mem (or any memory tool) is not installed, the
96
+ agent tells you so rather than fabricating one.
97
+
98
+ ```
99
+ pi install npm:pi-mem # optional companion
100
+ ```
101
+
102
+ By default only **memory**-kind items are pushed (the clean 1:1 mapping); use
103
+ `--all` for every active item or `--kind prompt|skill|subagent` for a specific
104
+ kind. The harness store itself is unchanged by a push — pi-mem gets a separate
105
+ copy.
106
+
87
107
  ## Configuration
88
108
 
89
109
  Optional config at `~/.pi/agent/harness.json` (missing or malformed → defaults):
@@ -93,7 +113,8 @@ Optional config at `~/.pi/agent/harness.json` (missing or malformed → defaults
93
113
  "durableScope": "global",
94
114
  "proposer": "steering",
95
115
  "remindRefine": { "enabled": false, "everyTurns": 50 },
96
- "autoRefine": { "enabled": false, "everyTurns": 100, "commit": false }
116
+ "autoRefine": { "enabled": false, "everyTurns": 100, "commit": false },
117
+ "outcomeImportance": { "enabled": false, "bump": 0.03 }
97
118
  }
98
119
  ```
99
120
 
@@ -109,10 +130,19 @@ Optional config at `~/.pi/agent/harness.json` (missing or malformed → defaults
109
130
  informational only — it never mutates state.
110
131
  - **`autoRefine`** — opt-in autonomous self-improvement (**off by default**).
111
132
  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.
133
+ (default 100). It is one of the package's opt-in autonomous paths: it reuses
134
+ the exact `/refine` routine (audited `REFINE_ENTRY` tagged `source: "auto"`,
135
+ branch-local, `/tree` rollback) and notifies before firing. `commit: true`
136
+ also flushes durable state on each run.
137
+ - **`outcomeImportance`** — opt-in autonomous **promotion** loop (**off by
138
+ default**). When `enabled`, a `turn_end` hook bumps (+`bump`, default 0.03)
139
+ the importance of any active item the agent cited by its `[h_xxxx]` tag in the
140
+ turn's output. Referenced items gain importance and get their `updatedAt`
141
+ touched (so they survive time-based decay); ignored items keep decaying. This
142
+ is the package's second opt-in autonomous path — promotion only, never
143
+ deletes, persisted/branchable like `harness_mutate`. Autonomous demotion from
144
+ outcomes is intentionally NOT done (high false-positive); use `/harness drop`,
145
+ `prune --decay`, or the `dedupe` proposer for that.
116
146
 
117
147
  ## How it works
118
148
 
@@ -180,16 +210,19 @@ Then `"my-proposer"` is selectable via `/refine --proposer my-proposer` or
180
210
 
181
211
  ## Status
182
212
 
183
- 0.3.x. Implemented:
213
+ 0.5.x. Implemented:
184
214
 
185
215
  - Unified harness-state store with branch-local snapshots (`/tree` rollback).
186
216
  - Online `/refine` + `harness_mutate` / `harness_list` tools.
187
217
  - Two-way durable round-trip with pi-reflect (`/harness import|export|status`).
188
218
  - Importance hygiene: `/harness prune [--decay <days>]` and `/harness keep|drop
189
219
  <id>`.
220
+ - **pi-mem composition**: `/harness push-mem [--all|--kind]` steers the agent
221
+ to persist active items into pi-mem (soft-fail; no dependency).
190
222
  - 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).
223
+ opt-in `turn_end` reminder, opt-in `turn_end` auto-refine, and an opt-in
224
+ `turn_end` outcome-importance loop — the package's two opt-in autonomous
225
+ paths (both off by default).
193
226
  - **Pluggable delta proposers** with a registry: `steering` (default) and
194
227
  `dedupe` (rule-based) shipped; `registerProposer()` for custom ones.
195
228
 
@@ -197,8 +230,10 @@ Open extension points (see `docs/ROADMAP.md`):
197
230
 
198
231
  - A dedicated-model proposer (interface ready; the hidden-model-spend tradeoff
199
232
  is a separate decision).
200
- - pi-mem ingestion of the durable export.
201
- - Outcome-driven importance (currently model-set + manual nudges).
233
+ - Correction-side outcome signals (promotion is shipped; autonomous demotion
234
+ is high-false-positive, so it is served by `/harness drop`, `prune --decay`,
235
+ and the `dedupe` proposer — a fuzzy `corrections` proposer is the natural
236
+ future extension).
202
237
 
203
238
  ## License
204
239
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-continual-harness",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Online self-improvement layer for pi. A unified, in-trajectory harness-state store (prompt notes, memory, skill descriptions, sub-agent specs) with a manual /refine that proposes evidence-backed structured CRUD deltas. Composes with pi-reflect (offline) and pi-mem (storage); owns only the online optimizer layer. Inspired by Continual Harness (arXiv 2605.09998) and ACE (arXiv 2510.04618).",
5
5
  "keywords": [
6
6
  "pi-package",
package/src/config.ts CHANGED
@@ -17,6 +17,8 @@ import { DEFAULT_DURABLE_PATH } from "./store.js";
17
17
 
18
18
  export const DEFAULT_EVERY_TURNS = 50;
19
19
  export const DEFAULT_AUTO_EVERY_TURNS = 100;
20
+ /** Default per-reference importance bump for the opt-in outcome loop. */
21
+ export const DEFAULT_REF_BUMP = 0.03;
20
22
 
21
23
  export interface HarnessConfig {
22
24
  durableScope?: "global" | "project";
@@ -31,6 +33,12 @@ export interface HarnessConfig {
31
33
  };
32
34
  /** Delta proposer name (see proposer.ts registry). Defaults to "steering". */
33
35
  proposer?: string;
36
+ /** Opt-in turn_end outcome loop: promote importance of items the agent
37
+ * references by their [h_xxxx] tag. Off by default (autonomous mutation). */
38
+ outcomeImportance?: {
39
+ enabled?: boolean;
40
+ bump?: number;
41
+ };
34
42
  }
35
43
 
36
44
  export const DEFAULT_CONFIG: HarnessConfig = {
@@ -38,6 +46,7 @@ export const DEFAULT_CONFIG: HarnessConfig = {
38
46
  remindRefine: { enabled: false, everyTurns: DEFAULT_EVERY_TURNS },
39
47
  autoRefine: { enabled: false, everyTurns: DEFAULT_AUTO_EVERY_TURNS, commit: false },
40
48
  proposer: "steering",
49
+ outcomeImportance: { enabled: false, bump: DEFAULT_REF_BUMP },
41
50
  };
42
51
 
43
52
  export const CONFIG_PATH = join(homedir(), ".pi", "agent", "harness.json");
@@ -58,6 +67,10 @@ function mergeConfig(over: Partial<HarnessConfig>): HarnessConfig {
58
67
  commit: over.autoRefine?.commit ?? false,
59
68
  },
60
69
  proposer: over.proposer ?? "steering",
70
+ outcomeImportance: {
71
+ enabled: over.outcomeImportance?.enabled ?? false,
72
+ bump: over.outcomeImportance?.bump ?? DEFAULT_REF_BUMP,
73
+ },
61
74
  };
62
75
  }
63
76
 
package/src/harness.ts CHANGED
@@ -22,14 +22,15 @@ import {
22
22
  reconstructFromDurable,
23
23
  } from "./store.js";
24
24
  import { loadConfig, resolveDurablePath } from "./config.js";
25
- import type { ComponentKind } from "./types.js";
25
+ import type { ComponentKind, HarnessItem } from "./types.js";
26
26
 
27
27
  export function registerHarness(pi: ExtensionAPI): void {
28
28
  pi.registerCommand("harness", {
29
29
  description:
30
30
  "Durable harness-state I/O + importance hygiene. Subcommands: " +
31
31
  "import [--prune] [path] · export [path] · status [path] · " +
32
- "prune [--decay <days>] · keep <id> · drop <id>.",
32
+ "prune [--decay <days>] · keep <id> · drop <id> · " +
33
+ "push-mem [--all|--kind <kind>] (persist active items to pi-mem).",
33
34
  handler: async (args, ctx) => {
34
35
  const parts = args.trim().split(/\s+/).filter(Boolean);
35
36
  const sub = (parts[0] ?? "status").toLowerCase();
@@ -50,6 +51,9 @@ export function registerHarness(pi: ExtensionAPI): void {
50
51
  case "drop":
51
52
  await handleBump(pi, ctx, rest, -0.1, "drop");
52
53
  return;
54
+ case "push-mem":
55
+ await handlePushMem(pi, ctx, rest);
56
+ return;
53
57
  case "status":
54
58
  default:
55
59
  await handleStatus(ctx, rest);
@@ -160,6 +164,61 @@ async function handleBump(
160
164
  ctx.ui.notify(`${label}: "${preview}" → importance ${item.importance.toFixed(2)}`, "info");
161
165
  }
162
166
 
167
+ /** Compose a steering message that asks the agent to persist the given active
168
+ * items to long-term memory via a memory tool (pi-mem's save_memory).
169
+ * Tool-agnostic: if no memory tool is present the agent says so; we never
170
+ * fabricate one. No dependency on pi-mem — soft-fail composition. */
171
+ function buildPushMemPrompt(items: HarnessItem[], scope: string): string {
172
+ const lines = [
173
+ `/harness push-mem — persist ${items.length} Continual Harness ${scope} to long-term memory`,
174
+ "",
175
+ "For EACH item below, call your memory tool (pi-mem exposes `save_memory`) once, mapping it as shown. This copies harness state into the semantic memory store so it is searchable across sessions.",
176
+ "",
177
+ "If you do NOT have a memory tool, do not fabricate one: tell the user to install pi-mem (`pi install npm:pi-mem`) and stop.",
178
+ "",
179
+ ];
180
+ items.forEach((i, n) => {
181
+ const title = `${i.id} ${i.content.slice(0, 48)}`;
182
+ const text = `${i.content} (evidence: ${i.evidence})`;
183
+ lines.push(
184
+ `${n + 1}. [${i.id}] ${i.content}`,
185
+ ` evidence: ${i.evidence}`,
186
+ ` → save_memory({ title: ${JSON.stringify(title)}, text: ${JSON.stringify(text)}, concepts: ["${i.kind}", "continual-harness"] })`,
187
+ "",
188
+ );
189
+ });
190
+ return lines.join("\n");
191
+ }
192
+
193
+ async function handlePushMem(
194
+ pi: ExtensionAPI,
195
+ ctx: ExtensionCommandContext,
196
+ rest: string[],
197
+ ): Promise<void> {
198
+ const all = rest.includes("--all");
199
+ const kindIdx = rest.indexOf("--kind");
200
+ let kind: ComponentKind | undefined;
201
+ if (kindIdx >= 0 && kindIdx + 1 < rest.length) {
202
+ const k = rest[kindIdx + 1]!;
203
+ if (k === "prompt" || k === "memory" || k === "skill" || k === "subagent") kind = k;
204
+ }
205
+ // Default: memory kind (the clean 1:1 mapping). --all or --kind override.
206
+ const items = getState().items.filter((i) =>
207
+ i.active && (all || (kind ? i.kind === kind : i.kind === "memory")),
208
+ );
209
+ if (items.length === 0) {
210
+ ctx.ui.notify(
211
+ "No active items to push (default: memory kind; use --all or --kind <kind>).",
212
+ "warning",
213
+ );
214
+ return;
215
+ }
216
+ const scope = all ? "item(s)" : `${kind ?? "memory"} item(s)`;
217
+ const msg = buildPushMemPrompt(items, scope);
218
+ pi.sendUserMessage(msg);
219
+ ctx.ui.notify(`Steering agent to persist ${items.length} ${scope} to pi-mem.`, "info");
220
+ }
221
+
163
222
  async function handleStatus(ctx: ExtensionCommandContext, rest: string[]): Promise<void> {
164
223
  const path = await resolvePath(rest, ctx);
165
224
  const items = getState().items;
package/src/index.ts CHANGED
@@ -13,6 +13,7 @@ import { registerRefine } from "./refine.js";
13
13
  import { registerTools } from "./tools.js";
14
14
  import { registerReminder } from "./remind.js";
15
15
  import { registerAutoRefine } from "./auto-refine.js";
16
+ import { registerOutcome } from "./outcome.js";
16
17
  import { getState, reconstruct, STATE_ENTRY } from "./store.js";
17
18
 
18
19
  export default function continualHarness(pi: ExtensionAPI): void {
@@ -32,6 +33,10 @@ export default function continualHarness(pi: ExtensionAPI): void {
32
33
  registerRefine(pi);
33
34
  registerHarness(pi);
34
35
  registerReminder(pi);
36
+ // Register outcome BEFORE auto-refine: the test fake overwrites turn_end on
37
+ // each registration (Map.set), and the auto-refine integration tests assume
38
+ // auto-refine is the surviving handler. In production pi, all three run.
39
+ registerOutcome(pi);
35
40
  registerAutoRefine(pi);
36
41
  }
37
42
 
package/src/outcome.ts ADDED
@@ -0,0 +1,104 @@
1
+ // turn_end outcome loop (Phase 5 / limit B-phase-2). The package's SECOND
2
+ // autonomous-mutation path (after auto-refine), so — like auto-refine — it is
3
+ // locked behind an explicit opt-in:
4
+ //
5
+ // ~/.pi/agent/harness.json
6
+ // { "outcomeImportance": { "enabled": true, "bump": 0.03 } }
7
+ //
8
+ // What it does: when the agent's own output cites an injected item by its
9
+ // [h_xxxx] tag, that is unambiguous POSITIVE evidence the item was useful, so
10
+ // we nudge its importance up (+bump). This closes the outcome half of the
11
+ // fitness loop that manual /harness keep|drop (B-phase-1) and /harness prune
12
+ // --decay (A) started: useful items gain importance AND get their updatedAt
13
+ // touched (so they survive time-based decay); ignored items keep decaying.
14
+ //
15
+ // Hard scope cut — CORRECTION/demotion from outcomes is genuinely fuzzy and
16
+ // high-false-positive, so it is intentionally NOT autonomized here. Demotion is
17
+ // served by the existing audited, reviewable primitives: /harness drop,
18
+ // /harness prune --decay, and the dedupe proposer. (See ROADMAP.md.)
19
+ //
20
+ // Safety properties (promotion only, never deletes): clamped to [0,1]; persisted
21
+ // via the same harness-state entries as harness_mutate (branch-local, /tree
22
+ // rollback); visible (notify per turn). Pure reference extraction is factored
23
+ // into findReferences() so it is unit-testable without a live pi runtime.
24
+
25
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
26
+ import { DEFAULT_REF_BUMP, type HarnessConfig, loadConfig } from "./config.js";
27
+ import { bumpImportance, getState } from "./store.js";
28
+
29
+ // Minimal entry shape; kept loose to avoid coupling to pi's internal types.
30
+ type AnyEntry = {
31
+ type?: string;
32
+ message?: {
33
+ role?: string;
34
+ content?: Array<{ type?: string; text?: string }>;
35
+ };
36
+ };
37
+
38
+ // Matches [h_xxxx] citation tags (the same form inject.ts emits).
39
+ const REF_RE = /\[(h_[a-z0-9_]+)\]/gi;
40
+
41
+ /**
42
+ * Extract distinct harness-item ids the agent cited by their [h_xxxx] tag,
43
+ * keeping only those that are currently known (active or not). Pure.
44
+ */
45
+ export function findReferences(text: string, knownIds: Set<string>): string[] {
46
+ const found = new Set<string>();
47
+ for (const m of text.matchAll(REF_RE)) {
48
+ const id = m[1]!.toLowerCase();
49
+ if (knownIds.has(id)) found.add(id);
50
+ }
51
+ return [...found];
52
+ }
53
+
54
+ // Number of assistant message-entries already consumed. -1 = unseen; the first
55
+ // observed turn_end seeds the cursor (so enabling mid-session never bumps
56
+ // retroactively for the whole history).
57
+ let scannedMessages = -1;
58
+
59
+ /** Test hook: reset internal cursor. */
60
+ export function resetOutcome(): void {
61
+ scannedMessages = -1;
62
+ }
63
+
64
+ /** Subscribe to turn_end and promote importance of referenced items (opt-in). */
65
+ export function registerOutcome(pi: ExtensionAPI): void {
66
+ pi.on("turn_end", async (_event, ctx) => {
67
+ const config = await loadConfig();
68
+ if (!config.outcomeImportance?.enabled) return;
69
+ const bump = config.outcomeImportance.bump ?? DEFAULT_REF_BUMP;
70
+ if (bump <= 0) return;
71
+
72
+ const entries = ctx.sessionManager.getBranch() as AnyEntry[];
73
+ const asst = entries.filter((e) => e.type === "message" && e.message?.role === "assistant");
74
+ if (scannedMessages < 0) {
75
+ scannedMessages = asst.length; // seed: no retroactive bump
76
+ return;
77
+ }
78
+ const fresh = asst.slice(scannedMessages);
79
+ scannedMessages = asst.length;
80
+
81
+ const text = fresh
82
+ .map((e) => (e.message?.content ?? []).map((c) => c.text ?? "").join(" "))
83
+ .join("\n");
84
+ if (!text.trim()) return;
85
+
86
+ const activeIds = new Set(getState().items.filter((i) => i.active).map((i) => i.id));
87
+ const refs = findReferences(text, activeIds);
88
+ if (refs.length === 0) return;
89
+
90
+ let bumped = 0;
91
+ for (const id of refs) {
92
+ const item = bumpImportance(id, bump, (snapshot, ver) => {
93
+ pi.appendEntry("harness-state", { state: snapshot, version: ver });
94
+ });
95
+ if (item) bumped += 1;
96
+ }
97
+ if (bumped > 0) {
98
+ ctx.ui.notify(
99
+ `Outcome: +${bump} importance for ${bumped} referenced item(s): ${refs.join(", ")}`,
100
+ "info",
101
+ );
102
+ }
103
+ });
104
+ }