pi-continual-harness 0.11.0 → 0.12.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
@@ -145,8 +145,10 @@ Optional config at `~/.pi/agent/harness.json` (missing or malformed → defaults
145
145
  "dedupe": { "threshold": 0.6, "merge": true },
146
146
  "injection": { "enabled": true, "maxTokens": 1500, "maxPerKind": 10, "charsPerToken": 4 },
147
147
  "remindRefine": { "enabled": false, "everyTurns": 50 },
148
- "autoRefine": { "enabled": false, "everyTurns": 100, "commit": false },
149
- "outcomeImportance": { "enabled": false, "bump": 0.03 }
148
+ "autoRefine": { "enabled": false, "everyTurns": 100, "commit": false, "gate": false },
149
+ "outcomeImportance": { "enabled": false, "bump": 0.03 },
150
+ "classifier": { "model": "typesafe/jev-latest" },
151
+ "virtualBinding": "virtual"
150
152
  }
151
153
  ```
152
154
 
@@ -187,7 +189,29 @@ Optional config at `~/.pi/agent/harness.json` (missing or malformed → defaults
187
189
  (default 100). It is one of the package's opt-in autonomous paths: it reuses
188
190
  the exact `/refine` routine (audited `REFINE_ENTRY` tagged `source: "auto"`,
189
191
  branch-local, `/tree` rollback) and notifies before firing. `commit: true`
190
- also flushes durable state on each run.
192
+ also flushes durable state on each run. `gate: true` (needs `classifier.model`)
193
+ asks the classifier first whether the recent trajectory contains a durable,
194
+ reusable correction and skips the refine when it does not — cadence becomes
195
+ signal, not just time (errors fall back to the plain cadence). The steering
196
+ prompt is delivered via the pi ≥ 0.87 actionable `turn_end` boundary (a
197
+ structural `harness.auto-refine-request` entry + `continue: true`), not a
198
+ synthetic user message.
199
+ - **`classifier`** — opt-in classifier model (pi ≥ 0.99) for the cheap gated
200
+ paths: `"provider/id"` or a bare id of a **classifier** catalog entry, e.g.
201
+ `"typesafe/jev-latest"` or a local `llama-cpp` classifier (every chat model
202
+ listed for llama.cpp also appears as a classifier). When set and resolvable
203
+ it enables the classifier-confirmed dedupe (see `dedupe` above) and the
204
+ auto-refine `gate`. When unset or unresolvable, every classifier feature
205
+ degrades to its no-classifier behavior.
206
+ - **`virtualBinding`** — how the per-model isolation key treats pi **virtual
207
+ models** (router extensions, pi ≥ 0.99). Default `"virtual"`: the key is
208
+ the selected model's own `provider/id` — a router starts a blank harness.
209
+ `"physical"`: when the selection is virtual, the key is the *physical*
210
+ model behind the latest successful response on the branch — notes
211
+ accumulated under that physical model keep injecting through the router, and
212
+ switching routed models re-scopes exactly like a manual model switch. Before
213
+ the first response the key is unknown: creates stay orphans and are adopted
214
+ on first contact.
191
215
  - **`outcomeImportance`** — opt-in autonomous **promotion** loop (**off by
192
216
  default**). When `enabled`, a `turn_end` hook bumps (+`bump`, default 0.03)
193
217
  the importance of any active item the agent cited by its `[h_xxxx]` tag in the
@@ -370,7 +394,7 @@ Then `"my-proposer"` is selectable via `/refine --proposer my-proposer` or
370
394
 
371
395
  ## Status
372
396
 
373
- 0.9.x. Implemented:
397
+ 0.12.x. Requires **pi ≥ 0.99.0**. Implemented:
374
398
 
375
399
  - Unified harness-state store with branch-local snapshots (`/tree` rollback).
376
400
  - Online `/refine` + `harness_mutate` / `harness_list` tools.
@@ -395,11 +419,28 @@ Then `"my-proposer"` is selectable via `/refine --proposer my-proposer` or
395
419
  - **Per-model isolation**: every item is bound to a `provider/id` and injected
396
420
  only for that model; new items are stamped automatically and orphans adopted
397
421
  on first contact. A new model id starts from a blank harness, and the durable
398
- round-trip preserves the owner tag.
422
+ round-trip preserves the owner tag. With pi virtual models (routers), the
423
+ `"virtualBinding": "physical"` policy keys items by the routed physical
424
+ model instead of the router id, so a router selection does not silently
425
+ reset the harness.
399
426
  - **Bounded injection (on by default)**: the supplemental block is
400
427
  importance-ordered and capped per kind + by a total token budget, so a growing
401
428
  harness never balloons the system prompt. Tunable / opt-out via the `injection`
402
- config key; the store is never changed by selection.
429
+ config key; the store is never changed by selection. Rendered on **every
430
+ provider request** (`context_with_system`), so a mid-run `harness_mutate`
431
+ shows up on the very next request.
432
+ - **Classifier features (pi ≥ 0.99, opt-in)**: `"classifier": { "model": … }`
433
+ enables (1) classifier-confirmed dedupe — candidate merge pairs confirmed by
434
+ one batched yes/no call (errors fall back to the rule-based plan) — and (2)
435
+ the auto-refine gate `"autoRefine": { "gate": true }`, which skips a cadenced
436
+ refine when the trajectory holds no durable correction.
437
+ - **Structured tool results**: `harness_list` / `harness_mutate` return
438
+ `structuredContent` (with `outputSchema`), so codemode scripts and
439
+ `ctx.executeTool()` callers can consume harness state programmatically.
440
+ - **Boundary delivery for auto-refine**: the steering prompt is persisted as a
441
+ structural entry with `{ entries, continue: true }` at `turn_end` (no
442
+ synthetic user message, no follow-up-scheduling side effects); manual
443
+ `/refine` still steers via a user message.
403
444
 
404
445
  Open extension points (see `docs/ROADMAP.md`):
405
446
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-continual-harness",
3
- "version": "0.11.0",
3
+ "version": "0.12.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",
@@ -35,13 +35,13 @@
35
35
  "node": ">=20.0.0"
36
36
  },
37
37
  "peerDependencies": {
38
- "@earendil-works/pi-coding-agent": ">=0.74.0",
39
- "@earendil-works/pi-ai": ">=0.74.0",
38
+ "@earendil-works/pi-coding-agent": ">=0.99.0",
39
+ "@earendil-works/pi-ai": ">=0.99.0",
40
40
  "typebox": ">=1.0.0"
41
41
  },
42
42
  "devDependencies": {
43
- "@earendil-works/pi-coding-agent": ">=0.74.0",
44
- "@earendil-works/pi-ai": ">=0.74.0",
43
+ "@earendil-works/pi-coding-agent": ">=0.99.0",
44
+ "@earendil-works/pi-ai": ">=0.99.0",
45
45
  "typebox": ">=1.0.0",
46
46
  "@types/node": "^22.0.0",
47
47
  "typescript": "^5.6.0",
@@ -9,13 +9,30 @@
9
9
  // properties: structured evidence-backed deltas, an audited REFINE_ENTRY
10
10
  // (tagged source: "auto"), branch-local snapshots, and /tree rollback.
11
11
  //
12
- // It is also visible: it notifies before firing and the steering message
13
- // appears in the transcript. The decision logic is factored into
12
+ // Two pi ≥ 0.87/0.99 upgrades (0.12.0):
13
+ //
14
+ // - Classifier gate (opt-in via autoRefine.gate + classifier.model): before
15
+ // spending a refine, ask ONE cheap yes/no classifier question over the
16
+ // recent trajectory. "No durable correction" skips the refine entirely —
17
+ // cadence becomes signal, not just time. Soft-fails to the plain cadence on
18
+ // any classifier error (the gate can skip work, never block it).
19
+ //
20
+ // - Boundary delivery: the steering message is no longer sent as a synthetic
21
+ // user message; runRefine returns it undelivered and this handler persists
22
+ // it as a structural custom-message entry with `{ entries, continue: true }`
23
+ // — the actionable turn_end boundary. One guaranteed next provider request,
24
+ // no steering queue and no follow-up scheduling side effects. Falls back to
25
+ // sendUserMessage when the boundary cannot continue (event.context.
26
+ // canContinue false, e.g. after an error turn).
27
+ //
28
+ // It stays visible: it notifies before firing, and the boundary draft (and the
29
+ // gate decision) appear in the transcript. The decision logic is factored into
14
30
  // evaluateAutoRefine() so it is unit-testable without a live pi runtime.
15
31
 
16
32
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
17
33
  import { DEFAULT_AUTO_EVERY_TURNS, type HarnessConfig, loadConfig } from "./config.js";
18
- import { runRefine } from "./refine.js";
34
+ import { runRefine, gatherEvidence, DEFAULT_LOOKBACK_TURNS } from "./refine.js";
35
+ import { buildClassify, buildGateRequest, gateDecision } from "./classify.js";
19
36
 
20
37
  // turnIndex of the last auto-refine (or the seeded baseline). -1 = unseen.
21
38
  let lastTurn = -1;
@@ -45,20 +62,64 @@ export function evaluateAutoRefine(config: HarnessConfig, turnIndex: number): bo
45
62
  return false;
46
63
  }
47
64
 
65
+ /** The boundary draft custom type for auto-refine steering messages. */
66
+ export const AUTO_REFINE_ENTRY = "harness.auto-refine-request";
67
+
68
+ /** Build the boundary draft carrying a steering message. Pure — testable. */
69
+ export function buildRefineDraft(steeringMessage: string): {
70
+ type: "custom_message";
71
+ customType: string;
72
+ content: string;
73
+ display: boolean;
74
+ } {
75
+ return { type: "custom_message", customType: AUTO_REFINE_ENTRY, content: steeringMessage, display: true };
76
+ }
77
+
48
78
  /** Subscribe to turn_end and run /refine on the configured cadence. */
49
79
  export function registerAutoRefine(pi: ExtensionAPI): void {
50
80
  pi.on("turn_end", async (event, ctx) => {
51
81
  const config = await loadConfig();
52
82
  if (!evaluateAutoRefine(config, event.turnIndex)) return;
53
83
  const every = config.autoRefine?.everyTurns ?? DEFAULT_AUTO_EVERY_TURNS;
84
+
85
+ // Opt-in classifier gate: cheap yes/no before spending the refine.
86
+ if (config.autoRefine?.gate) {
87
+ const classify = buildClassify(ctx, config.classifier?.model);
88
+ if (classify) {
89
+ const evidence = gatherEvidence(ctx, DEFAULT_LOOKBACK_TURNS);
90
+ const res = await classify(buildGateRequest(evidence));
91
+ const decision = gateDecision(res);
92
+ if (!decision.proceed) {
93
+ ctx.ui.notify(`Auto-refine: skipped — ${decision.because}.`, "info");
94
+ return;
95
+ }
96
+ ctx.ui.notify(`Auto-refine: gate passed (${decision.because}).`, "info");
97
+ }
98
+ // No resolvable classifier → plain cadence behavior (documented soft-fail).
99
+ }
100
+
54
101
  ctx.ui.notify(`Auto-refine: running /refine (every ${every} turns, opt-in).`, "info");
55
102
  try {
56
- await runRefine(
103
+ const result = await runRefine(
57
104
  pi,
58
105
  ctx,
59
- { commit: config.autoRefine?.commit ?? false, ...(config.proposer ? { proposer: config.proposer } : {}) },
106
+ {
107
+ commit: config.autoRefine?.commit ?? false,
108
+ ...(config.proposer ? { proposer: config.proposer } : {}),
109
+ delivery: "boundary",
110
+ },
60
111
  "auto",
61
112
  );
113
+ // Boundary delivery: persist the steering message as a structural entry
114
+ // and guarantee one next provider request. Only when the boundary CAN
115
+ // continue — otherwise fall back to the legacy steering send.
116
+ if (result.steeringMessage) {
117
+ if (event.context?.canContinue === false) {
118
+ pi.sendUserMessage(result.steeringMessage);
119
+ return;
120
+ }
121
+ return { entries: [...event.entries, buildRefineDraft(result.steeringMessage)], continue: true };
122
+ }
62
123
  } catch (err) {
63
124
  ctx.ui.notify(`Auto-refine failed: ${(err as Error).message}`, "error");
64
125
  }
@@ -0,0 +1,220 @@
1
+ // Classifier adapter (pi ≥ 0.99). Wraps ctx.modelRegistry.classify() — cheap
2
+ // typed yes/no or score questions answered from next-token label probabilities
3
+ // (local llama.cpp) or a hosted Jev model — behind two package seams:
4
+ //
5
+ // 1. autoRefine gate (auto-refine.ts): when the cadence elapses, ask ONE bool
6
+ // question over the recent trajectory ("did it contain a durable, reusable
7
+ // correction?") and skip the refine when the answer is no. Soft-fails to the
8
+ // old cadence behavior on any classifier error, so a flaky classifier never
9
+ // blocks refinement.
10
+ // 2. dedupe confirmation (proposer.ts): a precision filter on candidate merge
11
+ // pairs — see classifyConfirmPairs().
12
+ //
13
+ // The pi-ai classifier shapes (ClassifierContext/ClassifierQuestion/…) are kept
14
+ // OUT of the public proposer interface: ClassifyFn below is a minimal structural
15
+ // contract so proposers stay testable without pi runtime types.
16
+
17
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
18
+
19
+ /** One yes/no question in a ClassifyFn request. */
20
+ export interface ClassifyBoolQuestion {
21
+ instructions: string;
22
+ /** Criteria text for the yes answer. */
23
+ trueCriteria: string;
24
+ /** Criteria text for the no answer. */
25
+ falseCriteria: string;
26
+ }
27
+
28
+ /** A parsed answer per question key. */
29
+ export interface ClassifyBoolAnswer {
30
+ /** The classifier's decision. */
31
+ value: boolean;
32
+ /** [0,1] confidence when the engine reports one. */
33
+ confidence?: number;
34
+ }
35
+
36
+ /** Result of a ClassifyFn call: one answer per question key, or an error. */
37
+ export interface ClassifyBoolResult {
38
+ ok: boolean;
39
+ answers: Record<string, ClassifyBoolAnswer>;
40
+ /** Resolved classifier label ("provider/id") for telemetry. */
41
+ model?: string;
42
+ /** Token usage when the engine reports it (for the audit trail). */
43
+ usage?: { input: number; output: number };
44
+ error?: string;
45
+ }
46
+
47
+ /**
48
+ * Batched yes/no classification seam injected into ProposeInput when a
49
+ * classifier model is configured AND resolvable. Structural on purpose —
50
+ * the adapter below is the only place that knows pi-ai's shapes.
51
+ */
52
+ export type ClassifyFn = (input: {
53
+ state: Record<string, unknown>;
54
+ questions: Record<string, ClassifyBoolQuestion>;
55
+ }) => Promise<ClassifyBoolResult>;
56
+
57
+ /** Structural slice of ctx.modelRegistry used here. */
58
+ interface RegistryLike {
59
+ classify?: (model: unknown, context: unknown, options?: unknown) => Promise<ClassifyRawResult>;
60
+ findOfType?: (type: "classifier", provider: string, id: string) => unknown | undefined;
61
+ getModelsOfType?: (type: "classifier") => readonly unknown[];
62
+ }
63
+
64
+ /** Raw pi-ai ClassifierResult slice (never rejects, but stopReason may be error). */
65
+ interface ClassifyRawResult {
66
+ answers?: Record<string, { type?: string; choice?: unknown; score?: number; probability?: number; confidence?: number }>;
67
+ stopReason?: string;
68
+ errorMessage?: string;
69
+ }
70
+
71
+ /** Raw classifier-model slice (Model<ClassifierApi>). */
72
+ interface ClassifierModelLike {
73
+ provider?: string;
74
+ id?: string;
75
+ }
76
+
77
+ /**
78
+ * Resolve a classifier model from a configured id ("provider/id" or bare id).
79
+ * Returns undefined when unconfigured or unresolvable — every feature keyed on
80
+ * it then degrades to its no-classifier behavior.
81
+ */
82
+ export function resolveClassifierModel(
83
+ registry: RegistryLike | undefined,
84
+ configured?: string,
85
+ ): ClassifierModelLike | undefined {
86
+ if (!configured || !registry) return undefined;
87
+ const slash = configured.indexOf("/");
88
+ if (slash > 0 && typeof registry.findOfType === "function") {
89
+ const byPair = registry.findOfType("classifier", configured.slice(0, slash), configured.slice(slash + 1));
90
+ if (byPair) return byPair as ClassifierModelLike;
91
+ }
92
+ const all =
93
+ typeof registry.getModelsOfType === "function"
94
+ ? [...registry.getModelsOfType("classifier")]
95
+ : [];
96
+ return (all.find((m) => {
97
+ const cm = m as ClassifierModelLike;
98
+ return cm.id === configured || `${cm.provider}/${cm.id}` === configured;
99
+ }) ?? undefined) as ClassifierModelLike | undefined;
100
+ }
101
+
102
+ /** Build the ClassifyFn adapter over a resolved classifier model. */
103
+ export function makeClassifyFn(
104
+ registry: RegistryLike,
105
+ model: ClassifierModelLike,
106
+ signal?: AbortSignal,
107
+ ): ClassifyFn {
108
+ const label = `${model.provider}/${model.id}`;
109
+ return async ({ state, questions }) => {
110
+ if (typeof registry.classify !== "function") {
111
+ return { ok: false, answers: {}, model: label, error: "modelRegistry.classify unavailable" };
112
+ }
113
+ // Map our minimal bool contract onto pi-ai's ClassifierBoolQuestion.
114
+ const piQuestions: Record<string, unknown> = {};
115
+ for (const [key, q] of Object.entries(questions)) {
116
+ piQuestions[key] = {
117
+ type: "bool",
118
+ instructions: q.instructions,
119
+ criteria: { true: q.trueCriteria, false: q.falseCriteria },
120
+ };
121
+ }
122
+ let raw: ClassifyRawResult;
123
+ try {
124
+ raw = (await registry.classify(model, { state, questions: piQuestions }, signal ? { signal } : undefined)) as ClassifyRawResult;
125
+ } catch (err) {
126
+ // classify "never rejects", but stay defensive around transport edges.
127
+ return { ok: false, answers: {}, model: label, error: (err as Error).message };
128
+ }
129
+ const usage =
130
+ typeof (raw as { usage?: { input?: number; output?: number } }).usage?.input === "number" &&
131
+ typeof (raw as { usage?: { input?: number; output?: number } }).usage?.output === "number"
132
+ ? {
133
+ input: (raw as { usage?: { input: number; output: number } }).usage!.input,
134
+ output: (raw as { usage?: { input: number; output: number } }).usage!.output,
135
+ }
136
+ : undefined;
137
+ if (raw.stopReason && raw.stopReason !== "stop") {
138
+ return {
139
+ ok: false,
140
+ answers: {},
141
+ model: label,
142
+ ...(usage ? { usage } : {}),
143
+ error: raw.errorMessage ?? `stopReason ${raw.stopReason}`,
144
+ };
145
+ }
146
+ const answers: Record<string, ClassifyBoolAnswer> = {};
147
+ for (const [key, a] of Object.entries(raw.answers ?? {})) {
148
+ let value: boolean | undefined;
149
+ let confidence: number | undefined;
150
+ if (a?.type === "bool") {
151
+ // Bool answers carry the probability of TRUE.
152
+ value = (a.probability ?? 0) >= 0.5;
153
+ confidence = a.probability;
154
+ } else if (a?.type === "choice") {
155
+ value = a.choice === true || a.choice === "true";
156
+ confidence = a.confidence;
157
+ } else if (a?.type === "score") {
158
+ value = (a.score ?? 0) >= 0.5;
159
+ confidence = a.confidence;
160
+ }
161
+ if (value !== undefined) {
162
+ answers[key] = { value, ...(typeof confidence === "number" ? { confidence } : {}) };
163
+ }
164
+ }
165
+ return { ok: true, answers, model: label, ...(usage ? { usage } : {}) };
166
+ };
167
+ }
168
+
169
+ /**
170
+ * Resolve the ClassifyFn for a session from config + ctx, or undefined.
171
+ * The single construction point shared by refine (ProposeInput.classify) and
172
+ * the auto-refine gate.
173
+ */
174
+ export function buildClassify(
175
+ ctx: ExtensionContext,
176
+ configuredModel?: string,
177
+ ): ClassifyFn | undefined {
178
+ const registry = ctx.modelRegistry as RegistryLike | undefined;
179
+ const model = resolveClassifierModel(registry, configuredModel);
180
+ if (!registry || !model) return undefined;
181
+ return makeClassifyFn(registry, model, ctx.signal);
182
+ }
183
+
184
+ /**
185
+ * The auto-refine gate question: did the recent trajectory contain a durable,
186
+ * reusable correction worth a /refine pass? Pure builder — unit-testable.
187
+ * Keeps the state payload bounded (the caller already bounds evidence bytes).
188
+ */
189
+ export function buildGateRequest(evidence: string): {
190
+ state: Record<string, unknown>;
191
+ questions: Record<string, ClassifyBoolQuestion>;
192
+ } {
193
+ return {
194
+ state: { trajectory: evidence },
195
+ questions: {
196
+ gate: {
197
+ instructions:
198
+ "You are gating an online self-improvement pass for a coding agent. Does the recent trajectory below contain a durable, reusable correction — a mistake fixed, a preference learned, a convention discovered, or a fact worth remembering across sessions — that is NOT already routine work?",
199
+ trueCriteria:
200
+ "The trajectory contains at least one durable, reusable lesson or correction worth persisting into the agent's harness state.",
201
+ falseCriteria:
202
+ "The trajectory is routine work (ordinary edits, reads, test runs) with no reusable correction worth persisting.",
203
+ },
204
+ },
205
+ };
206
+ }
207
+
208
+ /**
209
+ * Gate decision from a classify result. Errors and missing answers fall back to
210
+ * `true` (proceed) so a flaky classifier never disables refinement — the gate
211
+ * can only SKIP work, never block it.
212
+ */
213
+ export function gateDecision(result: ClassifyBoolResult): { proceed: boolean; because: string } {
214
+ if (!result.ok) return { proceed: true, because: `classifier error (${result.error ?? "unknown"}); falling back to cadence` };
215
+ const a = result.answers.gate;
216
+ if (!a) return { proceed: true, because: "no gate answer; falling back to cadence" };
217
+ return a.value
218
+ ? { proceed: true, because: `classifier: yes (confidence ${a.confidence ?? "?"})` }
219
+ : { proceed: false, because: `classifier: no durable correction (confidence ${a.confidence ?? "?"})` };
220
+ }
package/src/config.ts CHANGED
@@ -27,6 +27,12 @@ export const DEFAULT_AUTO_EVERY_TURNS = 100;
27
27
  /** Default per-reference importance bump for the opt-in outcome loop. */
28
28
  export const DEFAULT_REF_BUMP = 0.03;
29
29
 
30
+ /** How the binding key treats virtual (router) models. "virtual" (default):
31
+ * key = the selected model's own provider/id. "physical": when the selection
32
+ * is virtual, key = the physical model behind the latest successful response
33
+ * on the branch (see src/vmodel.ts). */
34
+ export type VirtualBinding = "virtual" | "physical";
35
+
30
36
  /** Resolved dedupe policy (merge-capable, 0.10.0). Populated by loadConfig. */
31
37
  export interface NormalizedDedupe {
32
38
  /** Token-overlap threshold in (0,1]; pairs at/above it merge. Default 0.6. */
@@ -51,7 +57,21 @@ export interface HarnessConfig {
51
57
  enabled?: boolean;
52
58
  everyTurns?: number;
53
59
  commit?: boolean;
60
+ /** Opt-in classifier gate (pi ≥ 0.99, needs `classifier.model`): when the
61
+ * cadence elapses, ask a cheap yes/no classifier whether the recent
62
+ * trajectory contains a durable correction; skip the refine when it says
63
+ * no. Soft-fails to the plain cadence on classifier errors. Default false. */
64
+ gate?: boolean;
65
+ };
66
+ /** Classifier model for the cheap gated paths (pi ≥ 0.99): "provider/id" or
67
+ * a bare id of a classifier catalog entry (e.g. "typesafe/jev-latest" or a
68
+ * local llama.cpp classifier). When unset or unresolvable, every classifier
69
+ * feature degrades to its no-classifier behavior. */
70
+ classifier?: {
71
+ model?: string | undefined;
54
72
  };
73
+ /** Virtual-model binding policy (pi ≥ 0.99). Default "virtual". */
74
+ virtualBinding?: VirtualBinding;
55
75
  /** Delta proposer name (see proposer.ts registry). Defaults to "steering". */
56
76
  proposer?: string;
57
77
  /** Opt-in turn_end outcome loop: promote importance of items the agent
@@ -72,7 +92,9 @@ export const DEFAULT_CONFIG: HarnessConfig = {
72
92
  durableScope: "global",
73
93
  autoImport: false,
74
94
  remindRefine: { enabled: false, everyTurns: DEFAULT_EVERY_TURNS },
75
- autoRefine: { enabled: false, everyTurns: DEFAULT_AUTO_EVERY_TURNS, commit: false },
95
+ autoRefine: { enabled: false, everyTurns: DEFAULT_AUTO_EVERY_TURNS, commit: false, gate: false },
96
+ classifier: { model: undefined },
97
+ virtualBinding: "virtual",
76
98
  proposer: "steering",
77
99
  outcomeImportance: { enabled: false, bump: DEFAULT_REF_BUMP },
78
100
  // Dedupe (merge-capable): merge ON at the historical 0.6 threshold — a
@@ -102,7 +124,12 @@ function mergeConfig(over: Partial<HarnessConfig>): HarnessConfig {
102
124
  enabled: over.autoRefine?.enabled ?? false,
103
125
  everyTurns: over.autoRefine?.everyTurns ?? DEFAULT_AUTO_EVERY_TURNS,
104
126
  commit: over.autoRefine?.commit ?? false,
127
+ gate: over.autoRefine?.gate === true,
128
+ },
129
+ classifier: {
130
+ model: typeof over.classifier?.model === "string" && over.classifier.model ? over.classifier.model : undefined,
105
131
  },
132
+ virtualBinding: over.virtualBinding === "physical" ? "physical" : "virtual",
106
133
  proposer: over.proposer ?? "steering",
107
134
  outcomeImportance: {
108
135
  enabled: over.outcomeImportance?.enabled ?? false,
package/src/index.ts CHANGED
@@ -117,3 +117,25 @@ export {
117
117
  type NormalizedInjection,
118
118
  type SelectionResult,
119
119
  } from "./select.js";
120
+
121
+ // Virtual-model binding policy (pi ≥ 0.99, 0.12.0): strict per-model isolation
122
+ // across router selections. Re-exported so companion packages and tests reuse
123
+ // the pure resolution without reaching into internal paths.
124
+ export {
125
+ branchEntries,
126
+ isVirtualModel,
127
+ physicalKeyFromBranch,
128
+ resolveBindingKey,
129
+ VIRTUAL_MODEL_API,
130
+ } from "./vmodel.js";
131
+
132
+ // Classifier seam (pi ≥ 0.99, 0.12.0): the minimal structural ClassifyFn
133
+ // contract proposers see, plus the gate builders (pure, testable).
134
+ export {
135
+ buildGateRequest,
136
+ gateDecision,
137
+ type ClassifyBoolAnswer,
138
+ type ClassifyBoolQuestion,
139
+ type ClassifyBoolResult,
140
+ type ClassifyFn,
141
+ } from "./classify.js";
package/src/inject.ts CHANGED
@@ -1,13 +1,19 @@
1
- // before_agent_start: inject the active harness state as a structured block
2
- // appended to the base system prompt. Never rewrites or replaces the base —
3
- // only appends, matching Continual Harness's "immutable base + supplemental".
1
+ // Injection of the active harness state — two cooperating handlers (pi ≥ 0.99):
4
2
  //
5
- // This handler is also the model-binding bridge. The model-facing tools
6
- // (harness_list / harness_mutate) receive NO ctx, so they cannot read the
7
- // active model at execute time. before_agent_start always fires first in a turn
8
- // WITH ctx.model, so it: (1) caches the active model key for the tools,
9
- // (2) adopts any orphan items to the active model (the migration policy), and
10
- // (3) renders ONLY items bound to the active model — strict per-model isolation.
3
+ // before_agent_start: the model-binding bridge. The model-facing tools
4
+ // (harness_list / harness_mutate) receive no usable ctx, so this always-first
5
+ // handler (1) resolves the binding key under the configured virtual-model
6
+ // policy (see vmodel.ts) and caches it for the tools, and (2) adopts any
7
+ // orphan items to that key (the migration policy). It no longer bakes the
8
+ // harness block into the base system prompt.
9
+ //
10
+ // context_with_system: the render. Fires before EVERY provider request on the
11
+ // full transcript (system message included), so the block always reflects the
12
+ // LIVE store — a harness_mutate that fixes a stale note mid-run shows up on
13
+ // the very next request instead of the next agent run. The handler owns the
14
+ // returned transcript (pi sends it verbatim), so it returns undefined — never
15
+ // an owned transcript — when there is nothing to inject, and otherwise keeps
16
+ // the system message at index 0 and appends the block to it.
11
17
  //
12
18
  // WHAT gets rendered is decided by the selection policy in select.ts (on by
13
19
  // default): importance-ordered, capped per kind and by a total token budget, so
@@ -15,9 +21,10 @@
15
21
  // harness.json `injection`; the legacy "all items, in order" mode is a toggle.
16
22
 
17
23
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
18
- import { adoptOrphans, getState, modelKey, setActiveModelKey } from "./store.js";
24
+ import { adoptOrphans, getActiveModelKey, getState, setActiveModelKey } from "./store.js";
19
25
  import { KIND_ORDER, selectForInjection, type InjectionConfig } from "./select.js";
20
26
  import { loadConfig } from "./config.js";
27
+ import { resolveBindingKey, branchEntries } from "./vmodel.js";
21
28
  import type { ComponentKind } from "./types.js";
22
29
 
23
30
  const TITLES: Record<ComponentKind, string> = {
@@ -70,24 +77,49 @@ export function renderHarnessBlock(ownerKey?: string, cfg?: InjectionConfig): st
70
77
  return lines.join("\n");
71
78
  }
72
79
 
80
+ /** Append text to a system message's content (string or text blocks). */
81
+ function appendToSystemContent(content: string | Array<{ type: string; text?: string }>, block: string): string | Array<{ type: string; text?: string }> {
82
+ if (typeof content === "string") return content + "\n" + block;
83
+ return [...content, { type: "text", text: block }];
84
+ }
85
+
73
86
  export function registerInjection(pi: ExtensionAPI): void {
74
- pi.on("before_agent_start", async (event, ctx) => {
75
- const key = modelKey(ctx.model);
87
+ pi.on("before_agent_start", async (_event, ctx) => {
88
+ // Resolve the binding key under the configured policy. For a physical
89
+ // selection (or the default "virtual" policy) this is just provider/id;
90
+ // under "physical" with a virtual selection it is the routed model behind
91
+ // the latest successful response, undefined before the first response
92
+ // (creates then become orphans, adopted on the next contact).
93
+ const { virtualBinding } = await loadConfig();
94
+ const key = resolveBindingKey(ctx.model, branchEntries(ctx), virtualBinding);
76
95
  // Cache for the model-facing tools (they have no ctx of their own).
77
96
  setActiveModelKey(key);
78
- // Adopt any orphans to the active model (legacy/import migration). No-op —
97
+ // Adopt any orphans to the resolved key (legacy/import migration). No-op —
79
98
  // and no persist — when there is nothing to adopt.
80
99
  if (key) {
81
100
  adoptOrphans(key, (snapshot, ver) => {
82
101
  pi.appendEntry("harness-state", { state: snapshot, version: ver });
83
102
  });
84
103
  }
85
- // Read the injection policy from config (cached; tolerant). The default is
86
- // ON — importance-ordered + bounded — so a growing harness never balloons
87
- // the system prompt. Opt out via harness.json `injection.enabled: false`.
88
- const { injection } = await loadConfig();
104
+ });
105
+
106
+ pi.on("context_with_system", async (event, ctx) => {
107
+ // NOTE: injection.enabled:false inside the config means LEGACY selection
108
+ // (all items, store order) — handled by selectForInjection, not an off
109
+ // switch here. There is no "no injection" render path once state exists.
110
+ const { injection, virtualBinding } = await loadConfig();
111
+ // Prefer the live resolution (mid-run responses can change the physical
112
+ // key under the "physical" policy); fall back to the cached agent-start
113
+ // key when the model is momentarily unknown.
114
+ const key =
115
+ resolveBindingKey(ctx.model, branchEntries(ctx), virtualBinding) ??
116
+ getActiveModelKey();
89
117
  const block = renderHarnessBlock(key, injection);
90
- if (!block) return;
91
- return { systemPrompt: event.systemPrompt + "\n" + block };
118
+ if (!block) return; // nothing to inject — do not own the transcript
119
+ const messages = [...event.messages];
120
+ const sys = messages[0] as { role?: string; content?: string | Array<{ type: string; text?: string }> } | undefined;
121
+ if (!sys || sys.role !== "system" || sys.content === undefined) return;
122
+ messages[0] = { ...sys, content: appendToSystemContent(sys.content, block) } as (typeof messages)[number];
123
+ return { messages };
92
124
  });
93
125
  }
package/src/proposer.ts CHANGED
@@ -29,6 +29,7 @@
29
29
 
30
30
  import type { Delta, HarnessItem, HarnessState } from "./types.js";
31
31
  import type { HarnessConfig } from "./config.js";
32
+ import type { ClassifyFn } from "./classify.js";
32
33
 
33
34
  /** Options for the one-shot model completion injected into ProposeInput. */
34
35
  export interface CompleteOptions {
@@ -81,6 +82,12 @@ export interface ProposeInput {
81
82
  * and steering proposers ignore it. Undefined when no model is resolvable, so
82
83
  * a model proposer can no-op (and record an audited failure) rather than throw. */
83
84
  complete?: (prompt: string, opts?: CompleteOptions) => Promise<CompleteResult>;
85
+ /** Batched yes/no classification (pi ≥ 0.99 classifier models), injected by
86
+ * runRefine when `classifier.model` is configured and resolvable. The shipped
87
+ * `dedupe` proposer uses it as a precision filter on candidate merge pairs;
88
+ * other proposers may use it for cheap gating decisions. Undefined when no
89
+ * classifier is available — consumers must degrade gracefully. */
90
+ classify?: ClassifyFn;
84
91
  }
85
92
 
86
93
  /** A single delta plus a human-readable reason for the audit trail. */
@@ -227,43 +234,33 @@ export function unionEvidence(sources: string[]): string {
227
234
  }
228
235
 
229
236
  /**
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.
243
- *
244
- * Greedy and contradiction-free: items are considered in importance-descending
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.
237
+ * Pure dedupe grouping: the keeper/absorbed structure behind planDedupe.
238
+ * Exported (0.12.0) so the classifier-confirmed path can filter PAIRS before
239
+ * deltas are built — an async confirm cannot go through the sync `similarity`
240
+ * seam. See planDedupe for the matching semantics.
249
241
  */
250
- export function planDedupe(state: HarnessState, opts: DedupeOptions = DEFAULT_DEDUPE): ProposedDelta[] {
242
+ export interface DedupeGroup {
243
+ keeper: HarnessItem;
244
+ absorbed: Array<{ dup: HarnessItem; overlap: number }>;
245
+ }
246
+
247
+ export function planDedupePairs(state: HarnessState, opts: DedupeOptions = DEFAULT_DEDUPE): DedupeGroup[] {
251
248
  const similarity = opts.similarity ?? tokenOverlap;
252
249
  const ordered = state.items
253
250
  .filter((i) => i.active)
254
251
  .sort((a, b) => b.importance - a.importance);
255
- const keepers: Array<{ item: HarnessItem; absorbed: Array<{ dup: HarnessItem; overlap: number }> }> = [];
252
+ const keepers: DedupeGroup[] = [];
256
253
  for (const cand of ordered) {
257
- let target: (typeof keepers)[number] | undefined;
254
+ let target: DedupeGroup | undefined;
258
255
  let best = 0;
259
256
  for (const k of keepers) {
260
- if (k.item.kind !== cand.kind) continue;
257
+ if (k.keeper.kind !== cand.kind) continue;
261
258
  // Per-model isolation: each model keeps its own copy, so two near-
262
259
  // identical items bound to different models are NOT duplicates.
263
- if (k.item.ownerModel !== cand.ownerModel) continue;
260
+ if (k.keeper.ownerModel !== cand.ownerModel) continue;
264
261
  // 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));
262
+ if (durableLayer(k.keeper) !== durableLayer(cand)) continue;
263
+ const { score: sim, abstain } = asSimilarity(similarity(k.keeper.content, cand.content));
267
264
  // Abstain = keep both: an uncertain engine must never cause a merge.
268
265
  if (!abstain && sim >= opts.threshold && sim > best) {
269
266
  best = sim;
@@ -271,21 +268,25 @@ export function planDedupe(state: HarnessState, opts: DedupeOptions = DEFAULT_DE
271
268
  }
272
269
  }
273
270
  if (target) target.absorbed.push({ dup: cand, overlap: best });
274
- else keepers.push({ item: cand, absorbed: [] });
271
+ else keepers.push({ keeper: cand, absorbed: [] });
275
272
  }
273
+ return keepers;
274
+ }
276
275
 
276
+ /** Build the update/delete deltas from keeper groups (see planDedupe). */
277
+ export function deltasFromGroups(groups: DedupeGroup[], opts: DedupeOptions = DEFAULT_DEDUPE): ProposedDelta[] {
277
278
  const updates: ProposedDelta[] = [];
278
279
  const deletes: ProposedDelta[] = [];
279
- for (const k of keepers) {
280
+ for (const k of groups) {
280
281
  if (k.absorbed.length === 0) continue;
281
282
  if (opts.merge) {
282
- const merged = unionEvidence([k.item.evidence, ...k.absorbed.map((a) => a.dup.evidence)]);
283
+ const merged = unionEvidence([k.keeper.evidence, ...k.absorbed.map((a) => a.dup.evidence)]);
283
284
  // Skip the no-op: identical evidence → the merge degenerates to a plain
284
285
  // delete for this pair (no update delta).
285
- if (merged !== k.item.evidence) {
286
+ if (merged !== k.keeper.evidence) {
286
287
  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).`,
288
+ delta: { op: "update", id: k.keeper.id, evidence: merged },
289
+ rationale: `dedupe: merged ${k.absorbed.length} duplicate(s) (${k.absorbed.map((a) => a.dup.id).join(", ")}) into ${k.keeper.id}; evidence unioned (${k.absorbed.length + 1} sources).`,
289
290
  });
290
291
  }
291
292
  }
@@ -295,23 +296,137 @@ export function planDedupe(state: HarnessState, opts: DedupeOptions = DEFAULT_DE
295
296
  op: "delete",
296
297
  id: dup.id,
297
298
  reason: opts.merge
298
- ? `merged into ${k.item.id} (overlap ${overlap.toFixed(2)})`
299
- : `near-duplicate of ${k.item.id} (overlap ${overlap.toFixed(2)})`,
299
+ ? `merged into ${k.keeper.id} (overlap ${overlap.toFixed(2)})`
300
+ : `near-duplicate of ${k.keeper.id} (overlap ${overlap.toFixed(2)})`,
300
301
  },
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
+ rationale: `dedupe: "${truncate(dup.content)}" ≈ keeper "${truncate(k.keeper.content)}" (Jaccard ${overlap.toFixed(2)}); ${opts.merge ? `merged into ${k.keeper.id}.` : `kept higher-importance ${k.keeper.id}.`}`,
302
303
  });
303
304
  }
304
305
  }
305
306
  return [...updates, ...deletes];
306
307
  }
307
308
 
309
+ /**
310
+ * Pure dedupe planner: merges (or, with merge:false, drops) near-duplicate
311
+ * ACTIVE items. Two items are duplicates iff they share the key fields —
312
+ * kind, ownerModel, durable layer (scope+project) — and their content
313
+ * similarity is >= opts.threshold. A similarity engine MAY abstain per pair
314
+ * (see SimilarityResult): an abstaining pair is treated as NOT duplicates.
315
+ *
316
+ * Merge semantics (ACE-style deterministic merge, never a prose rewrite):
317
+ * the higher-importance item is the KEEPER and keeps its content verbatim;
318
+ * each keeper that absorbed duplicates gets ONE update whose evidence is the
319
+ * line-wise union of its own and every absorbed duplicate's evidence (one
320
+ * final union — applyOne replaces evidence wholesale, so per-duplicate
321
+ * updates would clobber each other); each absorbed duplicate is deleted with
322
+ * a reason naming its keeper. Updates are emitted before deletes.
323
+ *
324
+ * Greedy and contradiction-free: items are considered in importance-descending
325
+ * order (ties keep store order); the first item is always a keeper, and each
326
+ * later item is compared only against keepers, so a keeper is never
327
+ * subsequently dropped. A duplicate matching several keepers joins the
328
+ * best-overlap one.
329
+ */
330
+ export function planDedupe(state: HarnessState, opts: DedupeOptions = DEFAULT_DEDUPE): ProposedDelta[] {
331
+ return deltasFromGroups(planDedupePairs(state, opts), opts);
332
+ }
333
+
334
+ // ---- classifier confirmation (pi ≥ 0.99, opt-in via classifier.model) ------
335
+
336
+ /** Recall widening for the classifier pass: candidates are gathered at
337
+ * threshold - CLASSIFIER_RECALL_DROP (floored) so the cheap yes/no filter,
338
+ * not the token overlap, is the precision gate. */
339
+ export const CLASSIFIER_RECALL_DROP = 0.15;
340
+ export const CLASSIFIER_RECALL_FLOOR = 0.35;
341
+ /** Bound on pairs per classify call (question count), keeping the call cheap. */
342
+ export const CLASSIFIER_MAX_PAIRS = 20;
343
+
344
+ /** Build the batched pair-confirmation request. Pure — unit-testable. */
345
+ export function buildPairQuestions(
346
+ pairs: Array<{ keeper: HarnessItem; dup: HarnessItem }>,
347
+ ): { state: Record<string, unknown>; questions: Record<string, { instructions: string; trueCriteria: string; falseCriteria: string }> } {
348
+ const questions: Record<string, { instructions: string; trueCriteria: string; falseCriteria: string }> = {};
349
+ const state = {
350
+ pairs: pairs.map((p, i) => ({ index: i, keeper: p.keeper.content, duplicate: p.dup.content })),
351
+ };
352
+ for (let i = 0; i < pairs.length; i++) {
353
+ questions[`p${i}`] = {
354
+ instructions:
355
+ "You are confirming dedupe candidates for a coding agent's self-improvement store. Each pair holds two items (a keeper and a candidate duplicate). Do the two items state the SAME durable lesson, fact, or instruction — such that keeping both is redundant?",
356
+ trueCriteria: "The two items express the same reusable information; one merged copy loses nothing.",
357
+ falseCriteria:
358
+ "The items differ in scope, condition, or meaning — both must be kept, or the token overlap is only superficial.",
359
+ };
360
+ }
361
+ return { state, questions };
362
+ }
363
+
364
+ /** Filter keeper groups down to classifier-confirmed pairs. Pairs beyond the
365
+ * question cap and pairs with missing answers stay OUT (conservative: an
366
+ * unanswered pair is never merged). Answers are keyed p0..pN in the same
367
+ * groups.flatMap(absorbed) order buildPairQuestions enumerated. */
368
+ export function confirmGroups(
369
+ groups: DedupeGroup[],
370
+ answers: Record<string, { value: boolean; confidence?: number }>,
371
+ cap: number = CLASSIFIER_MAX_PAIRS,
372
+ ): DedupeGroup[] {
373
+ let i = 0;
374
+ return groups
375
+ .map((g) => ({
376
+ keeper: g.keeper,
377
+ absorbed: g.absorbed.filter(() => i < cap && answers[`p${i++}`]?.value === true),
378
+ }))
379
+ .filter((g) => g.absorbed.length > 0);
380
+ }
381
+
308
382
  /** Rule-based proposer wrapping planDedupe with the configured (or default)
309
- * options — merges near-duplicates; `merge: false` restores delete-only. */
383
+ * options — merges near-duplicates; `merge: false` restores delete-only.
384
+ * When a classifier is injected (harness.json `classifier.model` resolved),
385
+ * candidate pairs are recall-widened and then confirmed by ONE batched
386
+ * yes/no classify call: only confirmed pairs merge. A classifier error falls
387
+ * back to the pure rule-based plan — the classifier can narrow, never block. */
310
388
  export const dedupeProposer: DeltaProposer = {
311
389
  name: "dedupe",
312
- async propose({ state, config }): Promise<ProposeResult> {
313
- const deltas = planDedupe(state, config?.dedupe ?? DEFAULT_DEDUPE);
314
- return deltas.length ? { deltas } : {};
390
+ async propose({ state, config, classify }): Promise<ProposeResult> {
391
+ const opts = config?.dedupe ?? DEFAULT_DEDUPE;
392
+ if (!classify) {
393
+ const deltas = planDedupe(state, opts);
394
+ return deltas.length ? { deltas } : {};
395
+ }
396
+ const started = Date.now();
397
+ const recall: DedupeOptions = {
398
+ ...opts,
399
+ threshold: Math.max(CLASSIFIER_RECALL_FLOOR, opts.threshold - CLASSIFIER_RECALL_DROP),
400
+ };
401
+ const groups = planDedupePairs(state, recall);
402
+ const pairs = groups.flatMap((g) => g.absorbed.map((a) => ({ keeper: g.keeper, dup: a.dup })));
403
+ if (pairs.length === 0) return {};
404
+ const capped = pairs.slice(0, CLASSIFIER_MAX_PAIRS);
405
+ const res = await classify(buildPairQuestions(capped));
406
+ if (!res.ok) {
407
+ // Classifier failed → plain rule-based pass at the configured threshold.
408
+ const deltas = planDedupe(state, opts);
409
+ return {
410
+ ...(deltas.length ? { deltas } : {}),
411
+ modelCall: {
412
+ ok: false,
413
+ latencyMs: Date.now() - started,
414
+ ...(res.error ? { error: res.error } : {}),
415
+ ...(res.model ? { model: res.model } : {}),
416
+ },
417
+ };
418
+ }
419
+ const confirmed = confirmGroups(groups, res.answers);
420
+ const deltas = deltasFromGroups(confirmed, opts);
421
+ return {
422
+ ...(deltas.length ? { deltas } : {}),
423
+ modelCall: {
424
+ ok: true,
425
+ latencyMs: Date.now() - started,
426
+ ...(res.model ? { model: res.model } : {}),
427
+ ...(res.usage ? { inputTokens: res.usage.input, outputTokens: res.usage.output } : {}),
428
+ },
429
+ };
315
430
  },
316
431
  };
317
432
 
package/src/refine.ts CHANGED
@@ -19,9 +19,11 @@
19
19
 
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
- import { applyDeltas, DEFAULT_DURABLE_PATH, exportDurableLayers, modelKey, PROJECT_DURABLE_DIR, REFINE_ENTRY, snapshotState } from "./store.js";
22
+ import { applyDeltas, DEFAULT_DURABLE_PATH, exportDurableLayers, PROJECT_DURABLE_DIR, REFINE_ENTRY, snapshotState } from "./store.js";
23
23
  import { getProposer, DEFAULT_DEDUPE } from "./proposer.js";
24
24
  import type { CompleteOptions, CompleteResult } from "./proposer.js";
25
+ import { buildClassify } from "./classify.js";
26
+ import { resolveBindingKey, branchEntries } from "./vmodel.js";
25
27
  import type { AppliedDelta } from "./types.js";
26
28
  import { loadConfig, projectSlug } from "./config.js";
27
29
 
@@ -67,6 +69,13 @@ export interface RefineOptions {
67
69
  * (overrides `dedupe.threshold` in harness.json). Affects the rule-based
68
70
  * `dedupe` proposer; ignored by others. */
69
71
  threshold?: number;
72
+ /** How a steering message is delivered (pi ≥ 0.87 actionable boundaries).
73
+ * "steer" (default): send it as a user message via sendUserMessage — the
74
+ * manual /refine path. "boundary": do NOT send; return it in RefineResult
75
+ * so a turn_end caller can persist it as a structural entry and guarantee
76
+ * one next provider request with `{ entries, continue: true }` — no
77
+ * steering queue, no follow-up scheduling side effects. */
78
+ delivery?: "steer" | "boundary";
70
79
  }
71
80
 
72
81
  export interface RefineResult {
@@ -78,6 +87,10 @@ export interface RefineResult {
78
87
  proposer: string;
79
88
  /** Deltas the proposer applied directly (0 for the steering path). */
80
89
  applied: number;
90
+ /** Set ONLY with delivery "boundary" when the proposer returned a steering
91
+ * message: the message was NOT sent — the caller owns persisting it (e.g.
92
+ * as a turn_end boundary draft) and ensuring the continuation. */
93
+ steeringMessage?: string;
81
94
  }
82
95
 
83
96
  /**
@@ -109,6 +122,9 @@ export async function runRefine(
109
122
  // Inject a one-shot model completion (built from ctx) so dedicated-model
110
123
  // proposers can make a hidden LLM call. Undefined when no model is resolvable.
111
124
  const complete = buildComplete(ctx);
125
+ // Inject the classifier adapter when `classifier.model` is configured and
126
+ // resolvable (pi ≥ 0.99) — the dedupe proposer uses it to confirm merges.
127
+ const classify = buildClassify(ctx, config.classifier?.model);
112
128
  // Pass a defensive copy: proposers are a public extension point and must not
113
129
  // be able to mutate the live store outside applyDeltas (no audit / no rollback).
114
130
  const result = await proposer.propose({
@@ -117,13 +133,14 @@ export async function runRefine(
117
133
  lookback,
118
134
  config: proposerConfig,
119
135
  ...(complete ? { complete } : {}),
136
+ ...(classify ? { classify } : {}),
120
137
  });
121
138
  const proposedDeltas = result.deltas ?? [];
122
139
  // Stamp direct-apply create deltas with the active model so proposer-authored
123
140
  // notes bind to the model driving the turn (the steering path is stamped in
124
141
  // the harness_mutate tool instead). Orphans result only when the model is
125
142
  // unknown; before_agent_start adopts them on the next turn.
126
- const ownerKey = modelKey(ctx.model);
143
+ const ownerKey = resolveBindingKey(ctx.model, branchEntries(ctx), config.virtualBinding);
127
144
 
128
145
  let applied = 0;
129
146
  let appliedDeltas: AppliedDelta[] = [];
@@ -194,7 +211,12 @@ export async function runRefine(
194
211
  }
195
212
 
196
213
  if (result.steeringMessage) {
197
- pi.sendUserMessage(result.steeringMessage);
214
+ if (options.delivery === "boundary") {
215
+ // Caller owns delivery: persist as a structural boundary entry and
216
+ // continue — see auto-refine.ts. Returned via RefineResult.
217
+ } else {
218
+ pi.sendUserMessage(result.steeringMessage);
219
+ }
198
220
  }
199
221
  ctx.ui.setStatus("harness", undefined);
200
222
  return {
@@ -204,6 +226,7 @@ export async function runRefine(
204
226
  source,
205
227
  proposer: proposer.name,
206
228
  applied,
229
+ ...(options.delivery === "boundary" && result.steeringMessage ? { steeringMessage: result.steeringMessage } : {}),
207
230
  };
208
231
  }
209
232
 
@@ -237,7 +260,7 @@ function parseArgs(args: string): {
237
260
  return { lookback, commit, proposer, threshold, thresholdInvalid };
238
261
  }
239
262
 
240
- function gatherEvidence(ctx: ExtensionContext, lookback: number): string {
263
+ export function gatherEvidence(ctx: ExtensionContext, lookback: number): string {
241
264
  const entries = ctx.sessionManager.getBranch() as AnyEntry[];
242
265
  const messages = entries.filter((e) => e.type === "message" && e.message);
243
266
  const recent = messages.slice(-lookback * 2); // ~2 entries per turn (user+assistant)
package/src/tools.ts CHANGED
@@ -55,6 +55,34 @@ const DeleteShape = Type.Object({
55
55
 
56
56
  const DeltaShape = Type.Union([CreateShape, UpdateShape, DeleteShape]);
57
57
 
58
+ // outputSchema (pi ≥ 0.99): codemode scripts and ctx.executeTool() callers get
59
+ // structuredContent instead of the text; the model still sees `content`.
60
+ const ItemSchema = Type.Object({
61
+ id: Type.String(),
62
+ kind: StringEnum(KIND_VALUES),
63
+ content: Type.String(),
64
+ evidence: Type.String(),
65
+ importance: Type.Number(),
66
+ active: Type.Boolean(),
67
+ ownerModel: Type.String(),
68
+ scope: Type.Optional(StringEnum(["global", "project"])),
69
+ project: Type.Optional(Type.String()),
70
+ createdAt: Type.Number(),
71
+ updatedAt: Type.Number(),
72
+ });
73
+
74
+ const ListOutputSchema = Type.Object({
75
+ count: Type.Number(),
76
+ items: Type.Array(ItemSchema),
77
+ });
78
+
79
+ const MutateOutputSchema = Type.Object({
80
+ applied: Type.Number(),
81
+ created: Type.Number(),
82
+ updated: Type.Number(),
83
+ deleted: Type.Number(),
84
+ });
85
+
58
86
  export function registerTools(pi: ExtensionAPI): void {
59
87
  pi.registerTool({
60
88
  name: "harness_list",
@@ -74,6 +102,7 @@ export function registerTools(pi: ExtensionAPI): void {
74
102
  }),
75
103
  ),
76
104
  }),
105
+ outputSchema: ListOutputSchema,
77
106
  async execute(_toolCallId, params) {
78
107
  const kind = params.kind as ComponentKind | undefined;
79
108
  const modelFilter = params.model as string | undefined;
@@ -103,6 +132,22 @@ export function registerTools(pi: ExtensionAPI): void {
103
132
  },
104
133
  ],
105
134
  details: { count: items.length, items },
135
+ structuredContent: {
136
+ count: items.length,
137
+ items: items.map((i) => ({
138
+ id: i.id,
139
+ kind: i.kind,
140
+ content: i.content,
141
+ evidence: i.evidence,
142
+ importance: i.importance,
143
+ active: i.active,
144
+ ownerModel: i.ownerModel,
145
+ ...(i.scope ? { scope: i.scope } : {}),
146
+ ...(i.project ? { project: i.project } : {}),
147
+ createdAt: i.createdAt,
148
+ updatedAt: i.updatedAt,
149
+ })),
150
+ },
106
151
  };
107
152
  },
108
153
  });
@@ -119,6 +164,7 @@ export function registerTools(pi: ExtensionAPI): void {
119
164
  parameters: Type.Object({
120
165
  deltas: Type.Array(DeltaShape, { minItems: 1, maxItems: 20 }),
121
166
  }),
167
+ outputSchema: MutateOutputSchema,
122
168
  async execute(_toolCallId, params) {
123
169
  const incoming = params.deltas as Delta[];
124
170
  // The active model is the ACTOR: creates bind to it, and update/delete are
@@ -133,9 +179,13 @@ export function registerTools(pi: ExtensionAPI): void {
133
179
  getActiveModelKey(),
134
180
  );
135
181
  const summary = summarize(applied);
182
+ const created = applied.filter((a) => a.op === "create").length;
183
+ const updated = applied.filter((a) => a.op === "update").length;
184
+ const deleted = applied.filter((a) => a.op === "delete").length;
136
185
  return {
137
186
  content: [{ type: "text", text: summary }],
138
187
  details: { applied, version: getState().items.length },
188
+ structuredContent: { applied: applied.length, created, updated, deleted },
139
189
  };
140
190
  },
141
191
  });
package/src/vmodel.ts ADDED
@@ -0,0 +1,88 @@
1
+ // Virtual-model binding policy (pi ≥ 0.99). A virtual model (pi.registerVirtualModel,
2
+ // e.g. a Jev router) appears as its own catalog entry with api "pi-virtual"; ctx.model
3
+ // names the VIRTUAL model, while each assistant response names the PHYSICAL model it
4
+ // was routed to. Strict per-model isolation therefore has two defensible keys:
5
+ //
6
+ // "virtual" (default, pre-0.12 behavior): key = the selected model's provider/id,
7
+ // virtual or physical. One harness per selection; switching to a router
8
+ // starts a blank harness even if it always routes to the same model.
9
+ // "physical" (opt-in): when the selected model is virtual, key = the physical model
10
+ // of the latest successful response on the branch (pi's own
11
+ // findLatestResponse semantics: failed/aborted responses are skipped).
12
+ // Items accumulated under the physical model keep injecting through a
13
+ // router; when the router switches physical models mid-conversation the
14
+ // key changes and isolation applies exactly as after a manual switch.
15
+ // Before the first response on a branch there is no physical key yet:
16
+ // the binding resolves to undefined, so creates become orphans and are
17
+ // adopted by the resolved key on the next contact (the existing
18
+ // adoption machinery — no special-casing needed).
19
+ //
20
+ // Kept dependency-free and local: pi exports the virtual-model constants from its
21
+ // package entry only partially, and these helpers are tiny (the api id is a stable
22
+ // public constant, "pi-virtual").
23
+
24
+ import type { VirtualBinding } from "./config.js";
25
+
26
+ /** pi's public constant for virtual catalog entries (VIRTUAL_MODEL_API). */
27
+ export const VIRTUAL_MODEL_API = "pi-virtual";
28
+
29
+ /** Minimal structural shapes so tests can build fakes without pi types. */
30
+ interface ModelLike {
31
+ provider?: string;
32
+ id?: string;
33
+ api?: string;
34
+ }
35
+
36
+ interface AssistantLike {
37
+ role?: string;
38
+ provider?: string;
39
+ model?: string;
40
+ }
41
+
42
+ /** Whether a model object names a virtual (router) catalog entry. */
43
+ export function isVirtualModel(m?: ModelLike): boolean {
44
+ return m?.api === VIRTUAL_MODEL_API;
45
+ }
46
+
47
+ /**
48
+ * Key of the physical model behind the latest SUCCESSFUL response on a branch.
49
+ * Scans message entries backwards; failed/aborted responses do not produce
50
+ * assistant message entries that reach the branch tail, matching pi's
51
+ * findLatestResponse contract closely enough for binding purposes.
52
+ * Returns undefined when no assistant response exists yet.
53
+ */
54
+ export function physicalKeyFromBranch(entries: Iterable<unknown>): string | undefined {
55
+ const msgs: AssistantLike[] = [];
56
+ for (const e of entries as Array<{ type?: string; message?: AssistantLike }>) {
57
+ if (e?.type === "message" && e.message?.role === "assistant") msgs.push(e.message);
58
+ }
59
+ for (let i = msgs.length - 1; i >= 0; i--) {
60
+ const m = msgs[i]!;
61
+ if (m.provider && m.model) return `${m.provider}/${m.model}`;
62
+ }
63
+ return undefined;
64
+ }
65
+
66
+ /** Canonical owner key, mirroring store.modelKey with optional-field guards. */
67
+ function plainKey(m?: ModelLike): string | undefined {
68
+ return m?.provider && m?.id ? `${m.provider}/${m.id}` : undefined;
69
+ }
70
+
71
+ /** Defensive branch access: test fakes build minimal ctx without a session
72
+ * manager; production always has one. */
73
+ export function branchEntries(ctx: { sessionManager?: { getBranch?: () => unknown[] } }): unknown[] {
74
+ return ctx.sessionManager?.getBranch?.() ?? [];
75
+ }
76
+
77
+ /**
78
+ * Resolve the harness binding key under the configured policy. `entries` is the
79
+ * session branch (used only for the "physical" policy); pass [] when unknown.
80
+ */
81
+ export function resolveBindingKey(
82
+ model: ModelLike | undefined,
83
+ entries: Iterable<unknown>,
84
+ policy: VirtualBinding = "virtual",
85
+ ): string | undefined {
86
+ if (policy !== "physical" || !isVirtualModel(model)) return plainKey(model);
87
+ return physicalKeyFromBranch(entries);
88
+ }