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 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 [kind]` — read current state (optionally filtered by kind).
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 or `--kind prompt|skill|subagent` for a specific
104
- kind. The harness store itself is unchanged by a push — pi-mem gets a separate
105
- copy.
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**. Both `complete` and `modelCall` are optional;
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.5.x. Implemented:
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.6.2",
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((i) =>
207
- i.active && (all || (kind ? i.kind === kind : i.kind === "memory")),
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 --kind <kind>).",
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
- `Durable: ${fileState}.`,
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
- export function renderHarnessBlock(): string {
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 block = renderHarnessBlock();
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
- const activeIds = new Set(getState().items.filter((i) => i.active).map((i) => i.id));
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) => d.delta),
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
- function applyOne(delta: Delta): AppliedDelta {
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
- state = last ? { items: last.items.map((i) => ({ ...i })) } : { items: [] };
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 items = listItems(kind);
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 deltas = params.deltas as Delta[];
93
- const applied: AppliedDelta[] = applyDeltas(deltas, (snapshot, ver) => {
94
- pi.appendEntry("harness-state", { state: snapshot, version: ver });
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