pi-continual-harness 0.9.0 → 0.10.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):
@@ -106,6 +107,9 @@ The model-facing tools:
106
107
  - `harness_mutate { deltas: [...] }` — apply a batch of `create` / `update` /
107
108
  `delete` deltas. Every `create` requires `evidence`. New items are stamped
108
109
  automatically with the active model (see [Model binding](#model-binding-per-model-isolation)).
110
+ `create`/`update` deltas also accept `scope: "global" | "project"` (the
111
+ project slug is stamped server-side from the session cwd — used by
112
+ `/harness split`; normally managed via `/harness move`).
109
113
 
110
114
  Active items are injected into the system prompt each turn as a structured
111
115
  block, appended to (never replacing) the base prompt.
@@ -137,6 +141,7 @@ Optional config at `~/.pi/agent/harness.json` (missing or malformed → defaults
137
141
  {
138
142
  "autoImport": false,
139
143
  "proposer": "steering",
144
+ "dedupe": { "threshold": 0.6, "merge": true },
140
145
  "injection": { "enabled": true, "maxTokens": 1500, "maxPerKind": 10, "charsPerToken": 4 },
141
146
  "remindRefine": { "enabled": false, "everyTurns": 50 },
142
147
  "autoRefine": { "enabled": false, "everyTurns": 100, "commit": false },
@@ -160,7 +165,19 @@ Optional config at `~/.pi/agent/harness.json` (missing or malformed → defaults
160
165
  restore the legacy "inject all items, in store order" behaviour.
161
166
  - **`proposer`** — which delta proposer `/refine` and auto-refine use. Defaults
162
167
  to `steering` (the agent reasons via a steering message). `dedupe` applies a
163
- rule-based dedupe directly. See [Proposers](#proposers).
168
+ rule-based merge directly. See [Proposers](#proposers).
169
+ - **`dedupe`** — the rule-based dedupe proposer's policy. `threshold` (default
170
+ `0.6`, valid `(0,1]`) is the token-overlap level at which two items sharing
171
+ the key fields (kind, owner model, durable layer) count as duplicates;
172
+ `merge` (default `true`) **merges** each duplicate into its keeper — the
173
+ keeper keeps its content verbatim, its `evidence` becomes the line-wise union
174
+ of both, applied as one audited `update` + `delete` pair — while `false`
175
+ restores the pre-0.10 delete-only behavior. `/refine --proposer dedupe
176
+ --threshold 0.75` overrides the threshold for one run. A fourth key,
177
+ `similarity`, is API-only (functions cannot come from `harness.json`):
178
+ companion packages inject a richer comparator whose return may **abstain**
179
+ per pair (`{ score, abstain }` — see [`SimilarityResult`](#proposers)); an
180
+ abstaining pair is always kept, never merged or deleted.
164
181
  - **`remindRefine`** — opt-in `turn_end` nudge. `{ "enabled": true,
165
182
  "everyTurns": 50 }` notifies you to run `/refine` on a cadence. It is
166
183
  informational only — it never mutates state.
@@ -235,9 +252,9 @@ How the binding is set and respected:
235
252
  `model: provider/id`; `/harness import` restores it. An item whose tag
236
253
  pi-reflect stripped becomes an orphan and is adopted by the active model.
237
254
 
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
255
+ Manual commands (`export`, `import`, `move`, `split`, `keep`, `drop`, `prune`,
256
+ `push-mem`, `status`) operate on the **whole store** by design — they are
257
+ explicit human actions with full control. Isolation is enforced only where pollution would
241
258
  leak automatically: injection, listing, create-stamping, and the outcome loop.
242
259
  In particular, `/harness push-mem` pushes *every* model's active items into
243
260
  pi-mem by default (which can yield near-duplicate memories across models); pass
@@ -294,7 +311,16 @@ deltas directly). The propose stage is pluggable via a registry
294
311
  | Name | What it does |
295
312
  |---|---|
296
313
  | `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. |
314
+ | `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. |
315
+
316
+ The dedupe planner's `similarity` seam (`DedupeOptions.similarity`, re-exported
317
+ 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
+ (a local-only project for now). A comparator may return
320
+ `{ score, abstain }` instead of a plain number: **an abstaining pair is
321
+ treated as not duplicates** — both items are kept — so an engine with
322
+ conformal uncertainty guarantees can safely decline to merge. Plain numeric
323
+ returns keep working (the default remains token Jaccard).
298
324
 
299
325
  Select a proposer per run with `/refine --proposer <name>`, or set the default
300
326
  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.10.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/index.ts CHANGED
@@ -74,15 +74,22 @@ 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
 
88
95
  // Injection selection policy (on by default). Re-exported so companion packages
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,9 @@
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 { loadConfig, projectSlug } from "./config.js";
26
26
 
27
27
  const DEFAULT_EVIDENCE_BYTES = 16000;
28
28
  export const DEFAULT_LOOKBACK_TURNS = 25;
@@ -41,10 +41,18 @@ type AnyEntry = {
41
41
  export function registerRefine(pi: ExtensionAPI): void {
42
42
  pi.registerCommand("refine", {
43
43
  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]",
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] [--threshold 0.75]",
45
45
  handler: async (args, ctx) => {
46
- const { lookback, commit, proposer } = parseArgs(args);
47
- await runRefine(pi, ctx, { lookback, commit, ...(proposer ? { proposer } : {}) });
46
+ const { lookback, commit, proposer, threshold, thresholdInvalid } = parseArgs(args);
47
+ if (thresholdInvalid) {
48
+ ctx.ui.notify("Invalid --threshold ignored (expected 0 < t ≤ 1).", "warning");
49
+ }
50
+ await runRefine(pi, ctx, {
51
+ lookback,
52
+ commit,
53
+ ...(proposer ? { proposer } : {}),
54
+ ...(threshold !== undefined ? { threshold } : {}),
55
+ });
48
56
  },
49
57
  });
50
58
  }
@@ -54,6 +62,10 @@ export interface RefineOptions {
54
62
  commit?: boolean;
55
63
  /** Proposer name (see proposer.ts registry). Defaults to "steering". */
56
64
  proposer?: string;
65
+ /** Dedupe similarity threshold override in (0,1], applied for this run only
66
+ * (overrides `dedupe.threshold` in harness.json). Affects the rule-based
67
+ * `dedupe` proposer; ignored by others. */
68
+ threshold?: number;
57
69
  }
58
70
 
59
71
  export interface RefineResult {
@@ -85,6 +97,14 @@ export async function runRefine(
85
97
  ctx.ui.setStatus("harness", `Refining (last ${lookback} turns)… [${proposer.name}]`);
86
98
 
87
99
  const evidence = gatherEvidence(ctx, lookback);
100
+ // Load the (cached) config and thread it into ProposeInput so proposers read
101
+ // tuned knobs (e.g. the dedupe threshold/merge policy) without file I/O. An
102
+ // explicit --threshold overrides the configured dedupe threshold for this run.
103
+ const config = await loadConfig();
104
+ const proposerConfig =
105
+ options.threshold !== undefined
106
+ ? { ...config, dedupe: { ...(config.dedupe ?? DEFAULT_DEDUPE), threshold: options.threshold } }
107
+ : config;
88
108
  // Inject a one-shot model completion (built from ctx) so dedicated-model
89
109
  // proposers can make a hidden LLM call. Undefined when no model is resolvable.
90
110
  const complete = buildComplete(ctx);
@@ -94,6 +114,7 @@ export async function runRefine(
94
114
  evidence,
95
115
  state: snapshotState(),
96
116
  lookback,
117
+ config: proposerConfig,
97
118
  ...(complete ? { complete } : {}),
98
119
  });
99
120
  const proposedDeltas = result.deltas ?? [];
@@ -184,19 +205,30 @@ function parseArgs(args: string): {
184
205
  lookback: number;
185
206
  commit: boolean;
186
207
  proposer: string | undefined;
208
+ threshold: number | undefined;
209
+ thresholdInvalid: boolean;
187
210
  } {
188
211
  const parts = args.trim().split(/\s+/).filter(Boolean);
189
212
  let lookback = DEFAULT_LOOKBACK_TURNS;
190
213
  let commit = false;
191
214
  let proposer: string | undefined;
215
+ let threshold: number | undefined;
216
+ let thresholdInvalid = false;
217
+ const readThreshold = (raw: string): void => {
218
+ const v = Number(raw);
219
+ if (Number.isFinite(v) && v > 0 && v <= 1) threshold = v;
220
+ else thresholdInvalid = true;
221
+ };
192
222
  for (let i = 0; i < parts.length; i++) {
193
223
  const p = parts[i]!;
194
224
  if (p === "--commit") commit = true;
195
225
  else if (p === "--proposer" && i + 1 < parts.length) proposer = parts[++i];
196
226
  else if (p.startsWith("--proposer=")) proposer = p.slice("--proposer=".length);
227
+ else if (p === "--threshold" && i + 1 < parts.length) readThreshold(parts[++i]!);
228
+ else if (p.startsWith("--threshold=")) readThreshold(p.slice("--threshold=".length));
197
229
  else if (/^\d+$/.test(p)) lookback = Math.max(1, Math.min(200, Number(p)));
198
230
  }
199
- return { lookback, commit, proposer };
231
+ return { lookback, commit, proposer, threshold, thresholdInvalid };
200
232
  }
201
233
 
202
234
  function gatherEvidence(ctx: ExtensionContext, lookback: number): string {