pi-continual-harness 0.6.2 → 0.7.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 +58 -10
- package/package.json +1 -1
- package/src/harness.ts +26 -7
- package/src/index.ts +4 -1
- package/src/inject.ts +28 -5
- package/src/outcome.ts +9 -2
- package/src/proposer.ts +3 -0
- package/src/refine.ts +12 -2
- package/src/store.ts +99 -4
- package/src/tools.ts +34 -7
- package/src/types.ts +13 -0
package/README.md
CHANGED
|
@@ -64,7 +64,7 @@ Durable I/O (round-trip with pi-reflect):
|
|
|
64
64
|
/harness prune [--decay <days>] # drop items below the importance floor
|
|
65
65
|
/harness keep <id> # nudge importance up (+0.1)
|
|
66
66
|
/harness drop <id> # nudge importance down (−0.1)
|
|
67
|
-
/harness push-mem [--all|--kind <kind>] # persist active items to pi-mem (save_memory)
|
|
67
|
+
/harness push-mem [--all|--kind <kind>|--model <provider/id|active>] # persist active items to pi-mem (save_memory)
|
|
68
68
|
```
|
|
69
69
|
|
|
70
70
|
`import` reconciles the file into the live store: items whose id matches an
|
|
@@ -79,9 +79,12 @@ preserved). Point pi-reflect at the same file to refine it offline:
|
|
|
79
79
|
|
|
80
80
|
The model-facing tools:
|
|
81
81
|
|
|
82
|
-
- `harness_list
|
|
82
|
+
- `harness_list({ kind?, model? })` — read current state. `model` defaults to
|
|
83
|
+
the active model's items (what gets injected this turn); `"*"` returns every
|
|
84
|
+
model.
|
|
83
85
|
- `harness_mutate { deltas: [...] }` — apply a batch of `create` / `update` /
|
|
84
|
-
`delete` deltas. Every `create` requires `evidence`.
|
|
86
|
+
`delete` deltas. Every `create` requires `evidence`. New items are stamped
|
|
87
|
+
automatically with the active model (see [Model binding](#model-binding-per-model-isolation)).
|
|
85
88
|
|
|
86
89
|
Active items are injected into the system prompt each turn as a structured
|
|
87
90
|
block, appended to (never replacing) the base prompt.
|
|
@@ -100,9 +103,10 @@ pi install npm:pi-mem # optional companion
|
|
|
100
103
|
```
|
|
101
104
|
|
|
102
105
|
By default only **memory**-kind items are pushed (the clean 1:1 mapping); use
|
|
103
|
-
`--all` for every active item
|
|
104
|
-
kind
|
|
105
|
-
|
|
106
|
+
`--all` for every active item, `--kind prompt|skill|subagent` for a specific
|
|
107
|
+
kind, or `--model <provider/id|active>` to scope the push to one model's items
|
|
108
|
+
(`--model active` = the model driving the command). The harness store itself is
|
|
109
|
+
unchanged by a push — pi-mem gets a separate copy.
|
|
106
110
|
|
|
107
111
|
## Configuration
|
|
108
112
|
|
|
@@ -154,13 +158,52 @@ Optional config at `~/.pi/agent/harness.json` (missing or malformed → defaults
|
|
|
154
158
|
3. The agent calls the tools (steering path); each accepted delta updates the
|
|
155
159
|
in-memory state, which is snapshotted to the session via
|
|
156
160
|
`appendEntry("harness-state", ...)`. Direct-apply proposers snapshot the
|
|
157
|
-
same way.
|
|
161
|
+
same way. New items are stamped with the active model (see
|
|
162
|
+
[Model binding](#model-binding-per-model-isolation)).
|
|
158
163
|
4. Because pi's session tree branches at any entry, `/tree` navigation gives
|
|
159
164
|
rollback to any pre-refinement point for free — no bespoke snapshot system.
|
|
160
165
|
|
|
161
166
|
This reuses the existing agent loop (no nested/hidden model calls), is
|
|
162
167
|
model-agnostic, and keeps every delta visible and reviewable in the transcript.
|
|
163
168
|
|
|
169
|
+
## Model binding (per-model isolation)
|
|
170
|
+
|
|
171
|
+
Every item is bound to exactly one model as `ownerModel` (`"provider/id"`). An
|
|
172
|
+
item is **only injected for the model it belongs to** — so switching to a
|
|
173
|
+
brand-new model id starts from a **blank harness**, and one model's notes never
|
|
174
|
+
leak into another's context. Binding is at the *exact* model id (not family or
|
|
175
|
+
vendor): a new version id is a clean slate, by design.
|
|
176
|
+
|
|
177
|
+
How the binding is set and respected:
|
|
178
|
+
|
|
179
|
+
- **Created items are stamped automatically** with the model driving the turn.
|
|
180
|
+
The model-facing tools cannot read the active model, so `before_agent_start`
|
|
181
|
+
(which always fires first in a turn, with the model) caches it; `harness_mutate`
|
|
182
|
+
stamps creates from that cache, and direct-apply proposers stamp from their
|
|
183
|
+
`ctx.model`. You never name the model yourself.
|
|
184
|
+
- **Injection filters by owner.** Only items whose `ownerModel` matches the
|
|
185
|
+
active model are appended to the system prompt. An unknown model injects
|
|
186
|
+
nothing.
|
|
187
|
+
- **`harness_list` defaults to the active model** (pass `model: "*"` for every
|
|
188
|
+
model, or an explicit `"provider/id"`).
|
|
189
|
+
- **Orphan adoption.** Items with no owner — from a legacy session snapshot, an
|
|
190
|
+
old durable file, or created while the model was unknown — are adopted by the
|
|
191
|
+
active model on first contact (the next `before_agent_start`). This is the
|
|
192
|
+
migration path: existing harnesses transition cleanly with no manual steps,
|
|
193
|
+
and it's persisted as a normal `harness-state` entry (so `/tree` rollback
|
|
194
|
+
covers it).
|
|
195
|
+
- **Durable round-trip preserves owner.** `/harness export` tags each item with
|
|
196
|
+
`model: provider/id`; `/harness import` restores it. An item whose tag
|
|
197
|
+
pi-reflect stripped becomes an orphan and is adopted by the active model.
|
|
198
|
+
|
|
199
|
+
Manual commands (`export`, `import`, `keep`, `drop`, `prune`, `push-mem`,
|
|
200
|
+
`status`) operate on the **whole store** by design — they are explicit human
|
|
201
|
+
actions with full control. Isolation is enforced only where pollution would
|
|
202
|
+
leak automatically: injection, listing, create-stamping, and the outcome loop.
|
|
203
|
+
In particular, `/harness push-mem` pushes *every* model's active items into
|
|
204
|
+
pi-mem by default (which can yield near-duplicate memories across models); pass
|
|
205
|
+
`--model <provider/id|active>` to scope it to one model.
|
|
206
|
+
|
|
164
207
|
## Proposers
|
|
165
208
|
|
|
166
209
|
`/refine` is split into two stages: **propose** (given evidence + state, decide
|
|
@@ -186,7 +229,8 @@ available, `/refine` injects a one-shot `complete(prompt, opts?)` into
|
|
|
186
229
|
`ProposeInput` and records any `modelCall` telemetry a proposer returns
|
|
187
230
|
(model, tokens, latency, ok/error) in the `harness-refinement` audit entry —
|
|
188
231
|
so a companion package can ship a dedicated-model proposer whose spend stays
|
|
189
|
-
**audited, not hidden**.
|
|
232
|
+
**audited, not hidden**. One ships as a companion: **[pi-harness-model-proposer](https://github.com/pungggi/pi-harness-model-proposer)**
|
|
233
|
+
(`pi install npm:pi-harness-model-proposer`). Both `complete` and `modelCall` are optional;
|
|
190
234
|
`steering` and `dedupe` ignore them, so the default behavior is unchanged.
|
|
191
235
|
|
|
192
236
|
Register your own proposer from another extension (the registry is re-exported
|
|
@@ -219,14 +263,14 @@ Then `"my-proposer"` is selectable via `/refine --proposer my-proposer` or
|
|
|
219
263
|
|
|
220
264
|
## Status
|
|
221
265
|
|
|
222
|
-
0.
|
|
266
|
+
0.7.x. Implemented:
|
|
223
267
|
|
|
224
268
|
- Unified harness-state store with branch-local snapshots (`/tree` rollback).
|
|
225
269
|
- Online `/refine` + `harness_mutate` / `harness_list` tools.
|
|
226
270
|
- Two-way durable round-trip with pi-reflect (`/harness import|export|status`).
|
|
227
271
|
- Importance hygiene: `/harness prune [--decay <days>]` and `/harness keep|drop
|
|
228
272
|
<id>`.
|
|
229
|
-
- **pi-mem composition**: `/harness push-mem [--all|--kind]` steers the agent
|
|
273
|
+
- **pi-mem composition**: `/harness push-mem [--all|--kind|--model]` steers the agent
|
|
230
274
|
to persist active items into pi-mem (soft-fail; no dependency).
|
|
231
275
|
- Optional config (`~/.pi/agent/harness.json`): project-local durable scope, an
|
|
232
276
|
opt-in `turn_end` reminder, opt-in `turn_end` auto-refine, and an opt-in
|
|
@@ -234,6 +278,10 @@ Then `"my-proposer"` is selectable via `/refine --proposer my-proposer` or
|
|
|
234
278
|
paths (both off by default).
|
|
235
279
|
- **Pluggable delta proposers** with a registry: `steering` (default) and
|
|
236
280
|
`dedupe` (rule-based) shipped; `registerProposer()` for custom ones.
|
|
281
|
+
- **Per-model isolation**: every item is bound to a `provider/id` and injected
|
|
282
|
+
only for that model; new items are stamped automatically and orphans adopted
|
|
283
|
+
on first contact. A new model id starts from a blank harness, and the durable
|
|
284
|
+
round-trip preserves the owner tag.
|
|
237
285
|
|
|
238
286
|
Open extension points (see `docs/ROADMAP.md`):
|
|
239
287
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-continual-harness",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Online self-improvement layer for pi. A unified, in-trajectory harness-state store (prompt notes, memory, skill descriptions, sub-agent specs) with a manual /refine that proposes evidence-backed structured CRUD deltas. Composes with pi-reflect (offline) and pi-mem (storage); owns only the online optimizer layer. Inspired by Continual Harness (arXiv 2605.09998) and ACE (arXiv 2510.04618).",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
package/src/harness.ts
CHANGED
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
decayAndPrune,
|
|
20
20
|
exportDurable,
|
|
21
21
|
getState,
|
|
22
|
+
modelKey,
|
|
22
23
|
reconstructFromDurable,
|
|
23
24
|
} from "./store.js";
|
|
24
25
|
import { loadConfig, resolveDurablePath } from "./config.js";
|
|
@@ -30,7 +31,7 @@ export function registerHarness(pi: ExtensionAPI): void {
|
|
|
30
31
|
"Durable harness-state I/O + importance hygiene. Subcommands: " +
|
|
31
32
|
"import [--prune] [path] · export [path] · status [path] · " +
|
|
32
33
|
"prune [--decay <days>] · keep <id> · drop <id> · " +
|
|
33
|
-
"push-mem [--all|--kind <kind>] (persist active items to pi-mem).",
|
|
34
|
+
"push-mem [--all|--kind <kind>|--model <provider/id|active>] (persist active items to pi-mem).",
|
|
34
35
|
handler: async (args, ctx) => {
|
|
35
36
|
const parts = args.trim().split(/\s+/).filter(Boolean);
|
|
36
37
|
const sub = (parts[0] ?? "status").toLowerCase();
|
|
@@ -202,27 +203,44 @@ async function handlePushMem(
|
|
|
202
203
|
const k = rest[kindIdx + 1]!;
|
|
203
204
|
if (k === "prompt" || k === "memory" || k === "skill" || k === "subagent") kind = k;
|
|
204
205
|
}
|
|
206
|
+
// --model scopes the push to one owner model (default: every model). "active"
|
|
207
|
+
// resolves to the model driving this command, so you don't have to name it.
|
|
208
|
+
const modelIdx = rest.indexOf("--model");
|
|
209
|
+
let modelFilter: string | undefined;
|
|
210
|
+
if (modelIdx >= 0 && modelIdx + 1 < rest.length) {
|
|
211
|
+
const m = rest[modelIdx + 1]!;
|
|
212
|
+
modelFilter = m === "active" ? (modelKey(ctx.model) ?? "") : m;
|
|
213
|
+
}
|
|
205
214
|
// Default: memory kind (the clean 1:1 mapping). --all or --kind override.
|
|
206
|
-
const items = getState().items.filter(
|
|
207
|
-
|
|
215
|
+
const items = getState().items.filter(
|
|
216
|
+
(i) =>
|
|
217
|
+
i.active &&
|
|
218
|
+
(all || (kind ? i.kind === kind : i.kind === "memory")) &&
|
|
219
|
+
(modelFilter === undefined || i.ownerModel === modelFilter),
|
|
208
220
|
);
|
|
209
221
|
if (items.length === 0) {
|
|
210
222
|
ctx.ui.notify(
|
|
211
|
-
"No active items to push (default: memory kind; use --all or --
|
|
223
|
+
"No active items to push (default: memory kind; use --all, --kind <kind>, or --model <provider/id|active>).",
|
|
212
224
|
"warning",
|
|
213
225
|
);
|
|
214
226
|
return;
|
|
215
227
|
}
|
|
216
228
|
const scope = all ? "item(s)" : `${kind ?? "memory"} item(s)`;
|
|
229
|
+
const modelNote = modelFilter !== undefined ? ` [model ${modelFilter || "(orphan)"}]` : "";
|
|
217
230
|
const msg = buildPushMemPrompt(items, scope);
|
|
218
231
|
pi.sendUserMessage(msg);
|
|
219
|
-
ctx.ui.notify(`Steering agent to persist ${items.length} ${scope} to pi-mem.`, "info");
|
|
232
|
+
ctx.ui.notify(`Steering agent to persist ${items.length} ${scope}${modelNote} to pi-mem.`, "info");
|
|
220
233
|
}
|
|
221
234
|
|
|
222
235
|
async function handleStatus(ctx: ExtensionCommandContext, rest: string[]): Promise<void> {
|
|
223
236
|
const path = await resolvePath(rest, ctx);
|
|
224
237
|
const items = getState().items;
|
|
225
238
|
const active = items.filter((i) => i.active);
|
|
239
|
+
const key = modelKey(ctx.model);
|
|
240
|
+
const models = [...new Set(active.map((i) => i.ownerModel).filter(Boolean))];
|
|
241
|
+
const mine = key ? active.filter((i) => i.ownerModel === key).length : active.length;
|
|
242
|
+
// Status is a whole-store view: kind counts span every model. Annotate with
|
|
243
|
+
// the current model's share so the per-model picture is still visible.
|
|
226
244
|
const counts: Record<ComponentKind, number> = { prompt: 0, memory: 0, skill: 0, subagent: 0 };
|
|
227
245
|
for (const i of active) counts[i.kind] += 1;
|
|
228
246
|
let fileState: string;
|
|
@@ -235,8 +253,9 @@ async function handleStatus(ctx: ExtensionCommandContext, rest: string[]): Promi
|
|
|
235
253
|
ctx.ui.setStatus("harness", undefined);
|
|
236
254
|
ctx.ui.notify(
|
|
237
255
|
`Harness: ${active.length} active / ${items.length} total — ` +
|
|
238
|
-
`prompt ${counts.prompt}, memory ${counts.memory}, skill ${counts.skill}, subagent ${counts.subagent}
|
|
239
|
-
`
|
|
256
|
+
`prompt ${counts.prompt}, memory ${counts.memory}, skill ${counts.skill}, subagent ${counts.subagent}.` +
|
|
257
|
+
(key ? ` ${mine} active for [${key}] across ${models.length} model(s).` : "") +
|
|
258
|
+
` Durable: ${fileState}.`,
|
|
240
259
|
"info",
|
|
241
260
|
);
|
|
242
261
|
}
|
package/src/index.ts
CHANGED
|
@@ -14,7 +14,7 @@ import { registerTools } from "./tools.js";
|
|
|
14
14
|
import { registerReminder, resetReminder } from "./remind.js";
|
|
15
15
|
import { registerAutoRefine, resetAutoRefine } from "./auto-refine.js";
|
|
16
16
|
import { registerOutcome, resetOutcome } from "./outcome.js";
|
|
17
|
-
import { getState, reconstruct, STATE_ENTRY } from "./store.js";
|
|
17
|
+
import { getState, reconstruct, setActiveModelKey, STATE_ENTRY } from "./store.js";
|
|
18
18
|
|
|
19
19
|
export default function continualHarness(pi: ExtensionAPI): void {
|
|
20
20
|
// Rebuild in-memory state from the current branch on every session start /
|
|
@@ -26,6 +26,9 @@ export default function continualHarness(pi: ExtensionAPI): void {
|
|
|
26
26
|
resetAutoRefine();
|
|
27
27
|
resetReminder();
|
|
28
28
|
resetOutcome();
|
|
29
|
+
// Drop any stale active-model key from the previous session: the next
|
|
30
|
+
// before_agent_start re-caches it before any tool can run.
|
|
31
|
+
setActiveModelKey(undefined);
|
|
29
32
|
reconstruct(ctx.sessionManager.getBranch() as Iterable<unknown>);
|
|
30
33
|
const n = getState().items.length;
|
|
31
34
|
if (n > 0) {
|
package/src/inject.ts
CHANGED
|
@@ -1,9 +1,16 @@
|
|
|
1
1
|
// before_agent_start: inject the active harness state as a structured block
|
|
2
2
|
// appended to the base system prompt. Never rewrites or replaces the base —
|
|
3
3
|
// only appends, matching Continual Harness's "immutable base + supplemental".
|
|
4
|
+
//
|
|
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.
|
|
4
11
|
|
|
5
12
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
6
|
-
import { getState } from "./store.js";
|
|
13
|
+
import { adoptOrphans, getState, modelKey, setActiveModelKey } from "./store.js";
|
|
7
14
|
import type { ComponentKind } from "./types.js";
|
|
8
15
|
|
|
9
16
|
const ORDER: ComponentKind[] = ["prompt", "memory", "skill", "subagent"];
|
|
@@ -15,13 +22,19 @@ const TITLES: Record<ComponentKind, string> = {
|
|
|
15
22
|
subagent: "Reusable sub-agent specs",
|
|
16
23
|
};
|
|
17
24
|
|
|
18
|
-
|
|
25
|
+
/**
|
|
26
|
+
* Render the harness block for a single model. Only active items whose
|
|
27
|
+
* ownerModel === ownerKey are included. An undefined ownerKey (no active model)
|
|
28
|
+
* renders nothing — isolation is strict: unknown model → inject nothing.
|
|
29
|
+
*/
|
|
30
|
+
export function renderHarnessBlock(ownerKey?: string): string {
|
|
31
|
+
if (ownerKey === undefined) return "";
|
|
19
32
|
const state = getState();
|
|
20
33
|
if (state.items.length === 0) return "";
|
|
21
34
|
|
|
22
35
|
const sections: string[] = [];
|
|
23
36
|
for (const kind of ORDER) {
|
|
24
|
-
const items = state.items.filter((i) => i.kind === kind && i.active);
|
|
37
|
+
const items = state.items.filter((i) => i.kind === kind && i.active && i.ownerModel === ownerKey);
|
|
25
38
|
if (items.length === 0) continue;
|
|
26
39
|
const bullets = items
|
|
27
40
|
.map((i) => `- [${i.id}] ${i.content}`)
|
|
@@ -41,8 +54,18 @@ export function renderHarnessBlock(): string {
|
|
|
41
54
|
}
|
|
42
55
|
|
|
43
56
|
export function registerInjection(pi: ExtensionAPI): void {
|
|
44
|
-
pi.on("before_agent_start", async (event) => {
|
|
45
|
-
const
|
|
57
|
+
pi.on("before_agent_start", async (event, ctx) => {
|
|
58
|
+
const key = modelKey(ctx.model);
|
|
59
|
+
// Cache for the model-facing tools (they have no ctx of their own).
|
|
60
|
+
setActiveModelKey(key);
|
|
61
|
+
// Adopt any orphans to the active model (legacy/import migration). No-op —
|
|
62
|
+
// and no persist — when there is nothing to adopt.
|
|
63
|
+
if (key) {
|
|
64
|
+
adoptOrphans(key, (snapshot, ver) => {
|
|
65
|
+
pi.appendEntry("harness-state", { state: snapshot, version: ver });
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
const block = renderHarnessBlock(key);
|
|
46
69
|
if (!block) return;
|
|
47
70
|
return { systemPrompt: event.systemPrompt + "\n" + block };
|
|
48
71
|
});
|
package/src/outcome.ts
CHANGED
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
|
|
25
25
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
26
26
|
import { DEFAULT_REF_BUMP, type HarnessConfig, loadConfig } from "./config.js";
|
|
27
|
-
import { bumpImportance, getState } from "./store.js";
|
|
27
|
+
import { bumpImportance, getState, modelKey } from "./store.js";
|
|
28
28
|
|
|
29
29
|
// Minimal entry shape; kept loose to avoid coupling to pi's internal types.
|
|
30
30
|
type AnyEntry = {
|
|
@@ -83,7 +83,14 @@ export function registerOutcome(pi: ExtensionAPI): void {
|
|
|
83
83
|
.join("\n");
|
|
84
84
|
if (!text.trim()) return;
|
|
85
85
|
|
|
86
|
-
|
|
86
|
+
// Only the active model's items are injected, so only those can be cited.
|
|
87
|
+
// Filter accordingly; an unknown model → no candidates → no bumps.
|
|
88
|
+
const key = modelKey(ctx.model);
|
|
89
|
+
const activeIds = new Set(
|
|
90
|
+
key === undefined
|
|
91
|
+
? []
|
|
92
|
+
: getState().items.filter((i) => i.active && i.ownerModel === key).map((i) => i.id),
|
|
93
|
+
);
|
|
87
94
|
const refs = findReferences(text, activeIds);
|
|
88
95
|
if (refs.length === 0) return;
|
|
89
96
|
|
package/src/proposer.ts
CHANGED
|
@@ -175,6 +175,9 @@ export const dedupeProposer: DeltaProposer = {
|
|
|
175
175
|
let best = 0;
|
|
176
176
|
for (const k of keepers) {
|
|
177
177
|
if (k.kind !== cand.kind) continue;
|
|
178
|
+
// Per-model isolation: each model keeps its own copy, so two near-
|
|
179
|
+
// identical items bound to different models are NOT duplicates.
|
|
180
|
+
if (k.ownerModel !== cand.ownerModel) continue;
|
|
178
181
|
const sim = tokenOverlap(k.content, cand.content);
|
|
179
182
|
if (sim >= DEDUPE_THRESHOLD && sim > best) {
|
|
180
183
|
best = sim;
|
package/src/refine.ts
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
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, exportDurable, REFINE_ENTRY, snapshotState } from "./store.js";
|
|
22
|
+
import { applyDeltas, exportDurable, modelKey, REFINE_ENTRY, snapshotState } from "./store.js";
|
|
23
23
|
import { getProposer } from "./proposer.js";
|
|
24
24
|
import type { CompleteOptions, CompleteResult } from "./proposer.js";
|
|
25
25
|
|
|
@@ -96,6 +96,11 @@ export async function runRefine(
|
|
|
96
96
|
...(complete ? { complete } : {}),
|
|
97
97
|
});
|
|
98
98
|
const proposedDeltas = result.deltas ?? [];
|
|
99
|
+
// Stamp direct-apply create deltas with the active model so proposer-authored
|
|
100
|
+
// notes bind to the model driving the turn (the steering path is stamped in
|
|
101
|
+
// the harness_mutate tool instead). Orphans result only when the model is
|
|
102
|
+
// unknown; before_agent_start adopts them on the next turn.
|
|
103
|
+
const ownerKey = modelKey(ctx.model);
|
|
99
104
|
|
|
100
105
|
let applied = 0;
|
|
101
106
|
let applyError: string | undefined;
|
|
@@ -108,7 +113,12 @@ export async function runRefine(
|
|
|
108
113
|
// so we surface the failure as an audited no-op rather than crashing /refine.
|
|
109
114
|
try {
|
|
110
115
|
const appliedDeltas = applyDeltas(
|
|
111
|
-
proposedDeltas.map((d) =>
|
|
116
|
+
proposedDeltas.map((d) => {
|
|
117
|
+
const delta = d.delta;
|
|
118
|
+
return ownerKey !== undefined && delta.op === "create"
|
|
119
|
+
? { ...delta, ownerModel: ownerKey }
|
|
120
|
+
: delta;
|
|
121
|
+
}),
|
|
112
122
|
(snapshot, ver) => pi.appendEntry("harness-state", { state: snapshot, version: ver }),
|
|
113
123
|
);
|
|
114
124
|
applied = appliedDeltas.length;
|
package/src/store.ts
CHANGED
|
@@ -28,6 +28,32 @@ const IMPORTANCE_FLOOR = 0.3;
|
|
|
28
28
|
let state: HarnessState = { items: [] };
|
|
29
29
|
let version = 0;
|
|
30
30
|
|
|
31
|
+
// ---- model binding -------------------------------------------------------
|
|
32
|
+
//
|
|
33
|
+
// Items are strictly per-model (ownerModel = "provider/id"). The model-facing
|
|
34
|
+
// tools (harness_list / harness_mutate) receive NO ctx, so they cannot read
|
|
35
|
+
// the active model at execute time. before_agent_start always fires first in a
|
|
36
|
+
// turn WITH ctx.model, so it caches the active key here; the tools then read
|
|
37
|
+
// the cache to stamp/filter. Undefined cache (no turn started) is treated as
|
|
38
|
+
// "model unknown" — tools fall back gracefully (create orphans, list all).
|
|
39
|
+
let activeModelKey: string | undefined;
|
|
40
|
+
|
|
41
|
+
/** Canonical owner key for a model: "provider/id". Accepts the structural
|
|
42
|
+
* shape of pi-ai's Model (provider + id) without importing the type. */
|
|
43
|
+
export function modelKey(m?: { provider: string; id: string }): string | undefined {
|
|
44
|
+
return m ? `${m.provider}/${m.id}` : undefined;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Cache the active model key (called from before_agent_start). */
|
|
48
|
+
export function setActiveModelKey(key: string | undefined): void {
|
|
49
|
+
activeModelKey = key;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Read the cached active model key (called from the model-facing tools). */
|
|
53
|
+
export function getActiveModelKey(): string | undefined {
|
|
54
|
+
return activeModelKey;
|
|
55
|
+
}
|
|
56
|
+
|
|
31
57
|
export function getState(): HarnessState {
|
|
32
58
|
return state;
|
|
33
59
|
}
|
|
@@ -51,8 +77,10 @@ function clamp(n: number): number {
|
|
|
51
77
|
return Math.max(0, Math.min(1, n));
|
|
52
78
|
}
|
|
53
79
|
|
|
54
|
-
/** Apply a single delta against the in-memory state. Does not persist.
|
|
55
|
-
|
|
80
|
+
/** Apply a single delta against the in-memory state. Does not persist.
|
|
81
|
+
* `actorModel`, when set, stamps creates and restricts update/delete to that
|
|
82
|
+
* model's items (per-model isolation at the model-facing tool boundary). */
|
|
83
|
+
function applyOne(delta: Delta, actorModel?: string): AppliedDelta {
|
|
56
84
|
if (delta.op === "create") {
|
|
57
85
|
const now = Date.now();
|
|
58
86
|
const item: HarnessItem = {
|
|
@@ -62,6 +90,9 @@ function applyOne(delta: Delta): AppliedDelta {
|
|
|
62
90
|
evidence: delta.evidence,
|
|
63
91
|
importance: clamp(delta.importance ?? 0.5),
|
|
64
92
|
active: true,
|
|
93
|
+
// Owner: explicit delta wins; else the actor model; else orphan (""),
|
|
94
|
+
// adopted by the active model on first contact.
|
|
95
|
+
ownerModel: delta.ownerModel ?? actorModel ?? "",
|
|
65
96
|
createdAt: now,
|
|
66
97
|
updatedAt: now,
|
|
67
98
|
};
|
|
@@ -73,12 +104,17 @@ function applyOne(delta: Delta): AppliedDelta {
|
|
|
73
104
|
const idx = state.items.findIndex((i) => i.id === delta.id);
|
|
74
105
|
if (idx < 0) throw new Error(`update: no item with id ${delta.id}`);
|
|
75
106
|
const before = state.items[idx]!;
|
|
107
|
+
// Per-model isolation: when an actor model is known, a mutation may only
|
|
108
|
+
// touch that model's items. Cross-model maintenance paths (the dedupe
|
|
109
|
+
// proposer, /harness keep|drop|prune) call applyDeltas with no actor.
|
|
110
|
+
assertOwnsItem("update", delta.id, before, actorModel);
|
|
76
111
|
const after: HarnessItem = {
|
|
77
112
|
...before,
|
|
78
113
|
content: delta.content ?? before.content,
|
|
79
114
|
evidence: delta.evidence ?? before.evidence,
|
|
80
115
|
importance: clamp(delta.importance ?? before.importance),
|
|
81
116
|
active: delta.active ?? before.active,
|
|
117
|
+
ownerModel: delta.ownerModel ?? before.ownerModel,
|
|
82
118
|
updatedAt: Date.now(),
|
|
83
119
|
};
|
|
84
120
|
state.items[idx] = after;
|
|
@@ -88,10 +124,28 @@ function applyOne(delta: Delta): AppliedDelta {
|
|
|
88
124
|
// delete
|
|
89
125
|
const idx = state.items.findIndex((i) => i.id === delta.id);
|
|
90
126
|
if (idx < 0) throw new Error(`delete: no item with id ${delta.id}`);
|
|
127
|
+
assertOwnsItem("delete", delta.id, state.items[idx]!, actorModel);
|
|
91
128
|
state.items.splice(idx, 1);
|
|
92
129
|
return { op: "delete", id: delta.id, reason: delta.reason };
|
|
93
130
|
}
|
|
94
131
|
|
|
132
|
+
/** Enforce that `actorModel` owns `item`; no-op when the actor is unknown
|
|
133
|
+
* (manual / cross-model paths). Throws an audited, rollback-triggering error
|
|
134
|
+
* otherwise. */
|
|
135
|
+
function assertOwnsItem(
|
|
136
|
+
op: "update" | "delete",
|
|
137
|
+
id: string,
|
|
138
|
+
item: HarnessItem,
|
|
139
|
+
actorModel?: string,
|
|
140
|
+
): void {
|
|
141
|
+
if (actorModel === undefined) return;
|
|
142
|
+
if (item.ownerModel !== actorModel) {
|
|
143
|
+
throw new Error(
|
|
144
|
+
`${op}: item ${id} is owned by ${item.ownerModel || "(orphan)"}, not the active model ${actorModel}`,
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
95
149
|
/**
|
|
96
150
|
* Apply a batch of deltas. All-or-nothing: if any delta throws, nothing is
|
|
97
151
|
* applied. Returns the applied deltas and the new version.
|
|
@@ -99,11 +153,12 @@ function applyOne(delta: Delta): AppliedDelta {
|
|
|
99
153
|
export function applyDeltas(
|
|
100
154
|
deltas: Delta[],
|
|
101
155
|
persist: (snapshot: HarnessState, version: number) => void,
|
|
156
|
+
actorModel?: string,
|
|
102
157
|
): AppliedDelta[] {
|
|
103
158
|
const snapshotBefore = { items: state.items.map((i) => ({ ...i })) };
|
|
104
159
|
const applied: AppliedDelta[] = [];
|
|
105
160
|
try {
|
|
106
|
-
for (const d of deltas) applied.push(applyOne(d));
|
|
161
|
+
for (const d of deltas) applied.push(applyOne(d, actorModel));
|
|
107
162
|
} catch (err) {
|
|
108
163
|
// Roll back in-memory state on failure.
|
|
109
164
|
state = snapshotBefore;
|
|
@@ -123,7 +178,11 @@ export function reconstruct(entries: Iterable<unknown>): void {
|
|
|
123
178
|
last = entry.data.state;
|
|
124
179
|
}
|
|
125
180
|
}
|
|
126
|
-
|
|
181
|
+
// Normalize legacy snapshots that predate ownerModel: missing → orphan (""),
|
|
182
|
+
// adopted by the active model on first contact (see adoptOrphans).
|
|
183
|
+
state = last
|
|
184
|
+
? { items: last.items.map((i) => ({ ...i, ownerModel: i.ownerModel ?? "" })) }
|
|
185
|
+
: { items: [] };
|
|
127
186
|
version = 0;
|
|
128
187
|
}
|
|
129
188
|
|
|
@@ -172,6 +231,28 @@ export function bumpImportance(
|
|
|
172
231
|
return item;
|
|
173
232
|
}
|
|
174
233
|
|
|
234
|
+
/** Adopt every orphan item (ownerModel === "") to the given model key. This is
|
|
235
|
+
* the migration policy for legacy snapshots / durable imports / items created
|
|
236
|
+
* while the active model was unknown: they become owned by the first model to
|
|
237
|
+
* claim them. Idempotent: a no-op (no persist) when there are no orphans. */
|
|
238
|
+
export function adoptOrphans(
|
|
239
|
+
key: string,
|
|
240
|
+
persist: (snapshot: HarnessState, version: number) => void,
|
|
241
|
+
): number {
|
|
242
|
+
let adopted = 0;
|
|
243
|
+
for (const i of state.items) {
|
|
244
|
+
if (i.ownerModel === "") {
|
|
245
|
+
i.ownerModel = key;
|
|
246
|
+
adopted += 1;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
if (adopted > 0) {
|
|
250
|
+
version += 1;
|
|
251
|
+
persist(state, version);
|
|
252
|
+
}
|
|
253
|
+
return adopted;
|
|
254
|
+
}
|
|
255
|
+
|
|
175
256
|
export { STATE_ENTRY, REFINE_ENTRY, IMPORTANCE_FLOOR };
|
|
176
257
|
|
|
177
258
|
// ---- Durable export (composition seam with pi-reflect / pi-mem) -------------
|
|
@@ -186,6 +267,7 @@ export async function exportDurable(path = DEFAULT_DURABLE_PATH): Promise<string
|
|
|
186
267
|
for (const i of items) {
|
|
187
268
|
lines.push(`- **[${i.id}]** (importance ${i.importance.toFixed(2)}) ${i.content}`);
|
|
188
269
|
lines.push(` - evidence: ${i.evidence}`);
|
|
270
|
+
if (i.ownerModel) lines.push(` - model: ${i.ownerModel}`);
|
|
189
271
|
}
|
|
190
272
|
lines.push("");
|
|
191
273
|
}
|
|
@@ -241,6 +323,7 @@ interface ParsedItem {
|
|
|
241
323
|
importance: number;
|
|
242
324
|
content: string;
|
|
243
325
|
evidence: string;
|
|
326
|
+
ownerModel?: string;
|
|
244
327
|
}
|
|
245
328
|
|
|
246
329
|
// Section title → kind. Exact export titles first, then tolerant keyword
|
|
@@ -265,6 +348,7 @@ const RE_H2 = /^##\s+(.*)$/;
|
|
|
265
348
|
const RE_ID_BULLET = /^-\s+\*\*\[([^\]]+)\]\*\*\s*\(importance\s+([\d.]+)\)\s*(.*)$/;
|
|
266
349
|
const RE_PLAIN_BULLET = /^-\s+(.+)$/;
|
|
267
350
|
const RE_EVIDENCE = /^\s+-\s+evidence:\s*(.*)$/i;
|
|
351
|
+
const RE_MODEL = /^\s+-\s+model:\s*(.*)$/i;
|
|
268
352
|
|
|
269
353
|
/** Parse a durable markdown export into items. Tolerant of pi-reflect's edits. */
|
|
270
354
|
export function parseDurable(text: string): ParsedItem[] {
|
|
@@ -296,6 +380,12 @@ export function parseDurable(text: string): ParsedItem[] {
|
|
|
296
380
|
continue;
|
|
297
381
|
}
|
|
298
382
|
|
|
383
|
+
const mdl = line.match(RE_MODEL);
|
|
384
|
+
if (mdl) {
|
|
385
|
+
if (pending) pending.ownerModel = mdl[1]!.trim();
|
|
386
|
+
continue;
|
|
387
|
+
}
|
|
388
|
+
|
|
299
389
|
const idm = line.match(RE_ID_BULLET);
|
|
300
390
|
if (idm) {
|
|
301
391
|
flush();
|
|
@@ -361,6 +451,10 @@ export async function reconstructFromDurable(
|
|
|
361
451
|
existing.evidence = p.evidence;
|
|
362
452
|
existing.importance = clamp(p.importance);
|
|
363
453
|
existing.active = true;
|
|
454
|
+
// Durable wins on owner too: a present tag sets the owner; an absent tag
|
|
455
|
+
// (e.g. pi-reflect stripped it) orphans the item so it's adopted by the
|
|
456
|
+
// active model on first contact — matching the documented round-trip.
|
|
457
|
+
existing.ownerModel = p.ownerModel ?? "";
|
|
364
458
|
existing.updatedAt = now;
|
|
365
459
|
updated += 1;
|
|
366
460
|
} else {
|
|
@@ -372,6 +466,7 @@ export async function reconstructFromDurable(
|
|
|
372
466
|
evidence: p.evidence,
|
|
373
467
|
importance: clamp(p.importance),
|
|
374
468
|
active: true,
|
|
469
|
+
ownerModel: p.ownerModel ?? "",
|
|
375
470
|
createdAt: now,
|
|
376
471
|
updatedAt: now,
|
|
377
472
|
});
|
package/src/tools.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
9
9
|
import { StringEnum } from "@earendil-works/pi-ai";
|
|
10
10
|
import { Type } from "typebox";
|
|
11
|
-
import { applyDeltas, getState, listItems } from "./store.js";
|
|
11
|
+
import { applyDeltas, getActiveModelKey, getState, listItems } from "./store.js";
|
|
12
12
|
import type { AppliedDelta, ComponentKind, Delta } from "./types.js";
|
|
13
13
|
|
|
14
14
|
const KIND_VALUES = ["prompt", "memory", "skill", "subagent"] as const;
|
|
@@ -21,6 +21,9 @@ const CreateShape = Type.Object({
|
|
|
21
21
|
description: "Why this belongs in the harness state, grounded in trajectory evidence.",
|
|
22
22
|
}),
|
|
23
23
|
importance: Type.Optional(Type.Number({ minimum: 0, maximum: 1 })),
|
|
24
|
+
// ownerModel is intentionally NOT exposed to the model: it is stamped
|
|
25
|
+
// server-side from the active model so items bind to the model driving the
|
|
26
|
+
// turn without the agent having to (or being able to) name it.
|
|
24
27
|
});
|
|
25
28
|
|
|
26
29
|
const UpdateShape = Type.Object({
|
|
@@ -52,10 +55,26 @@ export function registerTools(pi: ExtensionAPI): void {
|
|
|
52
55
|
],
|
|
53
56
|
parameters: Type.Object({
|
|
54
57
|
kind: Type.Optional(StringEnum(KIND_VALUES)),
|
|
58
|
+
model: Type.Optional(
|
|
59
|
+
Type.String({
|
|
60
|
+
description:
|
|
61
|
+
"Filter by owner model as provider/id. Omit = the active model items; use * for all models.",
|
|
62
|
+
}),
|
|
63
|
+
),
|
|
55
64
|
}),
|
|
56
65
|
async execute(_toolCallId, params) {
|
|
57
66
|
const kind = params.kind as ComponentKind | undefined;
|
|
58
|
-
const
|
|
67
|
+
const modelFilter = params.model as string | undefined;
|
|
68
|
+
// Default scope = the active model's items (what gets injected this turn).
|
|
69
|
+
// "*" = every model; an explicit id = that model. When no active model is
|
|
70
|
+
// known (no turn started) and no filter is given, return all so the agent
|
|
71
|
+
// is never silently shown an empty store during setup.
|
|
72
|
+
const ownerKey =
|
|
73
|
+
modelFilter === "*"
|
|
74
|
+
? undefined
|
|
75
|
+
: modelFilter ?? getActiveModelKey();
|
|
76
|
+
let items = listItems(kind);
|
|
77
|
+
if (ownerKey !== undefined) items = items.filter((i) => i.ownerModel === ownerKey);
|
|
59
78
|
return {
|
|
60
79
|
content: [
|
|
61
80
|
{
|
|
@@ -66,7 +85,7 @@ export function registerTools(pi: ExtensionAPI): void {
|
|
|
66
85
|
: items
|
|
67
86
|
.map(
|
|
68
87
|
(i) =>
|
|
69
|
-
`[${i.id}] (${i.kind}, importance ${i.importance.toFixed(2)}, ${i.active ? "active" : "inactive"}) ${i.content}\n evidence: ${i.evidence}`,
|
|
88
|
+
`[${i.id}] (${i.kind}, importance ${i.importance.toFixed(2)}, ${i.active ? "active" : "inactive"}, model ${i.ownerModel || "(orphan)"}) ${i.content}\n evidence: ${i.evidence}`,
|
|
70
89
|
)
|
|
71
90
|
.join("\n"),
|
|
72
91
|
},
|
|
@@ -89,10 +108,18 @@ export function registerTools(pi: ExtensionAPI): void {
|
|
|
89
108
|
deltas: Type.Array(DeltaShape, { minItems: 1, maxItems: 20 }),
|
|
90
109
|
}),
|
|
91
110
|
async execute(_toolCallId, params) {
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
111
|
+
const incoming = params.deltas as Delta[];
|
|
112
|
+
// The active model is the ACTOR: creates bind to it, and update/delete are
|
|
113
|
+
// scoped to it (per-model isolation). When the active model is unknown (no
|
|
114
|
+
// turn started) there is no actor — creates become orphans, adopted next
|
|
115
|
+
// turn, and update/delete are unscoped (graceful fallback).
|
|
116
|
+
const applied: AppliedDelta[] = applyDeltas(
|
|
117
|
+
incoming,
|
|
118
|
+
(snapshot, ver) => {
|
|
119
|
+
pi.appendEntry("harness-state", { state: snapshot, version: ver });
|
|
120
|
+
},
|
|
121
|
+
getActiveModelKey(),
|
|
122
|
+
);
|
|
96
123
|
const summary = summarize(applied);
|
|
97
124
|
return {
|
|
98
125
|
content: [{ type: "text", text: summary }],
|
package/src/types.ts
CHANGED
|
@@ -25,6 +25,14 @@ export interface HarnessItem {
|
|
|
25
25
|
importance: number;
|
|
26
26
|
/** Whether the item is injected into the system prompt each turn. */
|
|
27
27
|
active: boolean;
|
|
28
|
+
/**
|
|
29
|
+
* The model this item is bound to, as "provider/id". Empty string = orphan
|
|
30
|
+
* (not yet adopted by any model). Model binding is STRICT per-model: an item
|
|
31
|
+
* is only injected for the exact model it belongs to, so a brand-new model
|
|
32
|
+
* id starts from a blank harness and never inherits another model's notes.
|
|
33
|
+
* Orphans are adopted by the active model on first contact (see store.ts).
|
|
34
|
+
*/
|
|
35
|
+
ownerModel: string;
|
|
28
36
|
createdAt: number;
|
|
29
37
|
updatedAt: number;
|
|
30
38
|
}
|
|
@@ -41,6 +49,9 @@ export type Delta =
|
|
|
41
49
|
content: string;
|
|
42
50
|
evidence: string;
|
|
43
51
|
importance?: number;
|
|
52
|
+
/** Owner "provider/id". Stamped server-side from the active model when the
|
|
53
|
+
* agent omits it; set explicitly by direct-apply proposers. Absent → orphan. */
|
|
54
|
+
ownerModel?: string;
|
|
44
55
|
}
|
|
45
56
|
| {
|
|
46
57
|
op: "update";
|
|
@@ -49,6 +60,8 @@ export type Delta =
|
|
|
49
60
|
evidence?: string;
|
|
50
61
|
importance?: number;
|
|
51
62
|
active?: boolean;
|
|
63
|
+
/** Reassign ownership (rare; used by migration/import). Absent → keep current. */
|
|
64
|
+
ownerModel?: string;
|
|
52
65
|
}
|
|
53
66
|
| { op: "delete"; id: string; reason: string };
|
|
54
67
|
|