pi-continual-harness 0.7.0 → 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
@@ -116,6 +116,7 @@ Optional config at `~/.pi/agent/harness.json` (missing or malformed → defaults
116
116
  {
117
117
  "durableScope": "global",
118
118
  "proposer": "steering",
119
+ "injection": { "enabled": true, "maxTokens": 1500, "maxPerKind": 10, "charsPerToken": 4 },
119
120
  "remindRefine": { "enabled": false, "everyTurns": 50 },
120
121
  "autoRefine": { "enabled": false, "everyTurns": 100, "commit": false },
121
122
  "outcomeImportance": { "enabled": false, "bump": 0.03 }
@@ -126,6 +127,11 @@ Optional config at `~/.pi/agent/harness.json` (missing or malformed → defaults
126
127
  `~/.pi/agent/harness-state.md`; `"project"` writes to
127
128
  `~/.pi/agent/harness-state/<slug>.md` (slug derived from `cwd`) so each
128
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.
129
135
  - **`proposer`** — which delta proposer `/refine` and auto-refine use. Defaults
130
136
  to `steering` (the agent reasons via a steering message). `dedupe` applies a
131
137
  rule-based dedupe directly. See [Proposers](#proposers).
@@ -204,6 +210,47 @@ In particular, `/harness push-mem` pushes *every* model's active items into
204
210
  pi-mem by default (which can yield near-duplicate memories across models); pass
205
211
  `--model <provider/id|active>` to scope it to one model.
206
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
+
207
254
  ## Proposers
208
255
 
209
256
  `/refine` is split into two stages: **propose** (given evidence + state, decide
@@ -263,7 +310,7 @@ Then `"my-proposer"` is selectable via `/refine --proposer my-proposer` or
263
310
 
264
311
  ## Status
265
312
 
266
- 0.7.x. Implemented:
313
+ 0.8.x. Implemented:
267
314
 
268
315
  - Unified harness-state store with branch-local snapshots (`/tree` rollback).
269
316
  - Online `/refine` + `harness_mutate` / `harness_list` tools.
@@ -282,6 +329,10 @@ Then `"my-proposer"` is selectable via `/refine --proposer my-proposer` or
282
329
  only for that model; new items are stamped automatically and orphans adopted
283
330
  on first contact. A new model id starts from a blank harness, and the durable
284
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.
285
336
 
286
337
  Open extension points (see `docs/ROADMAP.md`):
287
338
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-continual-harness",
3
- "version": "0.7.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/index.ts CHANGED
@@ -74,3 +74,16 @@ export {
74
74
  type ProposedDelta,
75
75
  type ProposeResult,
76
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
@@ -8,13 +8,18 @@
8
8
  // WITH ctx.model, so it: (1) caches the active model key for the tools,
9
9
  // (2) adopts any orphan items to the active model (the migration policy), and
10
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.
11
16
 
12
17
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
13
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";
14
21
  import type { ComponentKind } from "./types.js";
15
22
 
16
- const ORDER: ComponentKind[] = ["prompt", "memory", "skill", "subagent"];
17
-
18
23
  const TITLES: Record<ComponentKind, string> = {
19
24
  prompt: "Self-improved prompt notes",
20
25
  memory: "Remembered facts",
@@ -23,34 +28,46 @@ const TITLES: Record<ComponentKind, string> = {
23
28
  };
24
29
 
25
30
  /**
26
- * Render the harness block for a single model. Only active items whose
27
- * ownerModel === ownerKey are included. An undefined ownerKey (no active model)
28
- * renders nothing — isolation is strict: unknown model → inject nothing.
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.
29
36
  */
30
- export function renderHarnessBlock(ownerKey?: string): string {
37
+ export function renderHarnessBlock(ownerKey?: string, cfg?: InjectionConfig): string {
31
38
  if (ownerKey === undefined) return "";
32
- const state = getState();
33
- if (state.items.length === 0) 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 "";
34
44
 
35
45
  const sections: string[] = [];
36
- for (const kind of ORDER) {
37
- const items = state.items.filter((i) => i.kind === kind && i.active && i.ownerModel === ownerKey);
38
- if (items.length === 0) continue;
39
- const bullets = items
40
- .map((i) => `- [${i.id}] ${i.content}`)
41
- .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");
42
50
  sections.push(`### ${TITLES[kind]}\n${bullets}`);
43
51
  }
44
52
  if (sections.length === 0) return "";
45
53
 
46
- return [
54
+ const lines = [
47
55
  "",
48
56
  "## Continual Harness state",
49
57
  "Self-improved notes accumulated from past trajectories via /refine.",
50
58
  "Treat these as durable working context. Update them with the harness_mutate tool when they are wrong or stale.",
51
59
  "",
52
60
  sections.join("\n\n"),
53
- ].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");
54
71
  }
55
72
 
56
73
  export function registerInjection(pi: ExtensionAPI): void {
@@ -65,7 +82,11 @@ export function registerInjection(pi: ExtensionAPI): void {
65
82
  pi.appendEntry("harness-state", { state: snapshot, version: ver });
66
83
  });
67
84
  }
68
- const block = renderHarnessBlock(key);
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);
69
90
  if (!block) return;
70
91
  return { systemPrompt: event.systemPrompt + "\n" + block };
71
92
  });
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
+ }