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 +33 -6
- package/package.json +1 -1
- package/src/config.ts +29 -0
- package/src/corpus.ts +310 -0
- package/src/harness.ts +40 -0
- package/src/index.ts +20 -0
- package/src/proposer.ts +154 -40
- package/src/refine.ts +45 -7
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):
|
|
@@ -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
|
|
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`,
|
|
239
|
-
`status`) operate on the **whole store** by design — they are
|
|
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:
|
|
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.
|
|
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):
|
|
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,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 {
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|