pi-continual-harness 0.9.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
@@ -53,8 +53,9 @@ Or drop `src/index.ts` into `~/.pi/agent/extensions/`.
53
53
  ```
54
54
  /refine # review last 25 turns, propose deltas
55
55
  /refine 50 # review last 50 turns
56
- /refine 25 --commit # also export durable state to ~/.pi/agent/harness-state.md
56
+ /refine 25 --commit # also export durable state (global + project layers)
57
57
  /refine --proposer dedupe # run the rule-based dedupe proposer instead of steering
58
+ /refine --proposer dedupe --threshold 0.75 # dedupe with a one-shot threshold
58
59
  ```
59
60
 
60
61
  Durable I/O (round-trip with pi-reflect):
@@ -62,6 +63,7 @@ Durable I/O (round-trip with pi-reflect):
62
63
  ```
63
64
  /harness status # counts + durable layer presence/mtime
64
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)
65
67
  /harness import [--prune] [path] # layered import (or single file from a path)
66
68
  /harness prune [--decay <days>] # drop items below the importance floor
67
69
  /harness keep <id> # nudge importance up (+0.1)
@@ -106,6 +108,9 @@ The model-facing tools:
106
108
  - `harness_mutate { deltas: [...] }` — apply a batch of `create` / `update` /
107
109
  `delete` deltas. Every `create` requires `evidence`. New items are stamped
108
110
  automatically with the active model (see [Model binding](#model-binding-per-model-isolation)).
111
+ `create`/`update` deltas also accept `scope: "global" | "project"` (the
112
+ project slug is stamped server-side from the session cwd — used by
113
+ `/harness split`; normally managed via `/harness move`).
109
114
 
110
115
  Active items are injected into the system prompt each turn as a structured
111
116
  block, appended to (never replacing) the base prompt.
@@ -137,6 +142,7 @@ Optional config at `~/.pi/agent/harness.json` (missing or malformed → defaults
137
142
  {
138
143
  "autoImport": false,
139
144
  "proposer": "steering",
145
+ "dedupe": { "threshold": 0.6, "merge": true },
140
146
  "injection": { "enabled": true, "maxTokens": 1500, "maxPerKind": 10, "charsPerToken": 4 },
141
147
  "remindRefine": { "enabled": false, "everyTurns": 50 },
142
148
  "autoRefine": { "enabled": false, "everyTurns": 100, "commit": false },
@@ -160,7 +166,19 @@ Optional config at `~/.pi/agent/harness.json` (missing or malformed → defaults
160
166
  restore the legacy "inject all items, in store order" behaviour.
161
167
  - **`proposer`** — which delta proposer `/refine` and auto-refine use. Defaults
162
168
  to `steering` (the agent reasons via a steering message). `dedupe` applies a
163
- rule-based dedupe directly. See [Proposers](#proposers).
169
+ rule-based merge directly. See [Proposers](#proposers).
170
+ - **`dedupe`** — the rule-based dedupe proposer's policy. `threshold` (default
171
+ `0.6`, valid `(0,1]`) is the token-overlap level at which two items sharing
172
+ the key fields (kind, owner model, durable layer) count as duplicates;
173
+ `merge` (default `true`) **merges** each duplicate into its keeper — the
174
+ keeper keeps its content verbatim, its `evidence` becomes the line-wise union
175
+ of both, applied as one audited `update` + `delete` pair — while `false`
176
+ restores the pre-0.10 delete-only behavior. `/refine --proposer dedupe
177
+ --threshold 0.75` overrides the threshold for one run. A fourth key,
178
+ `similarity`, is API-only (functions cannot come from `harness.json`):
179
+ companion packages inject a richer comparator whose return may **abstain**
180
+ per pair (`{ score, abstain }` — see [`SimilarityResult`](#proposers)); an
181
+ abstaining pair is always kept, never merged or deleted.
164
182
  - **`remindRefine`** — opt-in `turn_end` nudge. `{ "enabled": true,
165
183
  "everyTurns": 50 }` notifies you to run `/refine` on a cadence. It is
166
184
  informational only — it never mutates state.
@@ -235,9 +253,9 @@ How the binding is set and respected:
235
253
  `model: provider/id`; `/harness import` restores it. An item whose tag
236
254
  pi-reflect stripped becomes an orphan and is adopted by the active model.
237
255
 
238
- Manual commands (`export`, `import`, `keep`, `drop`, `prune`, `push-mem`,
239
- `status`) operate on the **whole store** by design — they are explicit human
240
- actions with full control. Isolation is enforced only where pollution would
256
+ Manual commands (`export`, `import`, `move`, `split`, `keep`, `drop`, `prune`,
257
+ `push-mem`, `status`) operate on the **whole store** by design — they are
258
+ explicit human actions with full control. Isolation is enforced only where pollution would
241
259
  leak automatically: injection, listing, create-stamping, and the outcome loop.
242
260
  In particular, `/harness push-mem` pushes *every* model's active items into
243
261
  pi-mem by default (which can yield near-duplicate memories across models); pass
@@ -294,7 +312,16 @@ deltas directly). The propose stage is pluggable via a registry
294
312
  | Name | What it does |
295
313
  |---|---|
296
314
  | `steering` (default) | Delegates reasoning to the agent via a steering message — reuses the agent loop, model-agnostic, fully visible. |
297
- | `dedupe` | Rule-based: drops near-duplicate active items (token-overlap ≥ 0.6), keeping the higher-importance one. No model call. |
315
+ | `dedupe` | Rule-based: **merges** near-duplicate active items (token-overlap ≥ the configured threshold, same kind / owner model / durable layer) into the higher-importance keeper — one evidence-union update per keeper, then deletes the duplicates. `"dedupe": { "merge": false }` restores delete-only. No model call. |
316
+
317
+ The dedupe planner's `similarity` seam (`DedupeOptions.similarity`, re-exported
318
+ from the package entry) is how a companion package upgrades the comparison —
319
+ e.g. embedding cosine similarity, or a local decision engine such as pi-reflex
320
+ (a local-only project for now). A comparator may return
321
+ `{ score, abstain }` instead of a plain number: **an abstaining pair is
322
+ treated as not duplicates** — both items are kept — so an engine with
323
+ conformal uncertainty guarantees can safely decline to merge. Plain numeric
324
+ returns keep working (the default remains token Jaccard).
298
325
 
299
326
  Select a proposer per run with `/refine --proposer <name>`, or set the default
300
327
  for auto-refine via `proposer` in the [config](#configuration). Both paths are
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-continual-harness",
3
- "version": "0.9.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/config.ts CHANGED
@@ -20,12 +20,22 @@ import { homedir } from "node:os";
20
20
  import { basename, dirname, join } from "node:path";
21
21
  import { DEFAULT_DURABLE_PATH, PROJECT_DURABLE_DIR, type LayerFile, type ScopeInfo } from "./store.js";
22
22
  import { DEFAULT_INJECTION, normalizeInjection, type NormalizedInjection } from "./select.js";
23
+ import { DEDUPE_THRESHOLD } from "./proposer.js";
23
24
 
24
25
  export const DEFAULT_EVERY_TURNS = 50;
25
26
  export const DEFAULT_AUTO_EVERY_TURNS = 100;
26
27
  /** Default per-reference importance bump for the opt-in outcome loop. */
27
28
  export const DEFAULT_REF_BUMP = 0.03;
28
29
 
30
+ /** Resolved dedupe policy (merge-capable, 0.10.0). Populated by loadConfig. */
31
+ export interface NormalizedDedupe {
32
+ /** Token-overlap threshold in (0,1]; pairs at/above it merge. Default 0.6. */
33
+ threshold: number;
34
+ /** Merge duplicates into the keeper (evidence union) instead of delete-only.
35
+ * Default true; `false` restores the pre-0.10 delete-only behavior. */
36
+ merge: boolean;
37
+ }
38
+
29
39
  export interface HarnessConfig {
30
40
  /** DEPRECATED (0.9.0): items carry their own scope now (see /harness move);
31
41
  * durable I/O is always layered. Parsed for compatibility, ignored. */
@@ -53,6 +63,9 @@ export interface HarnessConfig {
53
63
  /** Injection selection policy (on by default). Resolved by loadConfig, so the
54
64
  * value here is always fully-populated. See src/select.ts. */
55
65
  injection?: NormalizedInjection;
66
+ /** Dedupe proposer policy (merge-capable). Resolved by loadConfig, so the
67
+ * value here is always fully-populated. See src/proposer.ts. */
68
+ dedupe?: NormalizedDedupe;
56
69
  }
57
70
 
58
71
  export const DEFAULT_CONFIG: HarnessConfig = {
@@ -62,6 +75,10 @@ export const DEFAULT_CONFIG: HarnessConfig = {
62
75
  autoRefine: { enabled: false, everyTurns: DEFAULT_AUTO_EVERY_TURNS, commit: false },
63
76
  proposer: "steering",
64
77
  outcomeImportance: { enabled: false, bump: DEFAULT_REF_BUMP },
78
+ // Dedupe (merge-capable): merge ON at the historical 0.6 threshold — a
79
+ // near-duplicate merges into its keeper (evidence unioned) instead of being
80
+ // deleted cold. `merge: false` restores delete-only.
81
+ dedupe: { threshold: DEDUPE_THRESHOLD, merge: true },
65
82
  // ON by default: importance-ordered, maxPerKind 10, maxTokens 1500. A no-op
66
83
  // for small stores; protective as the harness accumulates. Opt out with
67
84
  // `injection.enabled: false`. See src/select.ts.
@@ -95,6 +112,10 @@ function mergeConfig(over: Partial<HarnessConfig>): HarnessConfig {
95
112
  // malformed `injection` object degrades to the shipped defaults rather than
96
113
  // corrupting the block sizing arithmetic.
97
114
  injection: normalizeInjection(over.injection as Partial<{ enabled: boolean; maxTokens: number; maxPerKind: number; charsPerToken: number }> | undefined),
115
+ dedupe: {
116
+ threshold: coerceThreshold(over.dedupe?.threshold),
117
+ merge: over.dedupe?.merge !== false,
118
+ },
98
119
  };
99
120
  }
100
121
 
@@ -106,6 +127,14 @@ function coerceBump(raw: unknown): number {
106
127
  return typeof raw === "number" && Number.isFinite(raw) ? raw : DEFAULT_REF_BUMP;
107
128
  }
108
129
 
130
+ /** Coerce a user-provided dedupe threshold to a finite number in (0,1], else
131
+ * the default. The threshold is a COMPARISON operand (similarity >= threshold);
132
+ * out-of-range or non-numeric values must degrade to the default rather than
133
+ * silently matching everything (0/-1) or nothing (>1). */
134
+ function coerceThreshold(raw: unknown): number {
135
+ return typeof raw === "number" && Number.isFinite(raw) && raw > 0 && raw <= 1 ? raw : DEDUPE_THRESHOLD;
136
+ }
137
+
109
138
  /** Load the config, merged over defaults. Tolerant: missing/malformed → defaults.
110
139
  * Cached for the process lifetime (config is read once per session). */
111
140
  export async function loadConfig(path: string = CONFIG_PATH): Promise<HarnessConfig> {
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
@@ -74,17 +74,37 @@ export {
74
74
  // (see src/proposer.ts) and it becomes selectable via /refine --proposer <name>
75
75
  // or the `proposer` config key.
76
76
  export {
77
+ DEFAULT_DEDUPE,
78
+ DEDUPE_THRESHOLD,
79
+ EVIDENCE_MERGE_CAP,
77
80
  listProposers,
81
+ planDedupe,
78
82
  registerProposer,
83
+ unionEvidence,
79
84
  type CompleteOptions,
80
85
  type CompleteResult,
86
+ type DedupeOptions,
81
87
  type DeltaProposer,
82
88
  type ModelCallTelemetry,
83
89
  type ProposeInput,
84
90
  type ProposedDelta,
85
91
  type ProposeResult,
92
+ type SimilarityResult,
86
93
  } from "./proposer.js";
87
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
+
88
108
  // Injection selection policy (on by default). Re-exported so companion packages
89
109
  // and tests can reuse the pure selection/normalization without reaching into
90
110
  // internal paths.
package/src/proposer.ts CHANGED
@@ -10,9 +10,10 @@
10
10
  // original behavior exactly (delegates to the agent via a steering message,
11
11
  // reusing the agent loop — model-agnostic, fully visible). Alternate proposers
12
12
  // produce deltas directly:
13
- // - `dedupeProposer` (rule-based): drops near-duplicate active items by token
14
- // overlap, keeping the higher-importance one. Pure function of state —
15
- // deterministic, no model call, fully testable.
13
+ // - `dedupeProposer` (rule-based): merges near-duplicate active items by
14
+ // token overlap (threshold configurable), unioning their evidence into the
15
+ // higher-importance keeper; `merge: false` restores delete-only. Pure
16
+ // function of state — deterministic, no model call, fully testable.
16
17
  //
17
18
  // The interface supports a dedicated-model proposer — one that makes its own
18
19
  // hidden LLM call to produce deltas directly, instead of delegating to the
@@ -27,6 +28,7 @@
27
28
  // named proposer, then select it via /refine --proposer <name> or config.
28
29
 
29
30
  import type { Delta, HarnessItem, HarnessState } from "./types.js";
31
+ import type { HarnessConfig } from "./config.js";
30
32
 
31
33
  /** Options for the one-shot model completion injected into ProposeInput. */
32
34
  export interface CompleteOptions {
@@ -69,6 +71,11 @@ export interface ProposeInput {
69
71
  state: HarnessState;
70
72
  /** Lookback window in turns. */
71
73
  lookback: number;
74
+ /** Loaded harness config (harness.json), passed by runRefine so proposers
75
+ * can read tuned knobs (e.g. the dedupe threshold / merge policy) without
76
+ * file I/O. Optional — direct/legacy callers omit it and proposers fall
77
+ * back to their built-in defaults. */
78
+ config?: HarnessConfig;
72
79
  /** One-shot model completion, injected by runRefine when a model is available.
73
80
  * Dedicated-model proposers call this to make a hidden completion; rule-based
74
81
  * and steering proposers ignore it. Undefined when no model is resolvable, so
@@ -131,10 +138,44 @@ export const steeringProposer: DeltaProposer = {
131
138
  },
132
139
  };
133
140
 
134
- // ---- rule-based alternate: dedupe -----------------------------------------
141
+ // ---- rule-based alternate: dedupe (merge-capable) -------------------------
135
142
 
136
143
  export const DEDUPE_THRESHOLD = 0.6;
137
144
 
145
+ /** Enriched similarity return: a score plus an explicit per-pair abstain.
146
+ * The shape pinned by the pi-jev consumer contract (a conformal decision
147
+ * engine abstains when its prediction set is not a singleton). */
148
+ export interface SimilarityResult {
149
+ /** Similarity in [0,1], compared against DedupeOptions.threshold. */
150
+ score: number;
151
+ /** True = the engine abstains on this pair (uncertain). An abstaining
152
+ * pair is conservatively treated as NOT duplicates: both items are kept. */
153
+ abstain?: boolean;
154
+ }
155
+
156
+ /** Options for the dedupe planner. */
157
+ export interface DedupeOptions {
158
+ /** Token-overlap threshold in (0,1]: pairs at/above it are duplicates.
159
+ * 1 means "identical token sets only". */
160
+ threshold: number;
161
+ /** Merge duplicates into their keeper (one evidence-union update + deletes)
162
+ * instead of delete-only. */
163
+ merge: boolean;
164
+ /** Similarity in [0,1]; defaults to tokenOverlap. The seam for the semantic
165
+ * upgrade path (see docs/PLAN-dedupe-merge.md, Research grounding): a
166
+ * companion package injects cosine similarity over embeddings without
167
+ * forking planDedupe. May return a SimilarityResult to ABSTAIN per pair
168
+ * (e.g. a conformal engine below confidence): an abstaining pair is never
169
+ * treated as duplicates, whatever the score. */
170
+ similarity?: (a: string, b: string) => number | SimilarityResult;
171
+ }
172
+
173
+ /** Shipped defaults: merge ON at the historical 0.6 threshold. */
174
+ export const DEFAULT_DEDUPE: DedupeOptions = { threshold: DEDUPE_THRESHOLD, merge: true };
175
+
176
+ /** Cap (chars) on a merged item's evidence so durable layers stay lean. */
177
+ export const EVIDENCE_MERGE_CAP = 2000;
178
+
138
179
  /** Tokenize for overlap comparison: lowercase alnum tokens. */
139
180
  export function tokenize(text: string): string[] {
140
181
  return text.toLowerCase().replace(/[^a-z0-9\s]/g, " ").split(/\s+/).filter(Boolean);
@@ -154,50 +195,123 @@ function truncate(s: string, n = 48): string {
154
195
  return s.length > n ? `${s.slice(0, n - 1)}…` : s;
155
196
  }
156
197
 
198
+ /** Durable placement of an item: "global" or "project:<slug>". Only items in
199
+ * the SAME layer merge — a merge must never silently move an item between
200
+ * durable files or projects. */
201
+ function durableLayer(i: HarnessItem): string {
202
+ return i.scope === "project" ? `project:${i.project ?? ""}` : "global";
203
+ }
204
+
205
+ /** Normalize a similarity return: plain number or { score, abstain }. */
206
+ function asSimilarity(r: number | SimilarityResult): SimilarityResult {
207
+ return typeof r === "number" ? { score: r } : r;
208
+ }
209
+
210
+ /** Union evidence strings line-wise: trim, drop empty and exact-duplicate
211
+ * lines (first occurrence wins, keeper's lines first), cap the total. */
212
+ export function unionEvidence(sources: string[]): string {
213
+ const seen = new Set<string>();
214
+ const lines: string[] = [];
215
+ for (const src of sources) {
216
+ for (const raw of src.split("\n")) {
217
+ const line = raw.trim();
218
+ if (!line || seen.has(line)) continue;
219
+ seen.add(line);
220
+ lines.push(line);
221
+ }
222
+ }
223
+ const joined = lines.join("\n");
224
+ return joined.length > EVIDENCE_MERGE_CAP
225
+ ? `${joined.slice(0, EVIDENCE_MERGE_CAP)}\n[…merged evidence truncated…]`
226
+ : joined;
227
+ }
228
+
157
229
  /**
158
- * Rule-based proposer: drops near-duplicate ACTIVE items (same kind, Jaccard
159
- * token overlap >= DEDUPE_THRESHOLD), keeping the higher-importance one.
230
+ * Pure dedupe planner: merges (or, with merge:false, drops) near-duplicate
231
+ * ACTIVE items. Two items are duplicates iff they share the key fields —
232
+ * kind, ownerModel, durable layer (scope+project) — and their content
233
+ * similarity is >= opts.threshold. A similarity engine MAY abstain per pair
234
+ * (see SimilarityResult): an abstaining pair is treated as NOT duplicates.
235
+ *
236
+ * Merge semantics (ACE-style deterministic merge, never a prose rewrite):
237
+ * the higher-importance item is the KEEPER and keeps its content verbatim;
238
+ * each keeper that absorbed duplicates gets ONE update whose evidence is the
239
+ * line-wise union of its own and every absorbed duplicate's evidence (one
240
+ * final union — applyOne replaces evidence wholesale, so per-duplicate
241
+ * updates would clobber each other); each absorbed duplicate is deleted with
242
+ * a reason naming its keeper. Updates are emitted before deletes.
160
243
  *
161
244
  * Greedy and contradiction-free: items are considered in importance-descending
162
- * order; the first item is always a keeper, and each later item is compared
163
- * only against keepers, so a keeper is never subsequently dropped.
245
+ * order (ties keep store order); the first item is always a keeper, and each
246
+ * later item is compared only against keepers, so a keeper is never
247
+ * subsequently dropped. A duplicate matching several keepers joins the
248
+ * best-overlap one.
164
249
  */
165
- export const dedupeProposer: DeltaProposer = {
166
- name: "dedupe",
167
- async propose({ state }): Promise<ProposeResult> {
168
- const ordered = state.items
169
- .filter((i) => i.active)
170
- .sort((a, b) => b.importance - a.importance);
171
- const keepers: HarnessItem[] = [];
172
- const proposals: ProposedDelta[] = [];
173
- for (const cand of ordered) {
174
- let dup: HarnessItem | undefined;
175
- let best = 0;
176
- for (const k of keepers) {
177
- if (k.kind !== cand.kind) continue;
178
- // Per-model isolation: each model keeps its own copy, so two near-
179
- // identical items bound to different models are NOT duplicates.
180
- if (k.ownerModel !== cand.ownerModel) continue;
181
- const sim = tokenOverlap(k.content, cand.content);
182
- if (sim >= DEDUPE_THRESHOLD && sim > best) {
183
- best = sim;
184
- dup = k;
185
- }
250
+ export function planDedupe(state: HarnessState, opts: DedupeOptions = DEFAULT_DEDUPE): ProposedDelta[] {
251
+ const similarity = opts.similarity ?? tokenOverlap;
252
+ const ordered = state.items
253
+ .filter((i) => i.active)
254
+ .sort((a, b) => b.importance - a.importance);
255
+ const keepers: Array<{ item: HarnessItem; absorbed: Array<{ dup: HarnessItem; overlap: number }> }> = [];
256
+ for (const cand of ordered) {
257
+ let target: (typeof keepers)[number] | undefined;
258
+ let best = 0;
259
+ for (const k of keepers) {
260
+ if (k.item.kind !== cand.kind) continue;
261
+ // Per-model isolation: each model keeps its own copy, so two near-
262
+ // identical items bound to different models are NOT duplicates.
263
+ if (k.item.ownerModel !== cand.ownerModel) continue;
264
+ // Same durable layer only (see durableLayer).
265
+ if (durableLayer(k.item) !== durableLayer(cand)) continue;
266
+ const { score: sim, abstain } = asSimilarity(similarity(k.item.content, cand.content));
267
+ // Abstain = keep both: an uncertain engine must never cause a merge.
268
+ if (!abstain && sim >= opts.threshold && sim > best) {
269
+ best = sim;
270
+ target = k;
186
271
  }
187
- if (dup) {
188
- proposals.push({
189
- delta: {
190
- op: "delete",
191
- id: cand.id,
192
- reason: `near-duplicate of ${dup.id} (overlap ${best.toFixed(2)})`,
193
- },
194
- rationale: `dedupe: "${truncate(cand.content)}" ≈ keeper "${truncate(dup.content)}" (Jaccard ${best.toFixed(2)}); kept higher-importance ${dup.id}.`,
272
+ }
273
+ if (target) target.absorbed.push({ dup: cand, overlap: best });
274
+ else keepers.push({ item: cand, absorbed: [] });
275
+ }
276
+
277
+ const updates: ProposedDelta[] = [];
278
+ const deletes: ProposedDelta[] = [];
279
+ for (const k of keepers) {
280
+ if (k.absorbed.length === 0) continue;
281
+ if (opts.merge) {
282
+ const merged = unionEvidence([k.item.evidence, ...k.absorbed.map((a) => a.dup.evidence)]);
283
+ // Skip the no-op: identical evidence → the merge degenerates to a plain
284
+ // delete for this pair (no update delta).
285
+ if (merged !== k.item.evidence) {
286
+ updates.push({
287
+ delta: { op: "update", id: k.item.id, evidence: merged },
288
+ rationale: `dedupe: merged ${k.absorbed.length} duplicate(s) (${k.absorbed.map((a) => a.dup.id).join(", ")}) into ${k.item.id}; evidence unioned (${k.absorbed.length + 1} sources).`,
195
289
  });
196
- } else {
197
- keepers.push(cand);
198
290
  }
199
291
  }
200
- return proposals.length ? { deltas: proposals } : {};
292
+ for (const { dup, overlap } of k.absorbed) {
293
+ deletes.push({
294
+ delta: {
295
+ op: "delete",
296
+ id: dup.id,
297
+ reason: opts.merge
298
+ ? `merged into ${k.item.id} (overlap ${overlap.toFixed(2)})`
299
+ : `near-duplicate of ${k.item.id} (overlap ${overlap.toFixed(2)})`,
300
+ },
301
+ rationale: `dedupe: "${truncate(dup.content)}" ≈ keeper "${truncate(k.item.content)}" (Jaccard ${overlap.toFixed(2)}); ${opts.merge ? `merged into ${k.item.id}.` : `kept higher-importance ${k.item.id}.`}`,
302
+ });
303
+ }
304
+ }
305
+ return [...updates, ...deletes];
306
+ }
307
+
308
+ /** Rule-based proposer wrapping planDedupe with the configured (or default)
309
+ * options — merges near-duplicates; `merge: false` restores delete-only. */
310
+ export const dedupeProposer: DeltaProposer = {
311
+ name: "dedupe",
312
+ async propose({ state, config }): Promise<ProposeResult> {
313
+ const deltas = planDedupe(state, config?.dedupe ?? DEFAULT_DEDUPE);
314
+ return deltas.length ? { deltas } : {};
201
315
  },
202
316
  };
203
317
 
package/src/refine.ts CHANGED
@@ -20,9 +20,10 @@
20
20
  import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
21
21
  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
- import { getProposer } from "./proposer.js";
23
+ import { getProposer, DEFAULT_DEDUPE } from "./proposer.js";
24
24
  import type { CompleteOptions, CompleteResult } from "./proposer.js";
25
- import { projectSlug } from "./config.js";
25
+ import type { AppliedDelta } from "./types.js";
26
+ import { loadConfig, projectSlug } from "./config.js";
26
27
 
27
28
  const DEFAULT_EVIDENCE_BYTES = 16000;
28
29
  export const DEFAULT_LOOKBACK_TURNS = 25;
@@ -41,10 +42,18 @@ type AnyEntry = {
41
42
  export function registerRefine(pi: ExtensionAPI): void {
42
43
  pi.registerCommand("refine", {
43
44
  description:
44
- "Online self-improvement: review recent trajectory and propose evidence-backed CRUD deltas to the harness state. Usage: /refine [lookback-turns] [--commit] [--proposer steering|dedupe]",
45
+ "Online self-improvement: review recent trajectory and propose evidence-backed CRUD deltas to the harness state. Usage: /refine [lookback-turns] [--commit] [--proposer steering|dedupe] [--threshold 0.75]",
45
46
  handler: async (args, ctx) => {
46
- const { lookback, commit, proposer } = parseArgs(args);
47
- await runRefine(pi, ctx, { lookback, commit, ...(proposer ? { proposer } : {}) });
47
+ const { lookback, commit, proposer, threshold, thresholdInvalid } = parseArgs(args);
48
+ if (thresholdInvalid) {
49
+ ctx.ui.notify("Invalid --threshold ignored (expected 0 < t ≤ 1).", "warning");
50
+ }
51
+ await runRefine(pi, ctx, {
52
+ lookback,
53
+ commit,
54
+ ...(proposer ? { proposer } : {}),
55
+ ...(threshold !== undefined ? { threshold } : {}),
56
+ });
48
57
  },
49
58
  });
50
59
  }
@@ -54,6 +63,10 @@ export interface RefineOptions {
54
63
  commit?: boolean;
55
64
  /** Proposer name (see proposer.ts registry). Defaults to "steering". */
56
65
  proposer?: string;
66
+ /** Dedupe similarity threshold override in (0,1], applied for this run only
67
+ * (overrides `dedupe.threshold` in harness.json). Affects the rule-based
68
+ * `dedupe` proposer; ignored by others. */
69
+ threshold?: number;
57
70
  }
58
71
 
59
72
  export interface RefineResult {
@@ -85,6 +98,14 @@ export async function runRefine(
85
98
  ctx.ui.setStatus("harness", `Refining (last ${lookback} turns)… [${proposer.name}]`);
86
99
 
87
100
  const evidence = gatherEvidence(ctx, lookback);
101
+ // Load the (cached) config and thread it into ProposeInput so proposers read
102
+ // tuned knobs (e.g. the dedupe threshold/merge policy) without file I/O. An
103
+ // explicit --threshold overrides the configured dedupe threshold for this run.
104
+ const config = await loadConfig();
105
+ const proposerConfig =
106
+ options.threshold !== undefined
107
+ ? { ...config, dedupe: { ...(config.dedupe ?? DEFAULT_DEDUPE), threshold: options.threshold } }
108
+ : config;
88
109
  // Inject a one-shot model completion (built from ctx) so dedicated-model
89
110
  // proposers can make a hidden LLM call. Undefined when no model is resolvable.
90
111
  const complete = buildComplete(ctx);
@@ -94,6 +115,7 @@ export async function runRefine(
94
115
  evidence,
95
116
  state: snapshotState(),
96
117
  lookback,
118
+ config: proposerConfig,
97
119
  ...(complete ? { complete } : {}),
98
120
  });
99
121
  const proposedDeltas = result.deltas ?? [];
@@ -104,6 +126,7 @@ export async function runRefine(
104
126
  const ownerKey = modelKey(ctx.model);
105
127
 
106
128
  let applied = 0;
129
+ let appliedDeltas: AppliedDelta[] = [];
107
130
  let applyError: string | undefined;
108
131
  if (proposedDeltas.length > 0) {
109
132
  // Direct-apply path (rule-based / model proposers): persist each batch the
@@ -113,7 +136,7 @@ export async function runRefine(
113
136
  // race during the await above. It rolls back in-memory state before throwing,
114
137
  // so we surface the failure as an audited no-op rather than crashing /refine.
115
138
  try {
116
- const appliedDeltas = applyDeltas(
139
+ appliedDeltas = applyDeltas(
117
140
  proposedDeltas.map((d) => {
118
141
  const delta = d.delta;
119
142
  return ownerKey !== undefined && delta.op === "create"
@@ -143,6 +166,10 @@ export async function runRefine(
143
166
  source,
144
167
  proposer: proposer.name,
145
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,
146
173
  rationales: proposedDeltas.map((d) => d.rationale),
147
174
  // Hidden model spend made visible: dedicated-model proposers report what the
148
175
  // call cost (model, tokens, latency, ok/error). Absent for rule-based/steering.
@@ -184,19 +211,30 @@ function parseArgs(args: string): {
184
211
  lookback: number;
185
212
  commit: boolean;
186
213
  proposer: string | undefined;
214
+ threshold: number | undefined;
215
+ thresholdInvalid: boolean;
187
216
  } {
188
217
  const parts = args.trim().split(/\s+/).filter(Boolean);
189
218
  let lookback = DEFAULT_LOOKBACK_TURNS;
190
219
  let commit = false;
191
220
  let proposer: string | undefined;
221
+ let threshold: number | undefined;
222
+ let thresholdInvalid = false;
223
+ const readThreshold = (raw: string): void => {
224
+ const v = Number(raw);
225
+ if (Number.isFinite(v) && v > 0 && v <= 1) threshold = v;
226
+ else thresholdInvalid = true;
227
+ };
192
228
  for (let i = 0; i < parts.length; i++) {
193
229
  const p = parts[i]!;
194
230
  if (p === "--commit") commit = true;
195
231
  else if (p === "--proposer" && i + 1 < parts.length) proposer = parts[++i];
196
232
  else if (p.startsWith("--proposer=")) proposer = p.slice("--proposer=".length);
233
+ else if (p === "--threshold" && i + 1 < parts.length) readThreshold(parts[++i]!);
234
+ else if (p.startsWith("--threshold=")) readThreshold(p.slice("--threshold=".length));
197
235
  else if (/^\d+$/.test(p)) lookback = Math.max(1, Math.min(200, Number(p)));
198
236
  }
199
- return { lookback, commit, proposer };
237
+ return { lookback, commit, proposer, threshold, thresholdInvalid };
200
238
  }
201
239
 
202
240
  function gatherEvidence(ctx: ExtensionContext, lookback: number): string {