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 +32 -6
- package/package.json +1 -1
- package/src/config.ts +29 -0
- package/src/index.ts +7 -0
- package/src/proposer.ts +154 -40
- package/src/refine.ts +38 -6
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
|
|
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
|
|
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`,
|
|
239
|
-
`status`) operate on the **whole store** by design — they are
|
|
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:
|
|
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.
|
|
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):
|
|
14
|
-
// overlap
|
|
15
|
-
//
|
|
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
|
-
*
|
|
159
|
-
*
|
|
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
|
|
163
|
-
* only against keepers, so a keeper is never
|
|
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
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|