pi-continual-harness 0.10.0 → 0.11.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
@@ -63,6 +63,7 @@ Durable I/O (round-trip with pi-reflect):
63
63
  ```
64
64
  /harness status # counts + durable layer presence/mtime
65
65
  /harness export [path] # layered export (or full snapshot to a path)
66
+ /harness export-corpus [path] # calibration corpus JSONL for pi-reflex (local-only; dedupe pairs + lifecycle)
66
67
  /harness import [--prune] [path] # layered import (or single file from a path)
67
68
  /harness prune [--decay <days>] # drop items below the importance floor
68
69
  /harness keep <id> # nudge importance up (+0.1)
@@ -315,7 +316,7 @@ deltas directly). The propose stage is pluggable via a registry
315
316
 
316
317
  The dedupe planner's `similarity` seam (`DedupeOptions.similarity`, re-exported
317
318
  from the package entry) is how a companion package upgrades the comparison —
318
- e.g. embedding cosine similarity, or a local decision engine such as pi-jev
319
+ e.g. embedding cosine similarity, or a local decision engine such as pi-reflex
319
320
  (a local-only project for now). A comparator may return
320
321
  `{ score, abstain }` instead of a plain number: **an abstaining pair is
321
322
  treated as not duplicates** — both items are kept — so an engine with
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-continual-harness",
3
- "version": "0.10.0",
3
+ "version": "0.11.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/corpus.ts ADDED
@@ -0,0 +1,310 @@
1
+ // Calibration corpus exporter — the S1 half of the pi-reflex consumer contract
2
+ // (formerly pi-jev; CONTRACT-harness.md §4 in that project; issue #12 here).
3
+ //
4
+ // Turns the session's OWN audit trail — `harness-state` snapshots and
5
+ // `harness-refinement` entries — into two JSONL corpora for calibrating and
6
+ // evaluating a semantic decision engine (pi-reflex) against real harness
7
+ // decisions:
8
+ //
9
+ // dedupe-pairs.jsonl one record per compared item pair
10
+ // - label "dup" parsed from dedupe delete reasons (merged / near-
11
+ // duplicate); the run's ground truth
12
+ // - label "not_dup" same-key-field pairs with token overlap in
13
+ // [NOT_DUP_FLOOR, threshold) that the planner did NOT
14
+ // merge; heuristic negatives, needs_review until a human
15
+ // confirms (the planner's threshold, not a label, decided)
16
+ // lifecycle.jsonl one record per item lifecycle event, classified from
17
+ // consecutive snapshot diffs:
18
+ // created (id appears) · kept (+0.1) · dropped (−0.1) · cited (small
19
+ // positive bump, the outcome loop's default +0.03) · pruned (removed with
20
+ // last importance < IMPORTANCE_FLOOR) · deleted (removed otherwise)
21
+ //
22
+ // Pure: takes an entry iterable (e.g. ctx.sessionManager.getBranch()), returns
23
+ // records; no I/O here. The `/harness export-corpus` subcommand is thin glue.
24
+ //
25
+ // CONTRACT INVARIANT (local-only): records describe item CONTENTS. Export
26
+ // writes local files and nothing else — no network, no telemetry. The corpus
27
+ // leaves the machine only if the user pushes it.
28
+ //
29
+ // Determinism: same entries in, same records out (stable order, hashes of
30
+ // content only). Overlaps are rounded to 4 decimals for stable JSON.
31
+
32
+ import { createHash } from "node:crypto";
33
+ import { IMPORTANCE_FLOOR, REFINE_ENTRY, STATE_ENTRY } from "./store.js";
34
+ import { DEDUPE_THRESHOLD, tokenOverlap } from "./proposer.js";
35
+ import { DEFAULT_REF_BUMP } from "./config.js";
36
+ import type { ComponentKind, HarnessItem, HarnessState } from "./types.js";
37
+
38
+ // ---- record types (contract §4; `v` follows the contract's major.minor) -----
39
+
40
+ export type LifecycleEvent = "created" | "cited" | "kept" | "dropped" | "pruned" | "deleted";
41
+
42
+ export interface LifecycleRecord {
43
+ v: 1;
44
+ kind: "lifecycle";
45
+ item_kind: ComponentKind;
46
+ /** sha256 of the item's content, prefixed — contents are not carried here. */
47
+ content_hash: string;
48
+ event: LifecycleEvent;
49
+ /** Post-event importance. */
50
+ importance: number;
51
+ }
52
+
53
+ export interface DedupePairRecord {
54
+ v: 1;
55
+ kind: "dedupe_pair";
56
+ /** Keeper (dup label) / first-by-store-order (not_dup) content. */
57
+ a: string;
58
+ /** Duplicate (dup label) / second-by-store-order (not_dup) content. */
59
+ b: string;
60
+ label: "dup" | "not_dup";
61
+ similarity: number;
62
+ source: "tokenOverlap";
63
+ /** Present on "not_dup" records: the label is inferred from non-merge, not
64
+ * human-confirmed. */
65
+ needs_review?: true;
66
+ meta: { kinds: ComponentKind[]; owners: string[] };
67
+ }
68
+
69
+ export interface CorpusResult {
70
+ pairs: DedupePairRecord[];
71
+ lifecycle: LifecycleRecord[];
72
+ }
73
+
74
+ /** Options for buildCorpus. */
75
+ export interface CorpusOptions {
76
+ /** The CONFIGURED outcome-loop citation bump (harness.json
77
+ * `outcomeImportance.bump`; default 0.03 = DEFAULT_REF_BUMP). Classification
78
+ * is config-aware: keep/drop (±0.1) are checked FIRST, so a configured
79
+ * bump of exactly 0.1 collides and keep/drop win — documented behavior. */
80
+ citeBump?: number | undefined;
81
+ }
82
+
83
+ // ---- tuning knobs -----------------------------------------------------------
84
+
85
+ /** Overlap floor for "not_dup" candidates: below it a pair is uninformative. */
86
+ export const NOT_DUP_FLOOR = 0.3;
87
+ /** keep/drop bump magnitude (harness.ts handleBump). */
88
+ const KEEP_DROP_BUMP = 0.1;
89
+ /** Classification tolerance around each bump (float safety, clamped bumps). */
90
+ const BUMP_TOL = 0.02;
91
+ /** Smallest importance delta worth classifying at all. */
92
+ const EPSILON = 0.005;
93
+
94
+ // ---- helpers ----------------------------------------------------------------
95
+
96
+ /** Loose entry shape, kept decoupled from pi's internal session types.
97
+ * `applied` is a legacy COUNT; the full delta list lives in `appliedDeltas`
98
+ * (0.11.0+ audit entries — see refine.ts). */
99
+ type AnyEntry = {
100
+ type?: string;
101
+ customType?: string;
102
+ data?: {
103
+ state?: HarnessState;
104
+ proposer?: string;
105
+ applied?: unknown;
106
+ appliedDeltas?: unknown[];
107
+ };
108
+ };
109
+
110
+ /** Parse a dedupe delete reason ("merged into h_x (overlap 0.71)" or the
111
+ * delete-only "near-duplicate of h_x (overlap 0.71)"). */
112
+ export function parseDedupeDelete(
113
+ reason: string,
114
+ ): { keeperId: string; overlap: number } | undefined {
115
+ const m = /^(?:merged into|near-duplicate of) (h_[A-Za-z0-9_]+) \(overlap (0?\.\d+|1(?:\.0+)?)\)$/.exec(
116
+ reason,
117
+ );
118
+ if (!m) return undefined;
119
+ return { keeperId: m[1]!, overlap: Number(m[2]) };
120
+ }
121
+
122
+ function round4(x: number): number {
123
+ return Math.round(x * 1e4) / 1e4;
124
+ }
125
+
126
+ function hash(content: string): string {
127
+ return `sha256:${createHash("sha256").update(content).digest("hex")}`;
128
+ }
129
+
130
+ /** Durable placement, mirroring planDedupe's private key: only items in the
131
+ * same layer were ever compared by the planner. */
132
+ function durableLayer(i: HarnessItem): string {
133
+ return i.scope === "project" ? `project:${i.project ?? ""}` : "global";
134
+ }
135
+
136
+ // ---- lifecycle ---------------------------------------------------------------
137
+
138
+ /** Classify one snapshot → next-snapshot transition into lifecycle records.
139
+ * `citeBump` is the CONFIGURED outcome-loop bump (see CorpusOptions). */
140
+ function diffSnapshots(
141
+ prev: HarnessState,
142
+ next: HarnessState,
143
+ citeBump: number,
144
+ out: LifecycleRecord[],
145
+ ): void {
146
+ const prevById = new Map(prev.items.map((i) => [i.id, i]));
147
+ const nextById = new Map(next.items.map((i) => [i.id, i]));
148
+
149
+ for (const item of next.items) {
150
+ const before = prevById.get(item.id);
151
+ if (!before) {
152
+ out.push(lifecycle(item, "created", item.importance));
153
+ continue;
154
+ }
155
+ const d = item.importance - before.importance;
156
+ if (Math.abs(d) < EPSILON) continue;
157
+ // keep/drop FIRST: a configured citation bump of exactly ±0.1 collides and
158
+ // keep/drop win (documented in CorpusOptions).
159
+ if (Math.abs(d - KEEP_DROP_BUMP) <= BUMP_TOL) out.push(lifecycle(item, "kept", item.importance));
160
+ else if (Math.abs(d + KEEP_DROP_BUMP) <= BUMP_TOL)
161
+ out.push(lifecycle(item, "dropped", item.importance));
162
+ else if (citeBump > 0 && Math.abs(d - citeBump) <= BUMP_TOL)
163
+ out.push(lifecycle(item, "cited", item.importance));
164
+ // else: explicit harness_mutate importance set — not a lifecycle signal.
165
+ }
166
+ for (const item of prev.items) {
167
+ if (nextById.has(item.id)) continue;
168
+ const event = item.importance < IMPORTANCE_FLOOR ? "pruned" : "deleted";
169
+ out.push(lifecycle(item, event, item.importance));
170
+ }
171
+ }
172
+
173
+ function lifecycle(item: HarnessItem, event: LifecycleEvent, importance: number): LifecycleRecord {
174
+ return { v: 1, kind: "lifecycle", item_kind: item.kind, content_hash: hash(item.content), event, importance };
175
+ }
176
+
177
+ // ---- dedupe pairs --------------------------------------------------------------
178
+
179
+ /** Emit pair records for one dedupe refinement run.
180
+ *
181
+ * `current` is the store state AT AUDIT TIME — a run that applied nothing
182
+ * writes NO harness-state snapshot, so "last snapshot" is correct whether or
183
+ * not this run wrote one (no-merge runs: current IS the pre-run state).
184
+ * `pre` (previous snapshot, only meaningful when the run applied deletes)
185
+ * supplies DELETED items' contents; keepers are read from `current`.
186
+ *
187
+ * `deltas` comes from the audit entry's `appliedDeltas` (0.11.0+). Legacy
188
+ * entries carry only a numeric count — pass [] there: dup reconstruction is
189
+ * impossible without delete reasons, but not_dup candidates still emit. */
190
+ function collectDedupePairs(
191
+ current: HarnessState,
192
+ pre: HarnessState,
193
+ deltas: unknown[],
194
+ seen: Set<string>,
195
+ out: DedupePairRecord[],
196
+ ): void {
197
+ const currentById = new Map(current.items.map((i) => [i.id, i]));
198
+ const preById = new Map(pre.items.map((i) => [i.id, i]));
199
+
200
+ const deletes = (deltas as Array<{ op?: string; id?: string; reason?: string }>).filter(
201
+ (d) => d && d.op === "delete" && d.id && typeof d.reason === "string",
202
+ );
203
+ const deletedIds = new Set(deletes.map((d) => d.id!));
204
+
205
+ for (const d of deletes) {
206
+ const parsed = parseDedupeDelete(d.reason!);
207
+ if (!parsed) continue;
208
+ const keeper = currentById.get(parsed.keeperId) ?? preById.get(parsed.keeperId);
209
+ const dup = preById.get(d.id!);
210
+ if (!keeper || !dup) continue;
211
+ pushPair(
212
+ out,
213
+ seen,
214
+ keeper.content,
215
+ dup.content,
216
+ "dup",
217
+ round4(parsed.overlap),
218
+ { kinds: [keeper.kind], owners: [keeper.ownerModel, dup.ownerModel] },
219
+ );
220
+ }
221
+
222
+ // not_dup candidates: everything the planner COULD have compared (same key
223
+ // fields, active, not deleted this run) but did not merge, with an
224
+ // informative overlap. Always relative to the CURRENT state.
225
+ const candidates = current.items.filter((i) => i.active && !deletedIds.has(i.id));
226
+ for (let i = 0; i < candidates.length; i++) {
227
+ for (let j = i + 1; j < candidates.length; j++) {
228
+ const a = candidates[i]!;
229
+ const b = candidates[j]!;
230
+ if (a.kind !== b.kind) continue;
231
+ if (a.ownerModel !== b.ownerModel) continue;
232
+ if (durableLayer(a) !== durableLayer(b)) continue;
233
+ const sim = tokenOverlap(a.content, b.content);
234
+ if (sim < NOT_DUP_FLOOR || sim >= DEDUPE_THRESHOLD) continue;
235
+ pushPair(
236
+ out,
237
+ seen,
238
+ a.content,
239
+ b.content,
240
+ "not_dup",
241
+ round4(sim),
242
+ { kinds: [a.kind], owners: [a.ownerModel] },
243
+ true,
244
+ );
245
+ }
246
+ }
247
+ }
248
+
249
+ function pushPair(
250
+ out: DedupePairRecord[],
251
+ seen: Set<string>,
252
+ a: string,
253
+ b: string,
254
+ label: DedupePairRecord["label"],
255
+ similarity: number,
256
+ meta: DedupePairRecord["meta"],
257
+ needsReview = false,
258
+ ): void {
259
+ const key = `${label}\x00${a}\x00${b}`;
260
+ if (seen.has(key)) return; // one record per unique pair across runs
261
+ seen.add(key);
262
+ const rec: DedupePairRecord = { v: 1, kind: "dedupe_pair", a, b, label, similarity, source: "tokenOverlap", meta };
263
+ if (needsReview) rec.needs_review = true;
264
+ out.push(rec);
265
+ }
266
+
267
+ // ---- entry-point ---------------------------------------------------------------
268
+
269
+ /**
270
+ * Build the calibration corpora from an ordered session-branch entry iterable
271
+ * (e.g. `ctx.sessionManager.getBranch()`). Pure and deterministic.
272
+ *
273
+ * Walk notes:
274
+ * - A dedupe run that APPLIED deltas appends its post-run `harness-state`
275
+ * snapshot immediately before its `harness-refinement` audit entry, so at
276
+ * an audit entry the last snapshot is the CURRENT state either way.
277
+ * - A no-merge run writes NO snapshot: the last snapshot is an older state
278
+ * that is still the current store (candidates emit from it).
279
+ * - Legacy audit entries (pre-0.11.0) carry `applied` as a COUNT with no
280
+ * delta details: dup reconstruction is skipped, candidates still emit.
281
+ */
282
+ export function buildCorpus(entries: Iterable<unknown>, opts: CorpusOptions = {}): CorpusResult {
283
+ const citeBump = opts.citeBump ?? DEFAULT_REF_BUMP;
284
+ const pairs: DedupePairRecord[] = [];
285
+ const lifecycle: LifecycleRecord[] = [];
286
+ const seen = new Set<string>();
287
+ const snapshots: HarnessState[] = [];
288
+ for (const raw of entries) {
289
+ const entry = raw as AnyEntry;
290
+ if (entry.type === "custom" && entry.customType === STATE_ENTRY && entry.data?.state) {
291
+ const next = entry.data.state;
292
+ const prev = snapshots.at(-1);
293
+ if (prev) diffSnapshots(prev, next, citeBump, lifecycle);
294
+ snapshots.push(next);
295
+ continue;
296
+ }
297
+ if (
298
+ entry.type === "custom" &&
299
+ entry.customType === REFINE_ENTRY &&
300
+ entry.data?.proposer === "dedupe"
301
+ ) {
302
+ const current = snapshots.at(-1);
303
+ if (!current) continue; // no state seen yet — nothing to compare
304
+ const deltas = Array.isArray(entry.data.appliedDeltas) ? entry.data.appliedDeltas : [];
305
+ const pre = snapshots.at(-2) ?? { items: [] };
306
+ collectDedupePairs(current, pre, deltas, seen, pairs);
307
+ }
308
+ }
309
+ return { pairs, lifecycle };
310
+ }
package/src/harness.ts CHANGED
@@ -25,6 +25,8 @@
25
25
 
26
26
  import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
27
27
  import { stat } from "node:fs/promises";
28
+ import { mkdir, writeFile } from "node:fs/promises";
29
+ import { join } from "node:path";
28
30
  import {
29
31
  applyDeltas,
30
32
  bumpImportance,
@@ -41,11 +43,13 @@ import {
41
43
  import {
42
44
  defaultScopeForPath,
43
45
  layerFilesFor,
46
+ loadConfig,
44
47
  projectDurablePath,
45
48
  projectSlug,
46
49
  } from "./config.js";
47
50
  import { KIND_LABEL } from "./types.js";
48
51
  import type { ComponentKind, Delta, HarnessItem } from "./types.js";
52
+ import { buildCorpus } from "./corpus.js";
49
53
 
50
54
  /** One row of the /harness subcommand menu (label = name, value = name). */
51
55
  interface CompletionEntry {
@@ -56,6 +60,7 @@ interface CompletionEntry {
56
60
  const SUBCOMMANDS: CompletionEntry[] = [
57
61
  { name: "import", description: "Import durable state, layered (--prune to prune stale items)" },
58
62
  { name: "export", description: "Export active items to durable layers (or one file with a path)" },
63
+ { name: "export-corpus", description: "Export calibration corpus JSONL for pi-reflex (dedupe pairs + lifecycle)" },
59
64
  { name: "status", description: "Show harness status (active/total, per-kind counts, durable layers)" },
60
65
  { name: "prune", description: "Decay & prune inactive items (--decay <days>)" },
61
66
  { name: "keep", description: "Bump item importance (+0.1)" },
@@ -218,6 +223,9 @@ export function registerHarness(pi: ExtensionAPI): void {
218
223
  case "export":
219
224
  await handleExport(ctx, rest);
220
225
  return;
226
+ case "export-corpus":
227
+ await handleExportCorpus(ctx, rest);
228
+ return;
221
229
  case "prune":
222
230
  await handlePrune(pi, ctx, rest);
223
231
  return;
@@ -320,6 +328,38 @@ async function handleExport(ctx: ExtensionCommandContext, rest: string[]): Promi
320
328
  ctx.ui.setStatus("harness", undefined);
321
329
  }
322
330
 
331
+ function toJsonl(rows: unknown[]): string {
332
+ return rows.length > 0 ? `${rows.map((r) => JSON.stringify(r)).join("\n")}\n` : "";
333
+ }
334
+
335
+ /** `/harness export-corpus [path]` — write the calibration corpora (contract
336
+ * §4) from THIS session branch's audit trail. Default dir:
337
+ * ./harness-corpus/<yyyy-mm-dd>/. Local file output only — contract invariant:
338
+ * nothing leaves the machine. */
339
+ async function handleExportCorpus(ctx: ExtensionCommandContext, rest: string[]): Promise<void> {
340
+ const explicit = explicitPath(rest);
341
+ const dir = explicit ?? join("harness-corpus", new Date().toISOString().slice(0, 10));
342
+ ctx.ui.setStatus("harness", "Exporting calibration corpus…");
343
+ try {
344
+ // Config-aware citation classification: the outcome loop's bump is
345
+ // configurable (default 0.03), so the classifier must not hardcode it.
346
+ const { outcomeImportance } = await loadConfig();
347
+ const corpus = buildCorpus(ctx.sessionManager.getBranch() as Iterable<unknown>, {
348
+ citeBump: outcomeImportance?.bump,
349
+ });
350
+ await mkdir(dir, { recursive: true });
351
+ await writeFile(join(dir, "dedupe-pairs.jsonl"), toJsonl(corpus.pairs));
352
+ await writeFile(join(dir, "lifecycle.jsonl"), toJsonl(corpus.lifecycle));
353
+ ctx.ui.notify(
354
+ `Calibration corpus → ${dir}: ${corpus.pairs.length} dedupe pair(s), ${corpus.lifecycle.length} lifecycle event(s). Local files only — nothing leaves the machine.`,
355
+ "info",
356
+ );
357
+ } catch (err) {
358
+ ctx.ui.notify(`/harness export-corpus failed: ${(err as Error).message}`, "error");
359
+ }
360
+ ctx.ui.setStatus("harness", undefined);
361
+ }
362
+
323
363
  async function handlePrune(
324
364
  pi: ExtensionAPI,
325
365
  ctx: ExtensionCommandContext,
package/src/index.ts CHANGED
@@ -92,6 +92,19 @@ export {
92
92
  type SimilarityResult,
93
93
  } from "./proposer.js";
94
94
 
95
+ // Calibration corpus exporter (pi-reflex contract §4, formerly pi-jev). Re-exported so companion
96
+ // packages can build corpora from exported session branches without reaching
97
+ // into internal paths.
98
+ export {
99
+ buildCorpus,
100
+ NOT_DUP_FLOOR,
101
+ parseDedupeDelete,
102
+ type CorpusResult,
103
+ type DedupePairRecord,
104
+ type LifecycleEvent,
105
+ type LifecycleRecord,
106
+ } from "./corpus.js";
107
+
95
108
  // Injection selection policy (on by default). Re-exported so companion packages
96
109
  // and tests can reuse the pure selection/normalization without reaching into
97
110
  // internal paths.
package/src/refine.ts CHANGED
@@ -22,6 +22,7 @@ import type { Context, Model, TextContent } from "@earendil-works/pi-ai";
22
22
  import { applyDeltas, DEFAULT_DURABLE_PATH, exportDurableLayers, modelKey, PROJECT_DURABLE_DIR, REFINE_ENTRY, snapshotState } from "./store.js";
23
23
  import { getProposer, DEFAULT_DEDUPE } from "./proposer.js";
24
24
  import type { CompleteOptions, CompleteResult } from "./proposer.js";
25
+ import type { AppliedDelta } from "./types.js";
25
26
  import { loadConfig, projectSlug } from "./config.js";
26
27
 
27
28
  const DEFAULT_EVIDENCE_BYTES = 16000;
@@ -125,6 +126,7 @@ export async function runRefine(
125
126
  const ownerKey = modelKey(ctx.model);
126
127
 
127
128
  let applied = 0;
129
+ let appliedDeltas: AppliedDelta[] = [];
128
130
  let applyError: string | undefined;
129
131
  if (proposedDeltas.length > 0) {
130
132
  // Direct-apply path (rule-based / model proposers): persist each batch the
@@ -134,7 +136,7 @@ export async function runRefine(
134
136
  // race during the await above. It rolls back in-memory state before throwing,
135
137
  // so we surface the failure as an audited no-op rather than crashing /refine.
136
138
  try {
137
- const appliedDeltas = applyDeltas(
139
+ appliedDeltas = applyDeltas(
138
140
  proposedDeltas.map((d) => {
139
141
  const delta = d.delta;
140
142
  return ownerKey !== undefined && delta.op === "create"
@@ -164,6 +166,10 @@ export async function runRefine(
164
166
  source,
165
167
  proposer: proposer.name,
166
168
  applied,
169
+ // Full applied-delta list (incl. delete reasons) — richer than the count:
170
+ // the calibration-corpus exporter (corpus.ts) reconstructs dedupe pairs
171
+ // from it. Legacy readers only consuming `applied` are unaffected.
172
+ appliedDeltas,
167
173
  rationales: proposedDeltas.map((d) => d.rationale),
168
174
  // Hidden model spend made visible: dedicated-model proposers report what the
169
175
  // call cost (model, tokens, latency, ok/error). Absent for rule-based/steering.