pi-continual-harness 0.6.2 → 0.8.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
 
@@ -112,6 +116,7 @@ Optional config at `~/.pi/agent/harness.json` (missing or malformed → defaults
112
116
  {
113
117
  "durableScope": "global",
114
118
  "proposer": "steering",
119
+ "injection": { "enabled": true, "maxTokens": 1500, "maxPerKind": 10, "charsPerToken": 4 },
115
120
  "remindRefine": { "enabled": false, "everyTurns": 50 },
116
121
  "autoRefine": { "enabled": false, "everyTurns": 100, "commit": false },
117
122
  "outcomeImportance": { "enabled": false, "bump": 0.03 }
@@ -122,6 +127,11 @@ Optional config at `~/.pi/agent/harness.json` (missing or malformed → defaults
122
127
  `~/.pi/agent/harness-state.md`; `"project"` writes to
123
128
  `~/.pi/agent/harness-state/<slug>.md` (slug derived from `cwd`) so each
124
129
  project keeps separate state for pi-reflect.
130
+ - **`injection`** — the selection policy for WHAT gets surfaced in the system
131
+ prompt each turn (see [Injection selection](#injection-selection-on-by-default)).
132
+ ON by default: items are importance-ordered, capped at `maxPerKind` (default
133
+ 10) per kind and `maxTokens` (default 1500) total. Set `enabled: false` to
134
+ restore the legacy "inject all items, in store order" behaviour.
125
135
  - **`proposer`** — which delta proposer `/refine` and auto-refine use. Defaults
126
136
  to `steering` (the agent reasons via a steering message). `dedupe` applies a
127
137
  rule-based dedupe directly. See [Proposers](#proposers).
@@ -154,13 +164,93 @@ Optional config at `~/.pi/agent/harness.json` (missing or malformed → defaults
154
164
  3. The agent calls the tools (steering path); each accepted delta updates the
155
165
  in-memory state, which is snapshotted to the session via
156
166
  `appendEntry("harness-state", ...)`. Direct-apply proposers snapshot the
157
- same way.
167
+ same way. New items are stamped with the active model (see
168
+ [Model binding](#model-binding-per-model-isolation)).
158
169
  4. Because pi's session tree branches at any entry, `/tree` navigation gives
159
170
  rollback to any pre-refinement point for free — no bespoke snapshot system.
160
171
 
161
172
  This reuses the existing agent loop (no nested/hidden model calls), is
162
173
  model-agnostic, and keeps every delta visible and reviewable in the transcript.
163
174
 
175
+ ## Model binding (per-model isolation)
176
+
177
+ Every item is bound to exactly one model as `ownerModel` (`"provider/id"`). An
178
+ item is **only injected for the model it belongs to** — so switching to a
179
+ brand-new model id starts from a **blank harness**, and one model's notes never
180
+ leak into another's context. Binding is at the *exact* model id (not family or
181
+ vendor): a new version id is a clean slate, by design.
182
+
183
+ How the binding is set and respected:
184
+
185
+ - **Created items are stamped automatically** with the model driving the turn.
186
+ The model-facing tools cannot read the active model, so `before_agent_start`
187
+ (which always fires first in a turn, with the model) caches it; `harness_mutate`
188
+ stamps creates from that cache, and direct-apply proposers stamp from their
189
+ `ctx.model`. You never name the model yourself.
190
+ - **Injection filters by owner.** Only items whose `ownerModel` matches the
191
+ active model are appended to the system prompt. An unknown model injects
192
+ nothing.
193
+ - **`harness_list` defaults to the active model** (pass `model: "*"` for every
194
+ model, or an explicit `"provider/id"`).
195
+ - **Orphan adoption.** Items with no owner — from a legacy session snapshot, an
196
+ old durable file, or created while the model was unknown — are adopted by the
197
+ active model on first contact (the next `before_agent_start`). This is the
198
+ migration path: existing harnesses transition cleanly with no manual steps,
199
+ and it's persisted as a normal `harness-state` entry (so `/tree` rollback
200
+ covers it).
201
+ - **Durable round-trip preserves owner.** `/harness export` tags each item with
202
+ `model: provider/id`; `/harness import` restores it. An item whose tag
203
+ pi-reflect stripped becomes an orphan and is adopted by the active model.
204
+
205
+ Manual commands (`export`, `import`, `keep`, `drop`, `prune`, `push-mem`,
206
+ `status`) operate on the **whole store** by design — they are explicit human
207
+ actions with full control. Isolation is enforced only where pollution would
208
+ leak automatically: injection, listing, create-stamping, and the outcome loop.
209
+ In particular, `/harness push-mem` pushes *every* model's active items into
210
+ pi-mem by default (which can yield near-duplicate memories across models); pass
211
+ `--model <provider/id|active>` to scope it to one model.
212
+
213
+ ## Injection selection (on by default)
214
+
215
+ The harness ACCUMULATES notes (create / refine / auto-refine / outcome-promotion),
216
+ but the system prompt is finite. So since 0.8 the block appended each turn is the
217
+ result of a **selection policy**, not the whole store — and it is **on by
218
+ default**. The store itself is never changed by selection (nothing is lost);
219
+ only what is *surfaced* changes.
220
+
221
+ The policy (pure, deterministic; `src/select.ts`):
222
+
223
+ 1. **Filter** — only active items bound to the active model (strict per-model
224
+ isolation, unchanged).
225
+ 2. **Order** — importance desc; ties keep store/insertion order (stable). The
226
+ highest-fitness notes lead each section.
227
+ 3. **Cap** — `maxPerKind` (default 10: balanced sections, no single kind drowns
228
+ the block), then `maxTokens` (default 1500: total budget). The budget is
229
+ filled **round-robin across kinds by importance rank**, so one kind cannot
230
+ starve the others; within that order an item that doesn't fit is **skipped**
231
+ (not a hard stop), so a large item never blocks smaller higher-priority ones.
232
+
233
+ The defaults are deliberately generous — a **no-op for small stores** (nothing
234
+ trimmed) and protective as the harness grows. When the policy drops items, the
235
+ block ends with a one-line transparency note:
236
+
237
+ ```
238
+ _(3 item(s) not shown — below the injection budget. Raise `injection.maxTokens`/`maxPerKind` in harness.json or run `/harness prune`.)_
239
+ ```
240
+
241
+ Tune or disable it in `harness.json`:
242
+
243
+ ```json
244
+ { "injection": { "maxTokens": 3000, "maxPerKind": 20 } } // raise the ceiling
245
+ { "injection": { "maxPerKind": 5 } } // trim harder, per kind
246
+ { "injection": { "enabled": false } } // opt out: legacy "all, in order"
247
+ ```
248
+
249
+ Selection is factored into a pure function (`selectForInjection`) and
250
+ re-exported from the package entry, so a companion package can layer richer
251
+ policies (e.g. relevance to the current turn, via the shared `tokenize` /
252
+ `tokenOverlap` helpers) without touching `inject.ts`.
253
+
164
254
  ## Proposers
165
255
 
166
256
  `/refine` is split into two stages: **propose** (given evidence + state, decide
@@ -186,7 +276,8 @@ available, `/refine` injects a one-shot `complete(prompt, opts?)` into
186
276
  `ProposeInput` and records any `modelCall` telemetry a proposer returns
187
277
  (model, tokens, latency, ok/error) in the `harness-refinement` audit entry —
188
278
  so a companion package can ship a dedicated-model proposer whose spend stays
189
- **audited, not hidden**. Both `complete` and `modelCall` are optional;
279
+ **audited, not hidden**. One ships as a companion: **[pi-harness-model-proposer](https://github.com/pungggi/pi-harness-model-proposer)**
280
+ (`pi install npm:pi-harness-model-proposer`). Both `complete` and `modelCall` are optional;
190
281
  `steering` and `dedupe` ignore them, so the default behavior is unchanged.
191
282
 
192
283
  Register your own proposer from another extension (the registry is re-exported
@@ -219,14 +310,14 @@ Then `"my-proposer"` is selectable via `/refine --proposer my-proposer` or
219
310
 
220
311
  ## Status
221
312
 
222
- 0.5.x. Implemented:
313
+ 0.8.x. Implemented:
223
314
 
224
315
  - Unified harness-state store with branch-local snapshots (`/tree` rollback).
225
316
  - Online `/refine` + `harness_mutate` / `harness_list` tools.
226
317
  - Two-way durable round-trip with pi-reflect (`/harness import|export|status`).
227
318
  - Importance hygiene: `/harness prune [--decay <days>]` and `/harness keep|drop
228
319
  <id>`.
229
- - **pi-mem composition**: `/harness push-mem [--all|--kind]` steers the agent
320
+ - **pi-mem composition**: `/harness push-mem [--all|--kind|--model]` steers the agent
230
321
  to persist active items into pi-mem (soft-fail; no dependency).
231
322
  - Optional config (`~/.pi/agent/harness.json`): project-local durable scope, an
232
323
  opt-in `turn_end` reminder, opt-in `turn_end` auto-refine, and an opt-in
@@ -234,6 +325,14 @@ Then `"my-proposer"` is selectable via `/refine --proposer my-proposer` or
234
325
  paths (both off by default).
235
326
  - **Pluggable delta proposers** with a registry: `steering` (default) and
236
327
  `dedupe` (rule-based) shipped; `registerProposer()` for custom ones.
328
+ - **Per-model isolation**: every item is bound to a `provider/id` and injected
329
+ only for that model; new items are stamped automatically and orphans adopted
330
+ on first contact. A new model id starts from a blank harness, and the durable
331
+ round-trip preserves the owner tag.
332
+ - **Bounded injection (on by default)**: the supplemental block is
333
+ importance-ordered and capped per kind + by a total token budget, so a growing
334
+ harness never balloons the system prompt. Tunable / opt-out via the `injection`
335
+ config key; the store is never changed by selection.
237
336
 
238
337
  Open extension points (see `docs/ROADMAP.md`):
239
338
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-continual-harness",
3
- "version": "0.6.2",
3
+ "version": "0.8.0",
4
4
  "description": "Online self-improvement layer for pi. A unified, in-trajectory harness-state store (prompt notes, memory, skill descriptions, sub-agent specs) with a manual /refine that proposes evidence-backed structured CRUD deltas. Composes with pi-reflect (offline) and pi-mem (storage); owns only the online optimizer layer. Inspired by Continual Harness (arXiv 2605.09998) and ACE (arXiv 2510.04618).",
5
5
  "keywords": [
6
6
  "pi-package",
package/src/config.ts CHANGED
@@ -14,6 +14,7 @@ import { readFile } from "node:fs/promises";
14
14
  import { homedir } from "node:os";
15
15
  import { join } from "node:path";
16
16
  import { DEFAULT_DURABLE_PATH } from "./store.js";
17
+ import { DEFAULT_INJECTION, normalizeInjection, type NormalizedInjection } from "./select.js";
17
18
 
18
19
  export const DEFAULT_EVERY_TURNS = 50;
19
20
  export const DEFAULT_AUTO_EVERY_TURNS = 100;
@@ -39,6 +40,9 @@ export interface HarnessConfig {
39
40
  enabled?: boolean;
40
41
  bump?: number;
41
42
  };
43
+ /** Injection selection policy (on by default). Resolved by loadConfig, so the
44
+ * value here is always fully-populated. See src/select.ts. */
45
+ injection?: NormalizedInjection;
42
46
  }
43
47
 
44
48
  export const DEFAULT_CONFIG: HarnessConfig = {
@@ -47,6 +51,10 @@ export const DEFAULT_CONFIG: HarnessConfig = {
47
51
  autoRefine: { enabled: false, everyTurns: DEFAULT_AUTO_EVERY_TURNS, commit: false },
48
52
  proposer: "steering",
49
53
  outcomeImportance: { enabled: false, bump: DEFAULT_REF_BUMP },
54
+ // ON by default: importance-ordered, maxPerKind 10, maxTokens 1500. A no-op
55
+ // for small stores; protective as the harness accumulates. Opt out with
56
+ // `injection.enabled: false`. See src/select.ts.
57
+ injection: DEFAULT_INJECTION,
50
58
  };
51
59
 
52
60
  export const CONFIG_PATH = join(homedir(), ".pi", "agent", "harness.json");
@@ -71,6 +79,10 @@ function mergeConfig(over: Partial<HarnessConfig>): HarnessConfig {
71
79
  enabled: over.outcomeImportance?.enabled ?? false,
72
80
  bump: coerceBump(over.outcomeImportance?.bump),
73
81
  },
82
+ // normalizeInjection is defensive (bad types → defaults), so a partial or
83
+ // malformed `injection` object degrades to the shipped defaults rather than
84
+ // corrupting the block sizing arithmetic.
85
+ injection: normalizeInjection(over.injection as Partial<{ enabled: boolean; maxTokens: number; maxPerKind: number; charsPerToken: number }> | undefined),
74
86
  };
75
87
  }
76
88
 
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) {
@@ -71,3 +74,16 @@ export {
71
74
  type ProposedDelta,
72
75
  type ProposeResult,
73
76
  } from "./proposer.js";
77
+
78
+ // Injection selection policy (on by default). Re-exported so companion packages
79
+ // and tests can reuse the pure selection/normalization without reaching into
80
+ // internal paths.
81
+ export {
82
+ DEFAULT_INJECTION,
83
+ estimateTokens,
84
+ normalizeInjection,
85
+ selectForInjection,
86
+ type InjectionConfig,
87
+ type NormalizedInjection,
88
+ type SelectionResult,
89
+ } from "./select.js";
package/src/inject.ts CHANGED
@@ -1,13 +1,25 @@
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.
11
+ //
12
+ // WHAT gets rendered is decided by the selection policy in select.ts (on by
13
+ // default): importance-ordered, capped per kind and by a total token budget, so
14
+ // the block stays bounded as the harness accumulates. Configurable / opt-out via
15
+ // harness.json `injection`; the legacy "all items, in order" mode is a toggle.
4
16
 
5
17
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
6
- import { getState } from "./store.js";
18
+ import { adoptOrphans, getState, modelKey, setActiveModelKey } from "./store.js";
19
+ import { KIND_ORDER, selectForInjection, type InjectionConfig } from "./select.js";
20
+ import { loadConfig } from "./config.js";
7
21
  import type { ComponentKind } from "./types.js";
8
22
 
9
- const ORDER: ComponentKind[] = ["prompt", "memory", "skill", "subagent"];
10
-
11
23
  const TITLES: Record<ComponentKind, string> = {
12
24
  prompt: "Self-improved prompt notes",
13
25
  memory: "Remembered facts",
@@ -15,34 +27,66 @@ const TITLES: Record<ComponentKind, string> = {
15
27
  subagent: "Reusable sub-agent specs",
16
28
  };
17
29
 
18
- export function renderHarnessBlock(): string {
19
- const state = getState();
20
- if (state.items.length === 0) return "";
30
+ /**
31
+ * Render the harness block for a single model. Selection (`cfg`) defaults to the
32
+ * shipped policy when omitted — importance-ordered, capped per kind and by a
33
+ * total token budget (see select.ts). Only active items whose ownerModel ===
34
+ * ownerKey are ever considered. An undefined ownerKey (no active model) renders
35
+ * nothing — isolation is strict: unknown model → inject nothing.
36
+ */
37
+ export function renderHarnessBlock(ownerKey?: string, cfg?: InjectionConfig): string {
38
+ if (ownerKey === undefined) return "";
39
+ const items = getState().items;
40
+ if (items.length === 0) return "";
41
+
42
+ const { selected, omitted } = selectForInjection(items, ownerKey, cfg);
43
+ if (selected.length === 0) return "";
21
44
 
22
45
  const sections: string[] = [];
23
- for (const kind of ORDER) {
24
- const items = state.items.filter((i) => i.kind === kind && i.active);
25
- if (items.length === 0) continue;
26
- const bullets = items
27
- .map((i) => `- [${i.id}] ${i.content}`)
28
- .join("\n");
46
+ for (const kind of KIND_ORDER) {
47
+ const forKind = selected.filter((i) => i.kind === kind);
48
+ if (forKind.length === 0) continue;
49
+ const bullets = forKind.map((i) => `- [${i.id}] ${i.content}`).join("\n");
29
50
  sections.push(`### ${TITLES[kind]}\n${bullets}`);
30
51
  }
31
52
  if (sections.length === 0) return "";
32
53
 
33
- return [
54
+ const lines = [
34
55
  "",
35
56
  "## Continual Harness state",
36
57
  "Self-improved notes accumulated from past trajectories via /refine.",
37
58
  "Treat these as durable working context. Update them with the harness_mutate tool when they are wrong or stale.",
38
59
  "",
39
60
  sections.join("\n\n"),
40
- ].join("\n");
61
+ ];
62
+ // Transparency: when the selection policy dropped items, say so — the block is
63
+ // bounded on purpose, and the user should know the store has more than shows.
64
+ if (omitted > 0) {
65
+ lines.push(
66
+ "",
67
+ `_(${omitted} item(s) not shown — below the injection budget. Raise \`injection.maxTokens\`/\`maxPerKind\` in harness.json or run \`/harness prune\`.)_`,
68
+ );
69
+ }
70
+ return lines.join("\n");
41
71
  }
42
72
 
43
73
  export function registerInjection(pi: ExtensionAPI): void {
44
- pi.on("before_agent_start", async (event) => {
45
- const block = renderHarnessBlock();
74
+ pi.on("before_agent_start", async (event, ctx) => {
75
+ const key = modelKey(ctx.model);
76
+ // Cache for the model-facing tools (they have no ctx of their own).
77
+ setActiveModelKey(key);
78
+ // Adopt any orphans to the active model (legacy/import migration). No-op —
79
+ // and no persist — when there is nothing to adopt.
80
+ if (key) {
81
+ adoptOrphans(key, (snapshot, ver) => {
82
+ pi.appendEntry("harness-state", { state: snapshot, version: ver });
83
+ });
84
+ }
85
+ // Read the injection policy from config (cached; tolerant). The default is
86
+ // ON — importance-ordered + bounded — so a growing harness never balloons
87
+ // the system prompt. Opt out via harness.json `injection.enabled: false`.
88
+ const { injection } = await loadConfig();
89
+ const block = renderHarnessBlock(key, injection);
46
90
  if (!block) return;
47
91
  return { systemPrompt: event.systemPrompt + "\n" + block };
48
92
  });
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/select.ts ADDED
@@ -0,0 +1,184 @@
1
+ // Injection selection policy: WHICH active items for the active model get
2
+ // surfaced in the system-prompt block, and in what order.
3
+ //
4
+ // The harness ACCUMULATES notes (create / refine / auto-refine /
5
+ // outcome-promotion), but the system prompt is a finite resource. Without
6
+ // selection, every active item for the model is injected every turn, unbounded —
7
+ // so a long-running session's prompt grows without limit and the most valuable
8
+ // notes compete with the least for attention. Selection is ON BY DEFAULT with
9
+ // conservative knobs that are a NO-OP for small stores (nothing trimmed) and
10
+ // protective for large ones. Opt out entirely with `injection.enabled = false`
11
+ // → legacy "all items, in store order".
12
+ //
13
+ // Policy (pure, deterministic, unit-tested in test/select.test.ts):
14
+ // 1. FILTER — active items bound to the active model (strict per-model
15
+ // isolation, same rule inject.ts always enforced).
16
+ // 2. ORDER — importance desc; ties keep store/insertion order (stable), so
17
+ // the highest-fitness notes lead each section.
18
+ // 3. CAP — `maxPerKind` (balanced sections: no single kind drowns the
19
+ // block), then `maxTokens` (total budget). The budget is filled
20
+ // round-robin across kinds by importance rank, so one kind cannot starve
21
+ // the others; within that order an item that does not fit is *skipped*
22
+ // (not a hard stop), so a single large item never blocks smaller
23
+ // higher-priority ones after it.
24
+ //
25
+ // Kept pure + side-effect-free on purpose: inject.ts is thin glue over this, and
26
+ // the decision is fully unit-testable without a live pi runtime (the same
27
+ // factoring pattern remind.ts / auto-refine.ts / outcome.ts use).
28
+
29
+ import type { ComponentKind, HarnessItem } from "./types.js";
30
+
31
+ /** Canonical kind order (shared with inject.ts so there is one source of truth). */
32
+ export const KIND_ORDER: ComponentKind[] = ["prompt", "memory", "skill", "subagent"];
33
+
34
+ /** User-facing injection config (all optional; defaults applied on read). */
35
+ export interface InjectionConfig {
36
+ /** Master switch. `false` → selection disabled (legacy: inject all, in store
37
+ * order). Default `true`. */
38
+ enabled?: boolean;
39
+ /** Max total tokens (≈ chars / charsPerToken) for the rendered block,
40
+ * including the intro + section headers + items. Default 1500. */
41
+ maxTokens?: number;
42
+ /** Max items surfaced per kind. Default 10. */
43
+ maxPerKind?: number;
44
+ /** Token estimate: characters per token. Default 4. */
45
+ charsPerToken?: number;
46
+ }
47
+
48
+ /** Fully-resolved injection config (post-defaults; what loadConfig returns). */
49
+ export interface NormalizedInjection {
50
+ enabled: boolean;
51
+ maxTokens: number;
52
+ maxPerKind: number;
53
+ charsPerToken: number;
54
+ }
55
+
56
+ /** The shipped defaults — ON, but generous enough to never trim a small store. */
57
+ export const DEFAULT_INJECTION: NormalizedInjection = {
58
+ enabled: true,
59
+ maxTokens: 1500,
60
+ maxPerKind: 10,
61
+ charsPerToken: 4,
62
+ };
63
+
64
+ // Token-overhead estimates for the parts of the rendered block that are not item
65
+ // text, so the budget reflects the real appended size (see inject.ts). These are
66
+ // deliberately rough heuristics; the goal is a meaningful ceiling, not exactness.
67
+ const INTRO_TOKENS = 55; // "## Continual Harness state" + the two description lines
68
+ const SECTION_HEADER_TOKENS = 8; // "### <title>" per present section
69
+ const FOOTER_TOKENS = 18; // the omitted-count footer, when it renders
70
+
71
+ /** Resolve a (possibly partial / absent / malformed) config against the defaults.
72
+ * Defensive: bad types fall back to defaults, never throws. */
73
+ export function normalizeInjection(cfg?: Partial<InjectionConfig>): NormalizedInjection {
74
+ const num = (v: unknown): v is number => typeof v === "number" && Number.isFinite(v) && v > 0;
75
+ return {
76
+ enabled: typeof cfg?.enabled === "boolean" ? cfg.enabled : DEFAULT_INJECTION.enabled,
77
+ maxTokens: num(cfg?.maxTokens) ? cfg.maxTokens : DEFAULT_INJECTION.maxTokens,
78
+ maxPerKind: num(cfg?.maxPerKind) ? cfg.maxPerKind : DEFAULT_INJECTION.maxPerKind,
79
+ charsPerToken: num(cfg?.charsPerToken) ? cfg.charsPerToken : DEFAULT_INJECTION.charsPerToken,
80
+ };
81
+ }
82
+
83
+ /** Rough token estimate for a string. Exported for tests/companions. */
84
+ export function estimateTokens(text: string, charsPerToken = DEFAULT_INJECTION.charsPerToken): number {
85
+ return Math.ceil(text.length / charsPerToken);
86
+ }
87
+
88
+ export interface SelectionResult {
89
+ /** Chosen items, grouped by KIND_ORDER, importance-desc within each group. */
90
+ selected: HarnessItem[];
91
+ /** Active items for the model that did NOT make the cut (per-kind + budget). */
92
+ omitted: number;
93
+ /** True when any item was dropped (drives the transparency footer). */
94
+ truncated: boolean;
95
+ }
96
+
97
+ interface Ranked {
98
+ item: HarnessItem;
99
+ index: number; // store position, for a stable tie-break on equal importance
100
+ }
101
+
102
+ /** importance desc, then store index asc (stable). */
103
+ function byImportance(a: Ranked, b: Ranked): number {
104
+ if (b.item.importance !== a.item.importance) return b.item.importance - a.item.importance;
105
+ return a.index - b.index;
106
+ }
107
+
108
+ /**
109
+ * Select the items to inject for `ownerKey`. Pure: takes a snapshot, returns a
110
+ * decision. See the module doc for the policy.
111
+ *
112
+ * `items` is read-only here — callers (inject.ts) pass `getState().items` plus
113
+ * the active model key; nothing is mutated.
114
+ */
115
+ export function selectForInjection(
116
+ items: HarnessItem[],
117
+ ownerKey: string,
118
+ cfg?: Partial<InjectionConfig>,
119
+ ): SelectionResult {
120
+ const n = normalizeInjection(cfg);
121
+
122
+ // 1. FILTER: group active items bound to the active model, per kind, in
123
+ // store order. (Importance ranking happens below, only when enabled.)
124
+ const groups = new Map<ComponentKind, Ranked[]>();
125
+ for (const kind of KIND_ORDER) groups.set(kind, []);
126
+ items.forEach((item, index) => {
127
+ if (item.active && item.ownerModel === ownerKey) {
128
+ groups.get(item.kind)!.push({ item, index });
129
+ }
130
+ });
131
+ if (!n.enabled) {
132
+ // Legacy opt-out: every active item for the model, grouped by kind, in
133
+ // STORE (insertion) order — deliberately NOT importance-sorted. Returning to
134
+ // the pre-selection behaviour is the whole point of `enabled: false`.
135
+ const selected = KIND_ORDER.flatMap((kind) => groups.get(kind)!.map((r) => r.item));
136
+ return { selected, omitted: 0, truncated: false };
137
+ }
138
+
139
+ // Importance desc, stable on store index (ties keep insertion order).
140
+ for (const arr of groups.values()) arr.sort(byImportance);
141
+
142
+ // 2a. CAP per kind (maxPerKind): drop the lowest-importance tail of each kind.
143
+ let omitted = 0;
144
+ const capped = new Map<ComponentKind, Ranked[]>();
145
+ for (const kind of KIND_ORDER) {
146
+ const arr = groups.get(kind)!;
147
+ const kept = arr.slice(0, n.maxPerKind);
148
+ omitted += arr.length - kept.length;
149
+ capped.set(kind, kept);
150
+ }
151
+
152
+ // 2b. CAP total (maxTokens): flatten round-robin (rank-major, kind order
153
+ // within a rank) so importance is prioritized across kinds fairly, then take
154
+ // greedily while the budget allows — skipping (not stopping on) an item that
155
+ // does not fit, so a large item never blocks smaller higher-priority ones.
156
+ const presentKinds = KIND_ORDER.filter((k) => capped.get(k)!.length > 0);
157
+ const maxRank = presentKinds.reduce((m, k) => Math.max(m, capped.get(k)!.length), 0);
158
+ const queue: Ranked[] = [];
159
+ for (let rank = 0; rank < maxRank; rank++) {
160
+ for (const kind of presentKinds) {
161
+ const r = capped.get(kind)![rank];
162
+ if (r) queue.push(r);
163
+ }
164
+ }
165
+
166
+ const overhead = INTRO_TOKENS + presentKinds.length * SECTION_HEADER_TOKENS + FOOTER_TOKENS;
167
+ let remaining = Math.max(0, n.maxTokens - overhead);
168
+ const taken: Ranked[] = [];
169
+ for (const r of queue) {
170
+ const cost = estimateTokens(`- [${r.item.id}] ${r.item.content}`, n.charsPerToken);
171
+ if (cost <= remaining) {
172
+ remaining -= cost;
173
+ taken.push(r);
174
+ }
175
+ }
176
+ omitted += queue.length - taken.length;
177
+
178
+ // Re-group the survivors into KIND_ORDER sections (importance-desc within each).
179
+ const selected = KIND_ORDER.flatMap((kind) =>
180
+ taken.filter((r) => r.item.kind === kind).map((r) => r.item),
181
+ );
182
+
183
+ return { selected, omitted, truncated: omitted > 0 };
184
+ }
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