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 +109 -10
- package/package.json +1 -1
- package/src/config.ts +12 -0
- package/src/harness.ts +26 -7
- package/src/index.ts +17 -1
- package/src/inject.ts +60 -16
- package/src/outcome.ts +9 -2
- package/src/proposer.ts +3 -0
- package/src/refine.ts +12 -2
- package/src/select.ts +184 -0
- 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
|
|
|
@@ -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**.
|
|
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.
|
|
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.
|
|
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(
|
|
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) {
|
|
@@ -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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
|
24
|
-
const
|
|
25
|
-
if (
|
|
26
|
-
const bullets =
|
|
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
|
-
|
|
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
|
-
]
|
|
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
|
|
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
|
-
|
|
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/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
|
-
|
|
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
|
|