pi-continual-harness 0.11.0 → 0.13.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 +49 -6
- package/package.json +5 -5
- package/src/auto-refine.ts +111 -5
- package/src/classify.ts +220 -0
- package/src/config.ts +34 -1
- package/src/index.ts +33 -0
- package/src/inject.ts +51 -19
- package/src/proposer.ts +154 -39
- package/src/refine.ts +27 -4
- package/src/tools.ts +50 -0
- package/src/vmodel.ts +88 -0
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.
|
|
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,30 @@ 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. Each new boundary also omits
|
|
444
|
+
prior auto-refine request drafts from future provider context (pi ≥ 0.87
|
|
445
|
+
context edits; `autoRefine.omitStaleDrafts: false` to keep them).
|
|
403
446
|
|
|
404
447
|
Open extension points (see `docs/ROADMAP.md`):
|
|
405
448
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-continual-harness",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.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.
|
|
39
|
-
"@earendil-works/pi-ai": ">=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.
|
|
44
|
-
"@earendil-works/pi-ai": ">=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",
|
package/src/auto-refine.ts
CHANGED
|
@@ -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
|
-
//
|
|
13
|
-
//
|
|
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,109 @@ 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
|
+
|
|
78
|
+
/** Structural slice of pi's ProjectedSessionEntry (sourceEntry + messages). */
|
|
79
|
+
export interface ProjectedLike {
|
|
80
|
+
sourceEntry?: { type?: string; customType?: string; id?: string };
|
|
81
|
+
messages?: unknown[];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** A context_edit boundary draft (pi ≥ 0.87): omit one entry from future
|
|
85
|
+
* provider context — raw history, usage, and UI history stay untouched. */
|
|
86
|
+
export interface OmissionDraft {
|
|
87
|
+
type: "context_edit";
|
|
88
|
+
targetId: string;
|
|
89
|
+
replacement: null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Context-edit drafts omitting every LIVE prior auto-refine request from
|
|
94
|
+
* future provider context (pi ≥ 0.87 ContextEditEntry). Invariant: at most
|
|
95
|
+
* ONE auto-refine request is ever model-visible — the newest. Already-omitted
|
|
96
|
+
* drafts surface in the projection with empty `messages` and are skipped, so
|
|
97
|
+
* the operation is idempotent and never stacks redundant edits. Raw history
|
|
98
|
+
* (and the HTML export) keeps every draft: /tree rollback still applies.
|
|
99
|
+
*/
|
|
100
|
+
export function buildOmissionDrafts(projected: Iterable<ProjectedLike>): OmissionDraft[] {
|
|
101
|
+
const drafts: OmissionDraft[] = [];
|
|
102
|
+
for (const p of projected) {
|
|
103
|
+
const src = p.sourceEntry;
|
|
104
|
+
if (src?.type !== "custom_message" || src.customType !== AUTO_REFINE_ENTRY) continue;
|
|
105
|
+
if ((p.messages?.length ?? 0) === 0) continue; // already omitted → skip
|
|
106
|
+
if (typeof src.id === "string") drafts.push({ type: "context_edit", targetId: src.id, replacement: null });
|
|
107
|
+
}
|
|
108
|
+
return drafts;
|
|
109
|
+
}
|
|
110
|
+
|
|
48
111
|
/** Subscribe to turn_end and run /refine on the configured cadence. */
|
|
49
112
|
export function registerAutoRefine(pi: ExtensionAPI): void {
|
|
50
113
|
pi.on("turn_end", async (event, ctx) => {
|
|
51
114
|
const config = await loadConfig();
|
|
52
115
|
if (!evaluateAutoRefine(config, event.turnIndex)) return;
|
|
53
116
|
const every = config.autoRefine?.everyTurns ?? DEFAULT_AUTO_EVERY_TURNS;
|
|
117
|
+
|
|
118
|
+
// Opt-in classifier gate: cheap yes/no before spending the refine.
|
|
119
|
+
if (config.autoRefine?.gate) {
|
|
120
|
+
const classify = buildClassify(ctx, config.classifier?.model);
|
|
121
|
+
if (classify) {
|
|
122
|
+
const evidence = gatherEvidence(ctx, DEFAULT_LOOKBACK_TURNS);
|
|
123
|
+
const res = await classify(buildGateRequest(evidence));
|
|
124
|
+
const decision = gateDecision(res);
|
|
125
|
+
if (!decision.proceed) {
|
|
126
|
+
ctx.ui.notify(`Auto-refine: skipped — ${decision.because}.`, "info");
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
ctx.ui.notify(`Auto-refine: gate passed (${decision.because}).`, "info");
|
|
130
|
+
}
|
|
131
|
+
// No resolvable classifier → plain cadence behavior (documented soft-fail).
|
|
132
|
+
}
|
|
133
|
+
|
|
54
134
|
ctx.ui.notify(`Auto-refine: running /refine (every ${every} turns, opt-in).`, "info");
|
|
55
135
|
try {
|
|
56
|
-
await runRefine(
|
|
136
|
+
const result = await runRefine(
|
|
57
137
|
pi,
|
|
58
138
|
ctx,
|
|
59
|
-
{
|
|
139
|
+
{
|
|
140
|
+
commit: config.autoRefine?.commit ?? false,
|
|
141
|
+
...(config.proposer ? { proposer: config.proposer } : {}),
|
|
142
|
+
delivery: "boundary",
|
|
143
|
+
},
|
|
60
144
|
"auto",
|
|
61
145
|
);
|
|
146
|
+
// Boundary delivery: persist the steering message as a structural entry
|
|
147
|
+
// and guarantee one next provider request. Only when the boundary CAN
|
|
148
|
+
// continue — otherwise fall back to the legacy steering send.
|
|
149
|
+
if (result.steeringMessage) {
|
|
150
|
+
if (event.context?.canContinue === false) {
|
|
151
|
+
pi.sendUserMessage(result.steeringMessage);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
// Hygiene (pi ≥ 0.87 context edits): omit every LIVE prior auto-refine
|
|
155
|
+
// request from future provider context, so at most one draft (the new
|
|
156
|
+
// one) is ever model-visible. Already-omitted drafts are skipped by
|
|
157
|
+
// buildOmissionDrafts; raw history and /tree rollback are untouched.
|
|
158
|
+
// Kill-switch: autoRefine.omitStaleDrafts: false.
|
|
159
|
+
const omissions =
|
|
160
|
+
config.autoRefine?.omitStaleDrafts === false
|
|
161
|
+
? []
|
|
162
|
+
: buildOmissionDrafts(event.context?.contextEntries ?? []);
|
|
163
|
+
return {
|
|
164
|
+
entries: [...event.entries, ...omissions, buildRefineDraft(result.steeringMessage)],
|
|
165
|
+
continue: true,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
62
168
|
} catch (err) {
|
|
63
169
|
ctx.ui.notify(`Auto-refine failed: ${(err as Error).message}`, "error");
|
|
64
170
|
}
|
package/src/classify.ts
ADDED
|
@@ -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,26 @@ 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
|
+
/** Boundary-delivery hygiene (pi ≥ 0.87 context edits, default true): omit
|
|
66
|
+
* prior auto-refine request drafts from future provider context so at most
|
|
67
|
+
* one (the newest) is ever model-visible. Raw history and /tree rollback
|
|
68
|
+
* are untouched; set false to keep every draft in context. */
|
|
69
|
+
omitStaleDrafts?: boolean;
|
|
70
|
+
};
|
|
71
|
+
/** Classifier model for the cheap gated paths (pi ≥ 0.99): "provider/id" or
|
|
72
|
+
* a bare id of a classifier catalog entry (e.g. "typesafe/jev-latest" or a
|
|
73
|
+
* local llama.cpp classifier). When unset or unresolvable, every classifier
|
|
74
|
+
* feature degrades to its no-classifier behavior. */
|
|
75
|
+
classifier?: {
|
|
76
|
+
model?: string | undefined;
|
|
54
77
|
};
|
|
78
|
+
/** Virtual-model binding policy (pi ≥ 0.99). Default "virtual". */
|
|
79
|
+
virtualBinding?: VirtualBinding;
|
|
55
80
|
/** Delta proposer name (see proposer.ts registry). Defaults to "steering". */
|
|
56
81
|
proposer?: string;
|
|
57
82
|
/** Opt-in turn_end outcome loop: promote importance of items the agent
|
|
@@ -72,7 +97,9 @@ export const DEFAULT_CONFIG: HarnessConfig = {
|
|
|
72
97
|
durableScope: "global",
|
|
73
98
|
autoImport: false,
|
|
74
99
|
remindRefine: { enabled: false, everyTurns: DEFAULT_EVERY_TURNS },
|
|
75
|
-
autoRefine: { enabled: false, everyTurns: DEFAULT_AUTO_EVERY_TURNS, commit: false },
|
|
100
|
+
autoRefine: { enabled: false, everyTurns: DEFAULT_AUTO_EVERY_TURNS, commit: false, gate: false, omitStaleDrafts: true },
|
|
101
|
+
classifier: { model: undefined },
|
|
102
|
+
virtualBinding: "virtual",
|
|
76
103
|
proposer: "steering",
|
|
77
104
|
outcomeImportance: { enabled: false, bump: DEFAULT_REF_BUMP },
|
|
78
105
|
// Dedupe (merge-capable): merge ON at the historical 0.6 threshold — a
|
|
@@ -102,7 +129,13 @@ function mergeConfig(over: Partial<HarnessConfig>): HarnessConfig {
|
|
|
102
129
|
enabled: over.autoRefine?.enabled ?? false,
|
|
103
130
|
everyTurns: over.autoRefine?.everyTurns ?? DEFAULT_AUTO_EVERY_TURNS,
|
|
104
131
|
commit: over.autoRefine?.commit ?? false,
|
|
132
|
+
gate: over.autoRefine?.gate === true,
|
|
133
|
+
omitStaleDrafts: over.autoRefine?.omitStaleDrafts !== false,
|
|
134
|
+
},
|
|
135
|
+
classifier: {
|
|
136
|
+
model: typeof over.classifier?.model === "string" && over.classifier.model ? over.classifier.model : undefined,
|
|
105
137
|
},
|
|
138
|
+
virtualBinding: over.virtualBinding === "physical" ? "physical" : "virtual",
|
|
106
139
|
proposer: over.proposer ?? "steering",
|
|
107
140
|
outcomeImportance: {
|
|
108
141
|
enabled: over.outcomeImportance?.enabled ?? false,
|
package/src/index.ts
CHANGED
|
@@ -117,3 +117,36 @@ 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";
|
|
142
|
+
|
|
143
|
+
// Auto-refine boundary helpers (pi ≥ 0.87, 0.12.0–0.13.0): the steering draft
|
|
144
|
+
// and the stale-draft context-edit omissions. Re-exported for tests and
|
|
145
|
+
// companion packages without reaching into internal paths.
|
|
146
|
+
export {
|
|
147
|
+
AUTO_REFINE_ENTRY,
|
|
148
|
+
buildOmissionDrafts,
|
|
149
|
+
buildRefineDraft,
|
|
150
|
+
type OmissionDraft,
|
|
151
|
+
type ProjectedLike,
|
|
152
|
+
} from "./auto-refine.js";
|
package/src/inject.ts
CHANGED
|
@@ -1,13 +1,19 @@
|
|
|
1
|
-
//
|
|
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
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
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,
|
|
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 (
|
|
75
|
-
|
|
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
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
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
|
|
231
|
-
*
|
|
232
|
-
*
|
|
233
|
-
*
|
|
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
|
|
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:
|
|
252
|
+
const keepers: DedupeGroup[] = [];
|
|
256
253
|
for (const cand of ordered) {
|
|
257
|
-
let target:
|
|
254
|
+
let target: DedupeGroup | undefined;
|
|
258
255
|
let best = 0;
|
|
259
256
|
for (const k of keepers) {
|
|
260
|
-
if (k.
|
|
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.
|
|
260
|
+
if (k.keeper.ownerModel !== cand.ownerModel) continue;
|
|
264
261
|
// Same durable layer only (see durableLayer).
|
|
265
|
-
if (durableLayer(k.
|
|
266
|
-
const { score: sim, abstain } = asSimilarity(similarity(k.
|
|
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({
|
|
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
|
|
280
|
+
for (const k of groups) {
|
|
280
281
|
if (k.absorbed.length === 0) continue;
|
|
281
282
|
if (opts.merge) {
|
|
282
|
-
const merged = unionEvidence([k.
|
|
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.
|
|
286
|
+
if (merged !== k.keeper.evidence) {
|
|
286
287
|
updates.push({
|
|
287
|
-
delta: { op: "update", id: k.
|
|
288
|
-
rationale: `dedupe: merged ${k.absorbed.length} duplicate(s) (${k.absorbed.map((a) => a.dup.id).join(", ")}) into ${k.
|
|
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.
|
|
299
|
-
: `near-duplicate of ${k.
|
|
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.
|
|
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
|
|
314
|
-
|
|
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,
|
|
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 =
|
|
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
|
-
|
|
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
|
+
}
|