pi-continual-harness 0.1.1 → 0.2.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
@@ -13,8 +13,11 @@ with:
13
13
  → behavioral-file refinement (the "deep" path).
14
14
  - **pi-mem / pi-memory** — durable memory storage.
15
15
 
16
- The durable markdown export (`.pi/harness-state.md`) is the composition seam:
17
- pi-reflect can read and refine it, pi-mem can ingest it.
16
+ The durable markdown file (`.pi/harness-state.md`) is the composition seam,
17
+ and it is **two-way**: `/refine --commit` and `/harness export` write it;
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.
18
21
 
19
22
  ## Why
20
23
 
@@ -50,6 +53,24 @@ Or drop `src/index.ts` into `~/.pi/agent/extensions/`.
50
53
  /refine 25 --commit # also export durable state to ~/.pi/agent/harness-state.md
51
54
  ```
52
55
 
56
+ Durable I/O (round-trip with pi-reflect):
57
+
58
+ ```
59
+ /harness status # counts + durable file presence/mtime
60
+ /harness export [path] # write active items to a markdown file
61
+ /harness import [--prune] [path] # parse it back and merge (offline edits win)
62
+ ```
63
+
64
+ `import` reconciles the file into the live store: items whose id matches an
65
+ existing entry are updated (offline edits win on content/evidence/importance),
66
+ new entries are created. By default nothing is deleted — `--prune` also drops
67
+ active items whose id is no longer in the file (inactive items are always
68
+ preserved). Point pi-reflect at the same file to refine it offline:
69
+
70
+ ```
71
+ /reflect ~/.pi/agent/harness-state.md
72
+ ```
73
+
53
74
  The model-facing tools:
54
75
 
55
76
  - `harness_list [kind]` — read current state (optionally filtered by kind).
@@ -82,13 +103,15 @@ model-agnostic, and keeps every delta visible and reviewable in the transcript.
82
103
 
83
104
  ## Status
84
105
 
85
- Scaffold (0.1.0). Extension points to grow into:
106
+ 0.1.x. The durable round-trip with pi-reflect is implemented
107
+ (`/harness import|export|status`). Open extension points:
86
108
 
87
109
  - A pluggable delta proposer (currently the agent itself, via steering).
88
110
  - Project-local vs global durable paths.
89
111
  - Optional `turn_end` auto-trigger behind a setting (intentionally off by
90
112
  default — autonomous self-mutation is a sharp edge).
91
- - Importance decay/prune policy.
113
+ - Importance decay/prune policy — `decayAndPrune()` exists but is not yet wired.
114
+ - pi-mem ingestion of the durable export (manual/future).
92
115
 
93
116
  ## License
94
117
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-continual-harness",
3
- "version": "0.1.1",
3
+ "version": "0.2.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/harness.ts ADDED
@@ -0,0 +1,110 @@
1
+ // /harness — durable I/O. The two-way round-trip seam with pi-reflect.
2
+ //
3
+ // /harness status [path] counts + durable file presence/mtime
4
+ // /harness export [path] write active items to a markdown file
5
+ // /harness import [--prune] [path] parse it back and merge (durable wins)
6
+ //
7
+ // export writes the active items to ~/.pi/agent/harness-state.md (best-effort);
8
+ // import parses that file and merges into the live store. Because pi-reflect
9
+ // edits markdown files and git-commits, pointing it at the same file closes the
10
+ // loop: offline refinement flows back into the online store.
11
+ //
12
+ // Manual only (no session_start auto-import): importing is an explicit,
13
+ // reviewable action, matching the package's "no autonomous mutation" stance.
14
+
15
+ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
16
+ import { stat } from "node:fs/promises";
17
+ import { DEFAULT_DURABLE_PATH, exportDurable, getState, reconstructFromDurable } from "./store.js";
18
+ import type { ComponentKind } from "./types.js";
19
+
20
+ export function registerHarness(pi: ExtensionAPI): void {
21
+ pi.registerCommand("harness", {
22
+ description:
23
+ "Durable harness-state I/O (round-trip seam with pi-reflect). " +
24
+ "Subcommands: import [--prune] [path] · export [path] · status [path].",
25
+ handler: async (args, ctx) => {
26
+ const parts = args.trim().split(/\s+/).filter(Boolean);
27
+ const sub = (parts[0] ?? "status").toLowerCase();
28
+ const rest = parts.slice(1);
29
+ switch (sub) {
30
+ case "import":
31
+ await handleImport(pi, ctx, rest);
32
+ return;
33
+ case "export":
34
+ await handleExport(ctx, rest);
35
+ return;
36
+ case "status":
37
+ default:
38
+ await handleStatus(ctx, rest);
39
+ return;
40
+ }
41
+ },
42
+ });
43
+ }
44
+
45
+ function resolvePath(rest: string[]): string {
46
+ return rest.find((a) => !a.startsWith("-")) ?? DEFAULT_DURABLE_PATH;
47
+ }
48
+
49
+ async function handleImport(
50
+ pi: ExtensionAPI,
51
+ ctx: ExtensionCommandContext,
52
+ rest: string[],
53
+ ): Promise<void> {
54
+ const prune = rest.includes("--prune");
55
+ const path = resolvePath(rest);
56
+ ctx.ui.setStatus("harness", `Importing durable state${prune ? " (prune)" : ""}…`);
57
+ try {
58
+ const res = await reconstructFromDurable(path, { prune }, (snapshot, ver) => {
59
+ pi.appendEntry("harness-state", { state: snapshot, version: ver });
60
+ });
61
+ if (res.missingFile) {
62
+ ctx.ui.notify(
63
+ `No durable file at ${path}. Run /refine --commit or /harness export first.`,
64
+ "warning",
65
+ );
66
+ return;
67
+ }
68
+ const bits = [`${res.created} created`, `${res.updated} updated`];
69
+ if (prune) bits.push(`${res.pruned} pruned`);
70
+ ctx.ui.notify(`Imported ${res.imported} item(s) from ${path} (${bits.join(", ")}).`, "info");
71
+ } catch (err) {
72
+ ctx.ui.notify(`Harness import failed: ${(err as Error).message}`, "error");
73
+ }
74
+ ctx.ui.setStatus("harness", undefined);
75
+ }
76
+
77
+ async function handleExport(ctx: ExtensionCommandContext, rest: string[]): Promise<void> {
78
+ const path = resolvePath(rest);
79
+ ctx.ui.setStatus("harness", "Exporting durable state…");
80
+ try {
81
+ const written = await exportDurable(path);
82
+ const n = getState().items.filter((i) => i.active).length;
83
+ ctx.ui.notify(`Exported ${n} active item(s) to ${written}`, "info");
84
+ } catch (err) {
85
+ ctx.ui.notify(`Harness export failed: ${(err as Error).message}`, "error");
86
+ }
87
+ ctx.ui.setStatus("harness", undefined);
88
+ }
89
+
90
+ async function handleStatus(ctx: ExtensionCommandContext, rest: string[]): Promise<void> {
91
+ const path = resolvePath(rest);
92
+ const items = getState().items;
93
+ const active = items.filter((i) => i.active);
94
+ const counts: Record<ComponentKind, number> = { prompt: 0, memory: 0, skill: 0, subagent: 0 };
95
+ for (const i of active) counts[i.kind] += 1;
96
+ let fileState: string;
97
+ try {
98
+ const st = await stat(path);
99
+ fileState = `${path} (modified ${st.mtime.toISOString()})`;
100
+ } catch {
101
+ fileState = `none at ${path}`;
102
+ }
103
+ ctx.ui.setStatus("harness", undefined);
104
+ ctx.ui.notify(
105
+ `Harness: ${active.length} active / ${items.length} total — ` +
106
+ `prompt ${counts.prompt}, memory ${counts.memory}, skill ${counts.skill}, subagent ${counts.subagent}. ` +
107
+ `Durable: ${fileState}.`,
108
+ "info",
109
+ );
110
+ }
package/src/index.ts CHANGED
@@ -7,6 +7,7 @@
7
7
  // See README for design rationale and the research it is grounded in.
8
8
 
9
9
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
10
+ import { registerHarness } from "./harness.js";
10
11
  import { registerInjection } from "./inject.js";
11
12
  import { registerRefine } from "./refine.js";
12
13
  import { registerTools } from "./tools.js";
@@ -27,6 +28,7 @@ export default function continualHarness(pi: ExtensionAPI): void {
27
28
  registerInjection(pi);
28
29
  registerTools(pi);
29
30
  registerRefine(pi);
31
+ registerHarness(pi);
30
32
  }
31
33
 
32
34
  export { STATE_ENTRY };
package/src/store.ts CHANGED
@@ -10,7 +10,7 @@
10
10
  // to a markdown file pi-reflect can read and refine offline, and pi-mem can
11
11
  // ingest. Best-effort; the package works without it.
12
12
 
13
- import { mkdir, writeFile } from "node:fs/promises";
13
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
14
14
  import { homedir } from "node:os";
15
15
  import { dirname, join } from "node:path";
16
16
  import type { AppliedDelta, ComponentKind, Delta, HarnessItem, HarnessState } from "./types.js";
@@ -18,7 +18,7 @@ import type { AppliedDelta, ComponentKind, Delta, HarnessItem, HarnessState } fr
18
18
  const STATE_ENTRY = "harness-state";
19
19
  const REFINE_ENTRY = "harness-refinement";
20
20
 
21
- const DEFAULT_DURABLE_PATH = join(homedir(), ".pi", "agent", "harness-state.md");
21
+ export const DEFAULT_DURABLE_PATH = join(homedir(), ".pi", "agent", "harness-state.md");
22
22
 
23
23
  const IMPORTANCE_FLOOR = 0.3;
24
24
 
@@ -167,3 +167,190 @@ function titleFor(kind: ComponentKind): string {
167
167
  return "Sub-agent specs";
168
168
  }
169
169
  }
170
+
171
+ // ---- Durable import (round-trip seam with pi-reflect) ---------------------
172
+ //
173
+ // exportDurable() is write-only by design. reconstructFromDurable() closes the
174
+ // loop: it parses the markdown back into items and merges them into the live
175
+ // store, so offline edits pi-reflect makes to harness-state.md flow back in.
176
+ //
177
+ // Merge semantics (predictable, loss-free by default):
178
+ // - parsed item whose id matches an existing item → UPDATE in place
179
+ // (durable wins on content/evidence/importance; reactivated; createdAt kept).
180
+ // - parsed item with a new/foreign id → CREATE.
181
+ // - items in the store but absent from the file → KEPT by default.
182
+ // Pass { prune: true } to also drop active items whose id is not in the
183
+ // file (inactive items are always preserved — the durable export never
184
+ // contains them, so they cannot have been "deleted" by pi-reflect).
185
+
186
+ export interface DurableImportResult {
187
+ /** Items successfully parsed from the file. */
188
+ imported: number;
189
+ created: number;
190
+ updated: number;
191
+ /** Only non-zero when { prune: true }. */
192
+ pruned: number;
193
+ /** True if the file did not exist (no-op). */
194
+ missingFile: boolean;
195
+ }
196
+
197
+ interface ParsedItem {
198
+ id?: string;
199
+ kind: ComponentKind;
200
+ importance: number;
201
+ content: string;
202
+ evidence: string;
203
+ }
204
+
205
+ // Section title → kind. Exact export titles first, then tolerant keyword
206
+ // fallbacks so pi-reflect's edits to headings still parse.
207
+ const TITLE_TO_KIND: Array<[RegExp, ComponentKind]> = [
208
+ [/supplemental prompt notes/i, "prompt"],
209
+ [/memory facts/i, "memory"],
210
+ [/skill descriptions/i, "skill"],
211
+ [/sub-?agent specs/i, "subagent"],
212
+ [/\bprompt\b/i, "prompt"],
213
+ [/\bmemory\b/i, "memory"],
214
+ [/\bskills?\b/i, "skill"],
215
+ [/\bsub-?agents?\b/i, "subagent"],
216
+ ];
217
+
218
+ function titleToKind(title: string): ComponentKind | undefined {
219
+ for (const [re, kind] of TITLE_TO_KIND) if (re.test(title)) return kind;
220
+ return undefined;
221
+ }
222
+
223
+ const RE_H2 = /^##\s+(.*)$/;
224
+ const RE_ID_BULLET = /^-\s+\*\*\[([^\]]+)\]\*\*\s*\(importance\s+([\d.]+)\)\s*(.*)$/;
225
+ const RE_PLAIN_BULLET = /^-\s+(.+)$/;
226
+ const RE_EVIDENCE = /^\s+-\s+evidence:\s*(.*)$/i;
227
+
228
+ /** Parse a durable markdown export into items. Tolerant of pi-reflect's edits. */
229
+ export function parseDurable(text: string): ParsedItem[] {
230
+ const out: ParsedItem[] = [];
231
+ let kind: ComponentKind | undefined;
232
+ let pending: ParsedItem | null = null;
233
+ const flush = (): void => {
234
+ if (pending && pending.content) out.push(pending);
235
+ pending = null;
236
+ };
237
+
238
+ for (const raw of text.split(/\r?\n/)) {
239
+ const line = raw.replace(/\s+$/, "");
240
+ const h2 = line.match(RE_H2);
241
+ if (h2) {
242
+ flush();
243
+ kind = titleToKind(h2[1]!.trim());
244
+ continue;
245
+ }
246
+ if (/^#/.test(line)) {
247
+ flush(); // any other header (h1, h3, …)
248
+ continue;
249
+ }
250
+ if (!kind) continue; // ignore bullets outside a known section
251
+
252
+ const ev = line.match(RE_EVIDENCE);
253
+ if (ev) {
254
+ if (pending) pending.evidence = ev[1]!.trim();
255
+ continue;
256
+ }
257
+
258
+ const idm = line.match(RE_ID_BULLET);
259
+ if (idm) {
260
+ flush();
261
+ const imp = Number(idm[2]!);
262
+ pending = {
263
+ id: idm[1]!.trim(),
264
+ kind,
265
+ importance: Number.isNaN(imp) ? 0.5 : imp,
266
+ content: idm[3]!.trim(),
267
+ evidence: "",
268
+ };
269
+ continue;
270
+ }
271
+
272
+ const pm = line.match(RE_PLAIN_BULLET);
273
+ if (pm) {
274
+ flush();
275
+ const body = pm[1]!.trim();
276
+ const idInBody = body.match(/^\*\*\[([^\]]+)\]\*\*/);
277
+ pending = {
278
+ kind,
279
+ importance: 0.5,
280
+ content: body.replace(/^\*\*\[([^\]]+)\]\*\*\s*/, "").trim(),
281
+ evidence: "",
282
+ };
283
+ if (idInBody) pending.id = idInBody[1]!.trim();
284
+ continue;
285
+ }
286
+ }
287
+ flush();
288
+ return out.filter((p) => p.content && !/^\(?no active items\)?$/i.test(p.content));
289
+ }
290
+
291
+ /**
292
+ * Parse the durable file and merge it into the live store, then persist a
293
+ * snapshot. See the file-level comment above for merge semantics.
294
+ */
295
+ export async function reconstructFromDurable(
296
+ path: string,
297
+ options: { prune?: boolean },
298
+ persist: (snapshot: HarnessState, version: number) => void,
299
+ ): Promise<DurableImportResult> {
300
+ let text: string;
301
+ try {
302
+ text = await readFile(path, "utf8");
303
+ } catch {
304
+ return { imported: 0, created: 0, updated: 0, pruned: 0, missingFile: true };
305
+ }
306
+
307
+ const parsed = parseDurable(text);
308
+ const fileIds = new Set(parsed.map((p) => p.id).filter((id): id is string => Boolean(id)));
309
+ const existingById = new Map(state.items.map((i) => [i.id, i] as const));
310
+ const preActiveIds = new Set(state.items.filter((i) => i.active).map((i) => i.id));
311
+
312
+ let created = 0;
313
+ let updated = 0;
314
+ const now = Date.now();
315
+ for (const p of parsed) {
316
+ const existing = p.id ? existingById.get(p.id) : undefined;
317
+ if (existing) {
318
+ // Durable wins; reactivate; keep createdAt.
319
+ existing.content = p.content;
320
+ existing.evidence = p.evidence;
321
+ existing.importance = clamp(p.importance);
322
+ existing.active = true;
323
+ existing.updatedAt = now;
324
+ updated += 1;
325
+ } else {
326
+ const id = p.id && /^h_/.test(p.id) ? p.id : genId();
327
+ state.items.push({
328
+ id,
329
+ kind: p.kind,
330
+ content: p.content,
331
+ evidence: p.evidence,
332
+ importance: clamp(p.importance),
333
+ active: true,
334
+ createdAt: now,
335
+ updatedAt: now,
336
+ });
337
+ created += 1;
338
+ }
339
+ }
340
+
341
+ let pruned = 0;
342
+ if (options.prune) {
343
+ const before = state.items.length;
344
+ state.items = state.items.filter((i) => {
345
+ if (!i.active) return true; // inactive items are never touched by durable I/O
346
+ if (!preActiveIds.has(i.id)) return true; // created during this import
347
+ if (fileIds.has(i.id)) return true; // present in the file
348
+ return false; // was active before, absent from the file → drop
349
+ });
350
+ pruned = before - state.items.length;
351
+ }
352
+
353
+ version += 1;
354
+ persist(state, version);
355
+ return { imported: parsed.length, created, updated, pruned, missingFile: false };
356
+ }